Merge pull request #315 from Psychotoxical/feat/waveform-loudness-cache

feat(playback): waveform loudness cache (EBU R128 + persistent analysis)
This commit is contained in:
cucadmuh
2026-04-26 03:29:54 +03:00
committed by GitHub
24 changed files with 3102 additions and 83 deletions
+75
View File
@@ -944,6 +944,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dasp_frame"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
dependencies = [
"dasp_sample",
]
[[package]] [[package]]
name = "dasp_sample" name = "dasp_sample"
version = "0.11.0" version = "0.11.0"
@@ -1169,6 +1178,18 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ebur128"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
dependencies = [
"bitflags 1.3.2",
"dasp_frame",
"dasp_sample",
"smallvec",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -1302,6 +1323,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "1.9.0" version = "1.9.0"
@@ -1908,6 +1941,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -2548,6 +2590,17 @@ dependencies = [
"redox_syscall 0.7.4", "redox_syscall 0.7.4",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.3.8" version = "0.3.8"
@@ -3657,6 +3710,7 @@ version = "1.43.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"discord-rich-presence", "discord-rich-presence",
"ebur128",
"futures-util", "futures-util",
"id3", "id3",
"libc", "libc",
@@ -3665,6 +3719,7 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"ringbuf", "ringbuf",
"rodio", "rodio",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"souvlaki", "souvlaki",
@@ -4116,6 +4171,20 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -6115,6 +6184,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
+2
View File
@@ -49,6 +49,8 @@ lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] } sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4" id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7" symphonia-adapter-libopus = "0.2.7"
rusqlite = { version = "0.37", features = ["bundled"] }
ebur128 = "0.1"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
hw_params.set_buffer_size(v as alsa::pcm::Frames)?; hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
} }
BufferSize::Default => { BufferSize::Default => {
// These values together represent a moderate latency and wakeup interval. // Larger buffer than upstream cpal default: after Symphonia seeks or CPU
// Without them, we are at the mercy of the device // spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?; // ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?; hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?;
hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?;
} }
} }
+824
View File
@@ -0,0 +1,824 @@
use std::path::PathBuf;
use std::io::Cursor;
use std::sync::Mutex;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use ebur128::{EbuR128, Mode as Ebur128Mode};
use rusqlite::{params, Connection, OptionalExtension};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tauri::Manager;
pub const WAVEFORM_ALGO_VERSION: i64 = 3;
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
#[derive(Debug, Clone)]
pub struct TrackKey {
pub track_id: String,
pub md5_16kb: String,
}
#[derive(Debug, Clone)]
pub struct WaveformEntry {
pub bins: Vec<u8>,
pub bin_count: i64,
pub is_partial: bool,
pub known_until_sec: f64,
pub duration_sec: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessEntry {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessSnapshot {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
pub struct AnalysisCache {
conn: Mutex<Connection>,
}
/// Ranged HTTP seeding uses `stream:<subsonicId>` (see `playback_identity`); backfill
/// and IPC often use the bare `<subsonicId>`. Rows may exist under either key.
fn track_id_cache_variants(id: &str) -> Vec<String> {
let mut out = vec![id.to_string()];
if let Some(bare) = id.strip_prefix("stream:") {
if !bare.is_empty() {
out.push(bare.to_string());
}
} else {
out.push(format!("stream:{id}"));
}
out
}
impl AnalysisCache {
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
let db_path = analysis_db_path(app)?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_connection(&conn).map_err(|e| e.to_string())?;
migrate_schema(&conn).map_err(|e| e.to_string())?;
Ok(Self { conn: Mutex::new(conn) })
}
/// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant).
pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result<u64, String> {
if track_id.trim().is_empty() {
return Ok(0);
}
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let mut total: u64 = 0;
for tid in track_id_cache_variants(track_id) {
let n = conn
.execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid])
.map_err(|e| e.to_string())?;
total = total.saturating_add(n as u64);
}
Ok(total)
}
/// Remove all cached waveform rows across all tracks/variants.
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let n = conn
.execute("DELETE FROM waveform_cache", [])
.map_err(|e| e.to_string())?;
Ok(n as u64)
}
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
let now = now_unix_ts();
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO analysis_track (
track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
status = excluded.status,
waveform_algo_version = excluded.waveform_algo_version,
loudness_algo_version = excluded.loudness_algo_version,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
status,
WAVEFORM_ALGO_VERSION,
LOUDNESS_ALGO_VERSION,
now
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO waveform_cache (
track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
bins = excluded.bins,
bin_count = excluded.bin_count,
is_partial = excluded.is_partial,
known_until_sec = excluded.known_until_sec,
duration_sec = excluded.duration_sec,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.bins,
entry.bin_count,
if entry.is_partial { 1 } else { 0 },
entry.known_until_sec,
entry.duration_sec,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO loudness_cache (
track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET
integrated_lufs = excluded.integrated_lufs,
true_peak = excluded.true_peak,
recommended_gain_db = excluded.recommended_gain_db,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.integrated_lufs,
entry.true_peak,
entry.recommended_gain_db,
entry.target_lufs,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.query_row(
r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND w.md5_16kb = ?2
AND a.waveform_algo_version = ?3
"#,
params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())
}
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND a.waveform_algo_version = ?2
ORDER BY w.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if row.is_some() {
return Ok(row);
}
}
Ok(None)
}
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at
FROM loudness_cache l
JOIN analysis_track a
ON a.track_id = l.track_id
AND a.md5_16kb = l.md5_16kb
WHERE l.track_id = ?1
AND a.loudness_algo_version = ?2
ORDER BY l.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, LOUDNESS_ALGO_VERSION],
|row| {
Ok(LoudnessSnapshot {
integrated_lufs: row.get(0)?,
true_peak: row.get(1)?,
recommended_gain_db: row.get(2)?,
target_lufs: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if row.is_some() {
return Ok(row);
}
}
Ok(None)
}
}
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
let mut recommended_gain_db = target_lufs - integrated_lufs;
if true_peak > 0.0 {
let true_peak_dbtp = 20.0 * true_peak.log10();
let max_gain_db = -1.0 - true_peak_dbtp;
if recommended_gain_db > max_gain_db {
recommended_gain_db = max_gain_db;
}
}
recommended_gain_db.clamp(-24.0, 24.0)
}
/// Result of [`seed_from_bytes`]: callers use it to avoid redundant UI events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeedFromBytesOutcome {
/// Wrote waveform (and loudness when PCM decode succeeded).
Upserted,
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
SkippedWaveformCacheHit,
/// `AnalysisCache` was not registered on the app handle.
SkippedNoAnalysisCache,
}
pub fn seed_from_bytes(
app: &tauri::AppHandle,
track_id: &str,
bytes: &[u8],
) -> Result<SeedFromBytesOutcome, String> {
let started = Instant::now();
let Some(cache) = app.try_state::<AnalysisCache>() else {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
track_id,
bytes.len()
);
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
};
let key = TrackKey {
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
}
crate::app_deprintln!(
"[analysis][waveform] build start track_id={} bytes={} md5_16kb={}",
track_id,
bytes.len(),
key.md5_16kb
);
let build = (|| -> Result<(bool, usize), String> {
cache.touch_track_status(&key, "queued")?;
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
(
bins,
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
true,
)
}
None => (derive_waveform_bins(bytes, 500), None, false),
};
let bins_len = wf_bins.len();
let waveform = WaveformEntry {
bins: wf_bins,
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: now_unix_ts(),
};
cache.upsert_waveform(&key, &waveform)?;
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
let loudness = LoudnessEntry {
integrated_lufs,
true_peak,
recommended_gain_db,
target_lufs,
updated_at: now_unix_ts(),
};
cache.upsert_loudness(&key, &loudness)?;
}
cache.touch_track_status(&key, "ready")?;
Ok((used_pcm_decode, bins_len))
})();
let elapsed_ms = started.elapsed().as_millis();
match &build {
Ok((used_pcm_decode, bins_len)) => {
crate::app_deprintln!(
"[analysis][waveform] build done track_id={} elapsed_ms={} path={} bins_len={}",
track_id,
elapsed_ms,
if *used_pcm_decode {
"pcm_ebur128"
} else {
"byte_envelope"
},
bins_len
);
}
Err(e) => {
crate::app_deprintln!(
"[analysis][waveform] build failed track_id={} elapsed_ms={} err={}",
track_id,
elapsed_ms,
e
);
}
}
match build {
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
Err(e) => Err(e),
}
}
fn now_unix_ts() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
if bin_count == 0 || bytes.is_empty() {
return Vec::new();
}
let mut out = vec![0u8; bin_count];
for (i, slot) in out.iter_mut().enumerate() {
let start = i * bytes.len() / bin_count;
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
let mut peak: u8 = 0;
for &b in &bytes[start..end] {
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
}
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
}
out
}
struct PcmScanResult {
bins: Vec<u8>,
loudness: Option<(f64, f64, f64, f64)>,
}
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
fn analyze_loudness_and_waveform(
bytes: &[u8],
target_lufs: f64,
bin_count: usize,
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
if bytes.is_empty() || bin_count == 0 {
return None;
}
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
if decoded_frames == 0 {
return None;
}
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
let (i, t, r, tgt) = scanned.loudness?;
Some((i, t, r, tgt, scanned.bins))
}
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
/// `codec_params.n_frames` when the container reports total track length — used
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
/// when the buffer grows.
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
let mut format = probed.format;
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let mut decoder = symphonia::default::get_codecs()
.make(&codec_params, &DecoderOptions::default())
.ok()?;
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
if n < n_ch || n % n_ch != 0 {
continue;
}
total += (n / n_ch) as u64;
loop_i = loop_i.wrapping_add(1);
if loop_i % 128 == 0 {
std::thread::yield_now();
}
}
if total == 0 {
None
} else {
Some((total, timeline_hint))
}
}
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
let bin_count = bin_max.len();
if bin_count == 0 {
return Vec::new();
}
let mut sorted: Vec<f32> = bin_max.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
let range = (p99 - p5).max(1e-8);
let mut out = vec![0u8; bin_count];
for i in 0..bin_count {
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
let shaped = t.powf(0.52);
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
}
out
}
fn decode_scan_pcm(
bytes: &[u8],
bin_count: usize,
decoded_frames: u64,
timeline_hint: Option<u64>,
loudness_target_lufs: Option<f64>,
) -> Option<PcmScanResult> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
let mut format = probed.format;
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let codec_params = track.codec_params.clone();
let mut decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
return None;
}
};
let mut bin_max = vec![0.0f32; bin_count];
let mut ebu: Option<EbuR128> = None;
let mut ebu_channels: u32 = 0;
let mut sample_peak_abs = 0.0_f64;
let mut fed_any_frames = false;
let mut sample_idx: u64 = 0;
let mut loop_i: u32 = 0;
// Fixed timeline from metadata when available; otherwise fall back to decoded
// length (full-buffer analysis only — partial byte windows still shift, but
// then we usually lack n_frames anyway).
let bin_grid_frames = timeline_hint
.map(|n| n.max(decoded_frames))
.unwrap_or(decoded_frames)
.max(1);
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = spec.channels.count() as u32;
let sr = spec.rate;
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
ebu_channels = ch;
}
Err(e) => {
crate::app_deprintln!(
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
ch,
sr,
e
);
return None;
}
}
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || slice.len() % n_ch != 0 {
continue;
}
let frames = slice.len() / n_ch;
for f in 0..frames {
let base = f * n_ch;
let mut acc = 0.0f32;
for c in 0..n_ch {
acc += slice[base + c];
}
let mono = acc / (n_ch as f32);
let mag = mono.abs();
if mag.is_finite() {
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
let bin = bin.min(bin_count.saturating_sub(1));
bin_max[bin] = bin_max[bin].max(mag);
}
for c in 0..n_ch {
let v = (slice[base + c] as f64).abs();
if v.is_finite() && v > sample_peak_abs {
sample_peak_abs = v;
}
}
sample_idx += 1;
}
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(samples.samples()) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
return None;
}
}
}
}
loop_i = loop_i.wrapping_add(1);
if loop_i % 128 == 0 {
std::thread::yield_now();
}
}
let bins = normalize_peak_bins(&bin_max);
let loudness = if let Some(target_lufs) = loudness_target_lufs {
if !fed_any_frames {
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
return None;
}
let Some(ebu) = ebu else {
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
return None;
};
let integrated_lufs = match ebu.loudness_global() {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
return None;
}
};
if !integrated_lufs.is_finite() {
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
return None;
}
let mut true_peak = 0.0_f64;
let mut true_peak_ok = true;
for ch in 0..ebu_channels {
match ebu.true_peak(ch) {
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
Ok(_) => {}
Err(e) => {
true_peak_ok = false;
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
break;
}
}
}
if !true_peak_ok {
true_peak = sample_peak_abs;
}
let recommended_gain_db =
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
} else {
None
};
Some(PcmScanResult { bins, loudness })
}
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
Ok(base.join("audio-analysis.sqlite"))
}
fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS analysis_track (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS waveform_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS loudness_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb, target_lufs)
);
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
"#,
)?;
Ok(())
}
+727 -26
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio; mod audio;
mod analysis_cache;
pub mod cli; pub mod cli;
mod discord; mod discord;
pub(crate) mod logging; pub(crate) mod logging;
@@ -59,6 +60,12 @@ fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
FLAGS.get_or_init(|| Mutex::new(HashMap::new())) FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
} }
/// Tracks analysis backfill jobs already running per track_id.
fn analysis_backfill_inflight() -> &'static Mutex<std::collections::HashSet<String>> {
static SET: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
SET.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
}
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed. /// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. /// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>; type TrayState = Mutex<Option<TrayIcon>>;
@@ -67,6 +74,34 @@ type TrayState = Mutex<Option<TrayIcon>>;
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>; type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformCachePayload {
bins: Vec<u8>,
bin_count: i64,
is_partial: bool,
known_until_sec: f64,
duration_sec: f64,
updated_at: i64,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformUpdatedPayload {
track_id: String,
is_partial: bool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct LoudnessCachePayload {
integrated_lufs: f64,
true_peak: f64,
recommended_gain_db: f64,
target_lufs: f64,
updated_at: i64,
}
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
format!("Hello, {}!", name) format!("Hello, {}!", name)
@@ -1158,6 +1193,180 @@ fn check_dir_accessible(path: String) -> bool {
std::path::Path::new(&path).is_dir() std::path::Path::new(&path).is_dir()
} }
#[tauri::command]
fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let key = analysis_cache::TrackKey {
track_id: track_id.clone(),
md5_16kb: md5_16kb.clone(),
};
let row = cache.get_waveform(&key)?;
match &row {
Some(v) => {
crate::app_deprintln!(
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
track_id,
md5_16kb,
v.bins.len(),
v.bin_count,
v.updated_at
);
}
None => {
crate::app_deprintln!(
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
track_id,
md5_16kb
);
}
}
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
fn analysis_get_waveform_for_track(
track_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let row = cache.get_latest_waveform_for_track(&track_id)?;
match &row {
Some(v) => {
crate::app_deprintln!(
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
track_id,
v.bins.len(),
v.bin_count,
v.updated_at
);
}
None => {
crate::app_deprintln!(
"[analysis][waveform] db miss track_id={}",
track_id
);
}
}
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<LoudnessCachePayload>, String> {
let row = cache.get_latest_loudness_for_track(&track_id)?;
Ok(row.map(|v| {
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
v.integrated_lufs,
v.true_peak,
requested_target,
);
LoudnessCachePayload {
integrated_lufs: v.integrated_lufs,
true_peak: v.true_peak,
recommended_gain_db,
target_lufs: requested_target,
updated_at: v.updated_at,
}}))
}
#[tauri::command]
fn analysis_delete_loudness_for_track(
track_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_loudness_for_track_id(&track_id)
}
#[tauri::command]
fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_all_waveforms()
}
#[tauri::command]
fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
app: tauri::AppHandle,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
{
let mut inflight = analysis_backfill_inflight()
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
if inflight.contains(&track_id) {
return Ok(());
}
inflight.insert(track_id.clone());
}
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
crate::app_deprintln!("[analysis] backfill queued: {}", track_id);
let result = async {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty response".to_string());
}
let has_loudness = enqueue_analysis_seed(&app_clone, &track_id, &bytes)?;
Ok::<bool, String>(has_loudness)
}
.await;
match result {
Ok(has_loudness) => crate::app_deprintln!(
"[analysis] backfill ready: {} (has_loudness={})",
track_id,
has_loudness
),
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
}
if let Ok(mut inflight) = analysis_backfill_inflight().lock() {
inflight.remove(&track_id);
}
});
Ok(())
}
// ─── Offline Track Cache ────────────────────────────────────────────────────── // ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Streams an HTTP response body directly to `dest_path` in small chunks. /// Streams an HTTP response body directly to `dest_path` in small chunks.
@@ -1178,6 +1387,48 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path
Ok(()) Ok(())
} }
fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
let outcome = analysis_cache::seed_from_bytes(app, track_id, bytes).map_err(|e| {
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
e
})?;
if outcome == analysis_cache::SeedFromBytesOutcome::Upserted {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: track_id.to_string(),
is_partial: false,
},
);
}
let has_loudness = app
.try_state::<analysis_cache::AnalysisCache>()
.and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten())
.is_some();
crate::app_deprintln!(
"[analysis] seed result track_id={} bytes={} has_loudness={}",
track_id,
bytes.len(),
has_loudness
);
Ok(has_loudness)
}
async fn enqueue_analysis_seed_from_file(
app: &tauri::AppHandle,
track_id: &str,
file_path: &std::path::Path,
) {
let bytes = match tokio::fs::read(file_path).await {
Ok(v) => v,
Err(_) => return,
};
if bytes.is_empty() {
return;
}
let _ = enqueue_analysis_seed(app, track_id, &bytes);
}
/// Downloads a single track to the app's offline cache directory. /// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later /// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine. /// construct a `psysonic-local://<path>` URL for the audio engine.
@@ -1244,6 +1495,8 @@ async fn download_track_offline(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
Ok(path_str) Ok(path_str)
} }
@@ -1792,6 +2045,8 @@ async fn download_track_hot_cache(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
let size = tokio::fs::metadata(&file_path) let size = tokio::fs::metadata(&file_path)
.await .await
.map(|m| m.len()) .map(|m| m.len())
@@ -1845,6 +2100,8 @@ async fn promote_stream_cache_to_hot_cache(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let _ = enqueue_analysis_seed(&app, &track_id, &bytes);
let size = tokio::fs::metadata(&file_path) let size = tokio::fs::metadata(&file_path)
.await .await
.map(|m| m.len()) .map(|m| m.len())
@@ -3609,6 +3866,13 @@ pub fn run() {
})) }))
.setup(|app| { .setup(|app| {
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(&app.handle())
.map_err(|e| format!("analysis cache init failed: {e}"))?;
app.manage(cache);
}
// ── Custom title bar on Linux ───────────────────────────────── // ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations on all Linux so the React TitleBar // Remove OS window decorations on all Linux so the React TitleBar
// can take over. The frontend checks is_tiling_wm() to decide // can take over. The frontend checks is_tiling_wm() to decide
@@ -3847,6 +4111,7 @@ pub fn run() {
audio::audio_play_radio, audio::audio_play_radio,
audio::audio_set_crossfade, audio::audio_set_crossfade,
audio::audio_set_gapless, audio::audio_set_gapless,
audio::audio_set_normalization,
audio::audio_list_devices, audio::audio_list_devices,
audio::audio_canonicalize_selected_device, audio::audio_canonicalize_selected_device,
audio::audio_default_output_device_name, audio::audio_default_output_device_name,
@@ -3878,6 +4143,12 @@ pub fn run() {
fetch_json_url, fetch_json_url,
fetch_icy_metadata, fetch_icy_metadata,
resolve_stream_url, resolve_stream_url,
analysis_get_waveform,
analysis_get_waveform_for_track,
analysis_get_loudness_for_track,
analysis_delete_loudness_for_track,
analysis_delete_all_waveforms,
analysis_enqueue_seed_from_url,
download_track_offline, download_track_offline,
delete_offline_track, delete_offline_track,
get_offline_cache_size, get_offline_cache_size,
+178 -3
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useMemo, useEffect } from 'react'; import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue'; import OrbitGuestQueue from './OrbitGuestQueue';
@@ -304,6 +305,9 @@ function QueuePanelHostOrSolo() {
const suppressNextAutoScrollRef = useRef(false); const suppressNextAutoScrollRef = useRef(false);
const playbackSource = usePlayerStore(s => s.currentPlaybackSource); const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled); const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs); const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
@@ -313,6 +317,8 @@ function QueuePanelHostOrSolo() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled); const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const replayGainMode = useAuthStore(s => s.replayGainMode);
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab); const setTab = useLyricsStore(s => s.setTab);
@@ -320,10 +326,17 @@ function QueuePanelHostOrSolo() {
const [showRemainingTime, setShowRemainingTime] = useState(false); const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
const expandReplayGain = useThemeStore(s => s.expandReplayGain); const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null); const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null); const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
useEffect(() => { useEffect(() => {
if (!showCrossfadePopover) return; if (!showCrossfadePopover) return;
@@ -338,6 +351,64 @@ function QueuePanelHostOrSolo() {
return () => document.removeEventListener('mousedown', handle); return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]); }, [showCrossfadePopover]);
useEffect(() => {
if (!lufsTgtOpen) return;
const handle = (e: MouseEvent) => {
if (
lufsTgtBtnRef.current?.contains(e.target as Node) ||
lufsTgtMenuRef.current?.contains(e.target as Node)
) return;
setLufsTgtOpen(false);
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [lufsTgtOpen]);
const updateLufsTgtPopStyle = () => {
if (!lufsTgtBtnRef.current) return;
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
const MARGIN = 6;
const WIDTH = 160;
const MAX_H = 220;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(rect.right - WIDTH, 8),
window.innerWidth - WIDTH - 8,
);
setLufsTgtPopStyle({
position: 'fixed',
left,
width: WIDTH,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
};
useLayoutEffect(() => {
if (!lufsTgtOpen) return;
updateLufsTgtPopStyle();
}, [lufsTgtOpen]);
useEffect(() => {
if (!lufsTgtOpen) return;
const onResize = () => updateLufsTgtPopStyle();
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onResize, true);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onResize, true);
};
}, [lufsTgtOpen]);
useEffect(() => {
if (!expandReplayGain) setLufsTgtOpen(false);
}, [expandReplayGain]);
// Tracks which queue index is being psy-dragged for opacity visual feedback // Tracks which queue index is being psy-dragged for opacity visual feedback
const psyDragFromIdxRef = useRef<number | null>(null); const psyDragFromIdxRef = useRef<number | null>(null);
@@ -534,8 +605,15 @@ function QueuePanelHostOrSolo() {
const rgParts = formatQueueReplayGainParts(currentTrack, t); const rgParts = formatQueueReplayGainParts(currentTrack, t);
const baseLine = baseParts.join(' · '); const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · '); const rgLine = rgParts.join(' · ');
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '—';
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
const targetLabel = `${tgtNum} LUFS`;
if (!baseLine && !rgLine && !playbackSource) return null; if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine; const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
return ( return (
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}> <div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack"> <div className="queue-current-tech-stack">
@@ -558,7 +636,7 @@ function QueuePanelHostOrSolo() {
</span> </span>
)} )}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>} {baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && ( {!isLoudnessActive && rgLine && (
<button <button
type="button" type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`} className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
@@ -571,6 +649,19 @@ function QueuePanelHostOrSolo() {
<ChevronDown size={9} strokeWidth={2.5} /> <ChevronDown size={9} strokeWidth={2.5} />
</button> </button>
)} )}
{isLoudnessActive && (
<button
type="button"
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
aria-expanded={showLufsLine}
aria-label="LUFS"
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
LUFS
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
</div> </div>
{showRgLine && ( {showRgLine && (
<span className="queue-current-tech-rg"> <span className="queue-current-tech-rg">
@@ -578,6 +669,90 @@ function QueuePanelHostOrSolo() {
{' · '}{rgLine} {' · '}{rgLine}
</span> </span>
)} )}
{showLufsLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">Loudness</span>
{' · '}
<button
type="button"
className="queue-current-tech-metric"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(false);
void reanalyzeLoudnessForTrack(currentTrack.id);
}}
data-tooltip="Clear cached loudness and re-analyze this track"
>
{liveGainLabel}
</button>
{' · '}
<span className="queue-current-tech-rg-label">TGT</span>
{' · '}
<button
type="button"
ref={lufsTgtBtnRef}
className="queue-current-tech-metric"
onClick={e => {
e.stopPropagation();
setLufsTgtOpen(v => !v);
}}
data-tooltip="Change target integrated loudness"
aria-haspopup="listbox"
aria-expanded={lufsTgtOpen}
>
{targetLabel}
</button>
{lufsTgtOpen &&
createPortal(
<div
ref={lufsTgtMenuRef}
className="queue-lufs-tgt-menu"
style={{
...lufsTgtPopStyle,
background: 'var(--bg-card)',
border: '1px solid var(--border, rgba(255,255,255,0.12))',
borderRadius: 8,
boxShadow: '0 6px 24px rgba(0,0,0,0.35)',
padding: 6,
overflow: 'auto',
}}
role="listbox"
aria-label="LUFS target"
onClick={e => e.stopPropagation()}
>
{([-10, -12, -14, -16] as const).map((v) => (
<button
key={v}
type="button"
onClick={e => {
e.stopPropagation();
if (v !== authLoudnessTargetLufs) {
setLoudnessTargetLufs(v);
void reanalyzeLoudnessForTrack(currentTrack.id);
}
setLufsTgtOpen(false);
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '6px 8px',
borderRadius: 6,
border: 'none',
background: v === authLoudnessTargetLufs ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
font: 'inherit',
}}
>
{v} LUFS
</button>
))}
</div>,
document.body,
)}
</span>
)}
</div> </div>
</div> </div>
); );
+142 -24
View File
@@ -8,6 +8,8 @@ function fmt(s: number): string {
const BAR_COUNT = 500; const BAR_COUNT = 500;
const SEG_COUNT = 60; const SEG_COUNT = 60;
const FLAT_WAVE_NORM = 0.06;
const WAVE_MORPH_MS = 1000;
// ── animation state ─────────────────────────────────────────────────────────── // ── animation state ───────────────────────────────────────────────────────────
@@ -96,6 +98,40 @@ export function makeHeights(trackId: string): Float32Array {
// ── draw functions ──────────────────────────────────────────────────────────── // ── draw functions ────────────────────────────────────────────────────────────
function makeFlatWaveHeights(): Float32Array {
const h = new Float32Array(BAR_COUNT);
h.fill(FLAT_WAVE_NORM);
return h;
}
function easeOutCubic(t: number): number {
const x = Math.max(0, Math.min(1, t));
return 1 - Math.pow(1 - x, 3);
}
function binsToHeights(src: number[]): Float32Array {
const h = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
return h;
}
function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > eps) return false;
}
return true;
}
function waveformBarThickness(logicalH: number, norm: number): number {
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
return Math.max(1, safeNorm * logicalH);
}
function drawWaveform( function drawWaveform(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
heights: Float32Array | null, heights: Float32Array | null,
@@ -108,9 +144,38 @@ function drawWaveform(
const { played, buffered: buffCol, unplayed } = getColors(); const { played, buffered: buffCol, unplayed } = getColors();
if (!heights) { if (!heights) {
ctx.globalAlpha = 0.3; // No waveform data yet: flat rail like `drawLineDot`, but do not return early
// before played/buffered — otherwise there is no visible playhead.
const cy = h / 2;
const lh = 2;
const dotR = 5;
ctx.globalAlpha = 0.35;
ctx.fillStyle = unplayed; ctx.fillStyle = unplayed;
ctx.fillRect(0, (h - 2) / 2, w, 2); ctx.fillRect(0, cy - lh / 2, w, lh);
if (buffered > 0) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = buffCol;
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
}
if (progress > 0) {
ctx.globalAlpha = 1;
ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
if (w > 0) {
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
ctx.shadowColor = played;
ctx.shadowBlur = 7;
ctx.beginPath();
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
ctx.fillStyle = played;
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
return; return;
} }
@@ -122,7 +187,7 @@ function drawWaveform(
ctx.fillStyle = unplayed; ctx.fillStyle = unplayed;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT < buffered) continue; if (i / BAR_COUNT < buffered) continue;
const bh = Math.max(1, heights[i] * h); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
@@ -132,7 +197,7 @@ function drawWaveform(
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
const frac = i / BAR_COUNT; const frac = i / BAR_COUNT;
if (frac < progress || frac >= buffered) continue; if (frac < progress || frac >= buffered) continue;
const bh = Math.max(1, heights[i] * h); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
@@ -140,29 +205,14 @@ function drawWaveform(
if (progress > 0) { if (progress > 0) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = played; ctx.fillStyle = played;
ctx.shadowColor = played;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break; if (i / BAR_COUNT >= progress) break;
const bh = Math.max(1, heights[i] * h); const bh = waveformBarThickness(h, heights[i]);
const x = x1Of(i); const x = x1Of(i);
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
} }
ctx.shadowBlur = 0;
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
// Fade both edges to transparent using destination-in gradient mask
const fadeW = Math.min(22, w * 0.07);
const mask = ctx.createLinearGradient(0, 0, w, 0);
mask.addColorStop(0, 'transparent');
mask.addColorStop(fadeW / w, 'black');
mask.addColorStop(1 - fadeW / w, 'black');
mask.addColorStop(1, 'transparent');
ctx.globalCompositeOperation = 'destination-in';
ctx.fillStyle = mask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
} }
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
@@ -832,6 +882,7 @@ export default function WaveformSeek({ trackId }: Props) {
const [hoverPct, setHoverPct] = useState<number | null>(null); const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek); const seek = usePlayerStore(s => s.seek);
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle); const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -845,8 +896,70 @@ export default function WaveformSeek({ trackId }: Props) {
heightsRef.current = null; heightsRef.current = null;
return; return;
} }
heightsRef.current = makeHeights(trackId); if (waveformBins && waveformBins.length > 0) {
}, [trackId]); const h = binsToHeights(waveformBins);
const prev = heightsRef.current;
if (!prev || prev.length !== BAR_COUNT) {
heightsRef.current = h;
return;
}
if (heightsNearlyEqual(prev, h, 0.02)) {
heightsRef.current = h;
return;
}
const from = new Float32Array(prev);
const to = h;
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
if (heightsRef.current?.length === BAR_COUNT) {
const current = heightsRef.current;
let isAlreadyFlat = true;
for (let i = 0; i < BAR_COUNT; i++) {
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
isAlreadyFlat = false;
break;
}
}
if (isAlreadyFlat) return;
const from = new Float32Array(current);
const to = makeFlatWaveHeights();
const startedAt = performance.now();
let raf = 0;
const step = (now: number) => {
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
const next = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
next[i] = from[i] + (to[i] - from[i]) * p;
}
heightsRef.current = next;
if (!ANIMATED_STYLES.has(styleRef.current)) {
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
}
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}
// No analysis bins yet: render 500 flat bars immediately.
heightsRef.current = makeFlatWaveHeights();
}, [trackId, waveformBins]);
// Imperative subscription — no React re-renders from progress changes. // Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs. // Static styles draw here; animated styles only update refs.
@@ -874,12 +987,17 @@ export default function WaveformSeek({ trackId }: Props) {
}); });
}, []); }, []);
// Initial draw for static styles when style or track changes. // Initial draw for static styles when style, track, or waveform payload changes.
useEffect(() => { useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return; if (ANIMATED_STYLES.has(seekbarStyle)) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
}, [seekbarStyle, trackId]); }, [
seekbarStyle,
trackId,
waveformBins,
duration,
]);
// rAF loop — animated styles only. // rAF loop — animated styles only.
useEffect(() => { useEffect(() => {
+6
View File
@@ -847,6 +847,12 @@ export const deTranslation = {
replayGainAlbum: 'Album', replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getaggte Dateien)', replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
replayGainFallback: 'Fallback (ohne Tags / Radio)', replayGainFallback: 'Fallback (ohne Tags / Radio)',
normalization: 'Normalisierung',
loudnessTargetLufs: 'Ziel-LUFS',
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)',
loudnessPreAnalysisAttenuationDesc:
'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.',
loudnessPreAnalysisAttenuationReset: 'Standard',
crossfade: 'Crossfade', crossfade: 'Crossfade',
crossfadeDesc: 'Überblendung zwischen Tracks', crossfadeDesc: 'Überblendung zwischen Tracks',
crossfadeSecs: '{{n}} s', crossfadeSecs: '{{n}} s',
+9
View File
@@ -656,9 +656,12 @@ export const enTranslation = {
hotCacheTrackCount: 'Tracks in cache:', hotCacheTrackCount: 'Tracks in cache:',
cacheMaxLabel: 'Max. size', cacheMaxLabel: 'Max. size',
cacheClearBtn: 'Clear Cache', cacheClearBtn: 'Clear Cache',
waveformCacheClearBtn: 'Clear waveform cache',
cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything', cacheClearConfirm: 'Clear Everything',
cacheClearCancel: 'Cancel', cacheClearCancel: 'Cancel',
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
waveformCacheClearFailed: 'Failed to clear waveform cache.',
offlineDirTitle: 'Offline Library (In-App)', offlineDirTitle: 'Offline Library (In-App)',
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.', offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
offlineDirDefault: 'Default (App Data)', offlineDirDefault: 'Default (App Data)',
@@ -850,6 +853,12 @@ export const enTranslation = {
replayGainAlbum: 'Album', replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (tagged files)', replayGainPreGain: 'Pre-Gain (tagged files)',
replayGainFallback: 'Fallback (untagged / radio)', replayGainFallback: 'Fallback (untagged / radio)',
normalization: 'Normalization',
loudnessTargetLufs: 'Target LUFS',
loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)',
loudnessPreAnalysisAttenuationDesc:
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.',
loudnessPreAnalysisAttenuationReset: 'Reset to default',
crossfade: 'Crossfade', crossfade: 'Crossfade',
crossfadeDesc: 'Fade between tracks', crossfadeDesc: 'Fade between tracks',
crossfadeSecs: '{{n}} s', crossfadeSecs: '{{n}} s',
+6
View File
@@ -840,6 +840,12 @@ export const esTranslation = {
replayGainAlbum: 'Álbum', replayGainAlbum: 'Álbum',
replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)', replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)',
replayGainFallback: 'Respaldo (sin etiquetar / radio)', replayGainFallback: 'Respaldo (sin etiquetar / radio)',
normalization: 'Normalización',
loudnessTargetLufs: 'LUFS objetivo',
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)',
loudnessPreAnalysisAttenuationDesc:
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.',
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
crossfade: 'Crossfade', crossfade: 'Crossfade',
crossfadeDesc: 'Transición entre pistas', crossfadeDesc: 'Transición entre pistas',
crossfadeSecs: '{{n}} s', crossfadeSecs: '{{n}} s',
+6
View File
@@ -835,6 +835,12 @@ export const frTranslation = {
replayGainAlbum: 'Album', replayGainAlbum: 'Album',
replayGainPreGain: 'Pré-Gain (fichiers taggés)', replayGainPreGain: 'Pré-Gain (fichiers taggés)',
replayGainFallback: 'Repli (sans tags / radio)', replayGainFallback: 'Repli (sans tags / radio)',
normalization: 'Normalisation',
loudnessTargetLufs: 'LUFS cible',
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)',
loudnessPreAnalysisAttenuationDesc:
'Réduit le volume tant que la loudness du morceau nest pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.',
loudnessPreAnalysisAttenuationReset: 'Valeur par défaut',
crossfade: 'Fondu enchaîné', crossfade: 'Fondu enchaîné',
crossfadeDesc: 'Fondu entre les pistes', crossfadeDesc: 'Fondu entre les pistes',
crossfadeSecs: '{{n}} s', crossfadeSecs: '{{n}} s',
+6
View File
@@ -834,6 +834,12 @@ export const nbTranslation = {
replayGainAlbum: 'Album', replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (taggede filer)', replayGainPreGain: 'Pre-Gain (taggede filer)',
replayGainFallback: 'Reserveverdi (uten tagger / radio)', replayGainFallback: 'Reserveverdi (uten tagger / radio)',
normalization: 'Normalisering',
loudnessTargetLufs: 'Mål-LUFS',
loudnessPreAnalysisAttenuation: 'Demping før måling (dB)',
loudnessPreAnalysisAttenuationDesc:
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.',
loudnessPreAnalysisAttenuationReset: 'Standard',
crossfade: 'Crossfade', crossfade: 'Crossfade',
crossfadeDesc: 'Tone mellom spor', crossfadeDesc: 'Tone mellom spor',
crossfadeSecs: '{{n}}s', crossfadeSecs: '{{n}}s',
+6
View File
@@ -834,6 +834,12 @@ export const nlTranslation = {
replayGainAlbum: 'Album', replayGainAlbum: 'Album',
replayGainPreGain: 'Pre-Gain (getagde bestanden)', replayGainPreGain: 'Pre-Gain (getagde bestanden)',
replayGainFallback: 'Terugval (zonder tags / radio)', replayGainFallback: 'Terugval (zonder tags / radio)',
normalization: 'Normalisatie',
loudnessTargetLufs: 'Doel-LUFS',
loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)',
loudnessPreAnalysisAttenuationDesc:
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.',
loudnessPreAnalysisAttenuationReset: 'Standaard',
crossfade: 'Overgang', crossfade: 'Overgang',
crossfadeDesc: 'Fade tussen nummers', crossfadeDesc: 'Fade tussen nummers',
crossfadeSecs: '{{n}} s', crossfadeSecs: '{{n}} s',
+9
View File
@@ -677,9 +677,12 @@ export const ruTranslation = {
hotCacheTrackCount: 'Треков в кэше:', hotCacheTrackCount: 'Треков в кэше:',
cacheMaxLabel: 'Лимит', cacheMaxLabel: 'Лимит',
cacheClearBtn: 'Очистить кэш', cacheClearBtn: 'Очистить кэш',
waveformCacheClearBtn: 'Очистить кэш waveform',
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.', cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
cacheClearConfirm: 'Очистить всё', cacheClearConfirm: 'Очистить всё',
cacheClearCancel: 'Отмена', cacheClearCancel: 'Отмена',
waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).',
waveformCacheClearFailed: 'Не удалось очистить кэш waveform.',
offlineDirTitle: 'Офлайн-библиотека (в приложении)', offlineDirTitle: 'Офлайн-библиотека (в приложении)',
offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.', offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.',
offlineDirDefault: 'По умолчанию (данные приложения)', offlineDirDefault: 'По умолчанию (данные приложения)',
@@ -883,6 +886,12 @@ export const ruTranslation = {
replayGainAlbum: 'По альбому', replayGainAlbum: 'По альбому',
replayGainPreGain: 'Предусиление (файлы с тегами)', replayGainPreGain: 'Предусиление (файлы с тегами)',
replayGainFallback: 'Резерв (без тегов / радио)', replayGainFallback: 'Резерв (без тегов / радио)',
normalization: 'Нормализация',
loudnessTargetLufs: 'Целевой LUFS',
loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)',
loudnessPreAnalysisAttenuationDesc:
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.',
loudnessPreAnalysisAttenuationReset: 'По умолчанию',
crossfade: 'Кроссфейд', crossfade: 'Кроссфейд',
crossfadeDesc: 'Плавный переход между треками', crossfadeDesc: 'Плавный переход между треками',
crossfadeSecs: '{{n}} с', crossfadeSecs: '{{n}} с',
+6
View File
@@ -829,6 +829,12 @@ export const zhTranslation = {
replayGainAlbum: '专辑', replayGainAlbum: '专辑',
replayGainPreGain: '预增益(有标签文件)', replayGainPreGain: '预增益(有标签文件)',
replayGainFallback: '回退增益(无标签 / 收音机)', replayGainFallback: '回退增益(无标签 / 收音机)',
normalization: '响度归一化',
loudnessTargetLufs: '目标 LUFS',
loudnessPreAnalysisAttenuation: '测量前衰减 (dB)',
loudnessPreAnalysisAttenuationDesc:
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。',
loudnessPreAnalysisAttenuationReset: '恢复默认',
crossfade: '交叉淡入淡出', crossfade: '交叉淡入淡出',
crossfadeDesc: '曲目间淡入淡出', crossfadeDesc: '曲目间淡入淡出',
crossfadeSecs: '{{n}} 秒', crossfadeSecs: '{{n}} 秒',
+16
View File
@@ -30,6 +30,22 @@ try {
// Ignore in non-Tauri runtimes. // Ignore in non-Tauri runtimes.
} }
// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the
// user's persisted `loggingMode` until then — but waveform/audio may already
// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`).
try {
const raw = localStorage.getItem('psysonic-auth');
if (raw) {
const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } };
const mode = parsed.state?.loggingMode;
if (mode === 'off' || mode === 'normal' || mode === 'debug') {
void invoke('set_logging_mode', { mode });
}
}
} catch {
// Ignore parse / non-Tauri.
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+147 -12
View File
@@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore'; import { useHotCacheStore } from '../store/hotCacheStore';
import { usePlayerStore } from '../store/playerStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
@@ -25,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; import {
useAuthStore,
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
ServerProfile,
MIX_MIN_RATING_FILTER_MAX_STARS,
type SeekbarStyle,
type LyricsSourceId,
type LyricsSourceConfig,
type LoggingMode,
type LoudnessLufsPreset,
} from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek'; import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
@@ -64,6 +75,29 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
function LoudnessLufsButtonGroup(props: {
value: LoudnessLufsPreset;
onSelect: (v: LoudnessLufsPreset) => void;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
{LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
<button
key={v}
type="button"
className={`btn ${props.value === v ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => props.onSelect(v)}
>
{v}
</button>
))}
</div>
);
}
const CONTRIBUTORS = [ const CONTRIBUTORS = [
{ {
github: 'jiezhuo', github: 'jiezhuo',
@@ -1788,6 +1822,26 @@ export default function Settings() {
setClearing(false); setClearing(false);
}, [clearAllOffline, serverId]); }, [clearAllOffline, serverId]);
const handleClearWaveformCache = useCallback(async () => {
setClearing(true);
try {
const deleted = await invoke<number>('analysis_delete_all_waveforms');
usePlayerStore.setState({
waveformBins: null,
});
showToast(
t('settings.waveformCacheCleared', { count: deleted }),
3500,
'success',
);
} catch (e) {
console.error(e);
showToast(t('settings.waveformCacheClearFailed'), 4500, 'error');
} finally {
setClearing(false);
}
}, [t]);
const startLastfmConnect = useCallback(async () => { const startLastfmConnect = useCallback(async () => {
setLfmError(null); setLfmError(null);
let token: string; let token: string;
@@ -2217,19 +2271,89 @@ export default function Settings() {
icon={<Music2 size={16} />} icon={<Music2 size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
{/* Replay Gain */} {/* Normalization */}
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div> <button
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(false);
auth.setNormalizationEngine('off');
}}
>
Off
</button>
<button
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(true);
auth.setNormalizationEngine('replaygain');
}}
>
RG
</button>
<button
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(false);
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
auth.setNormalizationEngine('loudness');
}}
>
LUFS
</button>
</div> </div>
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
<span className="toggle-track" />
</label>
</div> </div>
{auth.replayGainEnabled && ( {auth.normalizationEngine === 'loudness' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.55rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
{t('settings.loudnessTargetLufs')}
</span>
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
{t('settings.loudnessPreAnalysisAttenuation')}
</span>
<input
type="range"
min={-24}
max={0}
step={0.5}
value={auth.loudnessPreAnalysisAttenuationDb}
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 44, textAlign: 'right' }}>
{auth.loudnessPreAnalysisAttenuationDb} dB
</span>
<button
type="button"
className="icon-btn"
style={{ flexShrink: 0 }}
disabled={
auth.loudnessPreAnalysisAttenuationDb === DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
}
onClick={() => auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
>
<RotateCcw size={15} />
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.35, maxWidth: 520 }}>
{t('settings.loudnessPreAnalysisAttenuationDesc')}
</div>
</div>
)}
{auth.normalizationEngine !== 'off' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> <div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{auth.normalizationEngine === 'replaygain' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span> <span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
<button <button
@@ -2254,14 +2378,15 @@ export default function Settings() {
{t('settings.replayGainAlbum')} {t('settings.replayGainAlbum')}
</button> </button>
</div> </div>
{auth.replayGainMode === 'auto' && ( )}
{auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.replayGainAutoDesc')} {t('settings.replayGainAutoDesc')}
</div> </div>
)} )}
</div> </div>
)} )}
{auth.replayGainEnabled && ( {auth.normalizationEngine === 'replaygain' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}> <div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}> <span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
@@ -2964,6 +3089,16 @@ export default function Settings() {
<Trash2 size={14} /> {t('settings.cacheClearBtn')} <Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button> </button>
)} )}
<div style={{ marginTop: 8 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={handleClearWaveformCache}
disabled={clearing}
>
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
</button>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
+29
View File
@@ -0,0 +1,29 @@
export type AnalysisStorageChangedReason =
| 'offline-delete'
| 'hotcache-delete'
| 'hotcache-purge';
export type AnalysisStorageChangedDetail = {
trackId?: string | null;
reason: AnalysisStorageChangedReason;
};
const EVENT_NAME = 'psysonic:analysis-storage-changed';
export function emitAnalysisStorageChanged(detail: AnalysisStorageChangedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<AnalysisStorageChangedDetail>(EVENT_NAME, { detail }));
}
export function onAnalysisStorageChanged(
listener: (detail: AnalysisStorageChangedDetail) => void,
): () => void {
if (typeof window === 'undefined') return () => {};
const wrapped = (evt: Event) => {
const ce = evt as CustomEvent<AnalysisStorageChangedDetail>;
if (!ce?.detail) return;
listener(ce.detail);
};
window.addEventListener(EVENT_NAME, wrapped as EventListener);
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
}
+54
View File
@@ -20,6 +20,28 @@ export interface ServerProfile {
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
export type LoggingMode = 'off' | 'normal' | 'debug'; export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
/** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10];
/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */
export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5;
function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset {
return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number)
? (v as LoudnessLufsPreset)
: fallback;
}
function clampLoudnessPreAnalysisAttenuationDb(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB;
const stepped = Math.round(n * 2) / 2;
return Math.max(-24, Math.min(0, stepped));
}
export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
@@ -49,6 +71,10 @@ interface AuthState {
excludeAudiobooks: boolean; excludeAudiobooks: boolean;
customGenreBlacklist: string[]; customGenreBlacklist: string[];
replayGainEnabled: boolean; replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: LoudnessLufsPreset;
/** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */
loudnessPreAnalysisAttenuationDb: number;
replayGainMode: 'track' | 'album' | 'auto'; replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
@@ -208,6 +234,10 @@ interface AuthState {
setExcludeAudiobooks: (v: boolean) => void; setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void; setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void; setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
setLoudnessPreAnalysisAttenuationDb: (v: number) => void;
resetLoudnessPreAnalysisAttenuationDbDefault: () => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void; setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void;
@@ -319,6 +349,9 @@ export const useAuthStore = create<AuthState>()(
excludeAudiobooks: false, excludeAudiobooks: false,
customGenreBlacklist: [], customGenreBlacklist: [],
replayGainEnabled: false, replayGainEnabled: false,
normalizationEngine: 'off',
loudnessTargetLufs: -12,
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
replayGainMode: 'auto', replayGainMode: 'auto',
replayGainPreGainDb: 0, replayGainPreGainDb: 0,
replayGainFallbackDb: 0, replayGainFallbackDb: 0,
@@ -447,6 +480,21 @@ export const useAuthStore = create<AuthState>()(
set({ replayGainEnabled: v }); set({ replayGainEnabled: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack(); usePlayerStore.getState().updateReplayGainForCurrentTrack();
}, },
setNormalizationEngine: (v) => {
set({ normalizationEngine: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessTargetLufs: (v) => {
set({ loudnessTargetLufs: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessPreAnalysisAttenuationDb: (v) => {
set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) });
},
resetLoudnessPreAnalysisAttenuationDbDefault: () => {
set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => { setReplayGainMode: (v) => {
set({ replayGainMode: v }); set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack(); usePlayerStore.getState().updateReplayGainForCurrentTrack();
@@ -681,6 +729,10 @@ export const useAuthStore = create<AuthState>()(
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown };
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb);
useAuthStore.setState({ useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -688,6 +740,8 @@ export const useAuthStore = create<AuthState>()(
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts( skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
), ),
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
...conflictingLegacyState, ...conflictingLegacyState,
...lyricsSourcesMigrated, ...lyricsSourcesMigrated,
...wheelSmoothOneTime, ...wheelSmoothOneTime,
+4
View File
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate'; import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
import type { Track } from './playerStore'; import type { Track } from './playerStore';
import { emitAnalysisStorageChanged } from './analysisSync';
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */ /** How many queue slots after the current index are eviction-protected (1 = current + next only). */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1; export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
@@ -96,6 +97,7 @@ export const useHotCacheStore = create<HotCacheState>()(
delete next[entryKey(serverId, trackId)]; delete next[entryKey(serverId, trackId)];
return { entries: next }; return { entries: next };
}); });
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
}, },
totalBytes: () => totalBytes: () =>
@@ -167,6 +169,7 @@ export const useHotCacheStore = create<HotCacheState>()(
}).catch(() => {}); }).catch(() => {});
sum -= meta.sizeBytes || 0; sum -= meta.sizeBytes || 0;
delete entries[key]; delete entries[key];
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
} }
set({ entries }); set({ entries });
@@ -175,6 +178,7 @@ export const useHotCacheStore = create<HotCacheState>()(
clearAllDisk: async (customDir: string | null) => { clearAllDisk: async (customDir: string | null) => {
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {}); await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} }); set({ entries: {} });
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
}, },
}), }),
{ {
+4
View File
@@ -6,6 +6,7 @@ import type { SubsonicSong } from '../api/subsonic';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore'; import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
import { emitAnalysisStorageChanged } from './analysisSync';
export interface OfflineTrackMeta { export interface OfflineTrackMeta {
id: string; id: string;
@@ -299,6 +300,9 @@ export const useOfflineStore = create<OfflineState>()(
}).catch(() => {}); }).catch(() => {});
}), }),
); );
for (const trackId of album.trackIds) {
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
}
set(state => { set(state => {
const tracks = { ...state.tracks }; const tracks = { ...state.tracks };
+552 -14
View File
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore'; import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore'; import { useHotCacheStore } from './hotCacheStore';
import { onAnalysisStorageChanged } from './analysisSync';
import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { useOrbitStore } from './orbitStore'; import { useOrbitStore } from './orbitStore';
import { estimateLivePosition } from '../api/orbit'; import { estimateLivePosition } from '../api/orbit';
@@ -146,6 +147,16 @@ async function buildInfiniteQueueCandidates(
interface PlayerState { interface PlayerState {
currentTrack: Track | null; currentTrack: Track | null;
waveformBins: number[] | null;
normalizationNowDb: number | null;
normalizationTargetLufs: number | null;
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
normalizationDbgSource: string | null;
normalizationDbgTrackId: string | null;
normalizationDbgCacheGainDb: number | null;
normalizationDbgCacheTargetLufs: number | null;
normalizationDbgCacheUpdatedAt: number | null;
normalizationDbgLastEventAt: number | null;
currentRadio: InternetRadioStation | null; currentRadio: InternetRadioStation | null;
/** Latches the source used to start the currently playing track. */ /** Latches the source used to start the currently playing track. */
currentPlaybackSource: PlaybackSourceKind | null; currentPlaybackSource: PlaybackSourceKind | null;
@@ -195,8 +206,9 @@ interface PlayerState {
next: (manual?: boolean) => void; next: (manual?: boolean) => void;
previous: () => void; previous: () => void;
seek: (progress: number) => void; seek: (progress: number) => void;
setVolume: (v: number) => void; setVolume: (v: number) => void;
updateReplayGainForCurrentTrack: () => void; updateReplayGainForCurrentTrack: () => void;
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
setProgress: (t: number, duration: number) => void; setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
@@ -248,6 +260,51 @@ interface PlayerState {
closeSongInfo: () => void; closeSongInfo: () => void;
} }
type WaveformCachePayload = {
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
bins: number[] | Uint8Array;
binCount: number;
isPartial: boolean;
knownUntilSec: number;
durationSec: number;
updatedAt: number;
};
/** `Vec<u8>` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */
function coerceWaveformBins(bins: unknown): number[] | null {
if (bins == null) return null;
if (Array.isArray(bins)) {
return bins.length > 0 ? bins.map(x => Number(x) & 255) : null;
}
if (bins instanceof Uint8Array) {
return bins.length > 0 ? Array.from(bins) : null;
}
if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
const len = (bins as { length: number }).length;
if (len === 0) return null;
try {
return Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
} catch {
return null;
}
}
return null;
}
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
recommendedGainDb: number;
targetLufs: number;
updatedAt: number;
};
type NormalizationStatePayload = {
engine: 'off' | 'replaygain' | 'loudness' | string;
currentGainDb: number | null;
targetLufs: number;
};
// ─── Module-level playback primitives ───────────────────────────────────────── // ─── Module-level playback primitives ─────────────────────────────────────────
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track. // isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
@@ -266,6 +323,66 @@ let radioFetching = false;
// Artist ID used to start the current radio session — persists across track // Artist ID used to start the current radio session — persists across track
// advances so proactive loading works even when songs lack artistId. // advances so proactive loading works even when songs lack artistId.
let currentRadioArtistId: string | null = null; let currentRadioArtistId: string | null = null;
let cachedLoudnessGainByTrackId: Record<string, number> = {};
let stableLoudnessGainByTrackId: Record<string, true> = {};
let lastNormalizationUiUpdateAtMs = 0;
function emitNormalizationDebug(step: string, details?: Record<string, unknown>) {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'normalization',
message: JSON.stringify({ step, details }),
}).catch(() => {});
}
function normalizeAnalysisTrackId(trackId?: string | null): string | null {
if (!trackId) return null;
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
return trackId;
}
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return Math.abs(a - b) <= eps;
}
function deriveNormalizationSnapshot(
track: Track,
queue: Track[],
queueIndex: number,
): Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
> {
const auth = useAuthStore.getState();
const engine = auth.normalizationEngine;
if (engine === 'loudness') {
const target = auth.loudnessTargetLufs;
return {
// Clears stale UI until `audio:normalization-state` / refresh catches up.
normalizationNowDb: null,
normalizationTargetLufs: target,
normalizationEngineLive: 'loudness',
};
}
if (engine === 'replaygain' && auth.replayGainEnabled) {
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb;
return {
normalizationNowDb: nowDb,
normalizationTargetLufs: null,
normalizationEngineLive: 'replaygain',
};
}
return {
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
};
}
// Debounce timer for seek slider drags. // Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null; let seekDebounce: ReturnType<typeof setTimeout> | null = null;
@@ -274,6 +391,9 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
let seekTarget: number | null = null; let seekTarget: number | null = null;
let seekTargetSetAt = 0; let seekTargetSetAt = 0;
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000; const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null; let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
let seekFallbackRetryStartedAt = 0; let seekFallbackRetryStartedAt = 0;
@@ -488,6 +608,185 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
useHotCacheStore.getState().touchPlayed(trackId, serverId); useHotCacheStore.getState().touchPlayed(trackId, serverId);
} }
function isReplayGainActive() {
const a = useAuthStore.getState();
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
}
function loudnessCacheStateKeysForTrackId(trackId: string): string[] {
if (!trackId) return [];
const out: string[] = [trackId];
if (trackId.startsWith('stream:')) {
const bare = trackId.slice('stream:'.length);
if (bare) out.push(bare);
} else {
out.push(`stream:${trackId}`);
}
return out;
}
function clearLoudnessCacheStateForTrackId(trackId: string) {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete cachedLoudnessGainByTrackId[k];
delete stableLoudnessGainByTrackId[k];
}
}
/** Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit partial hints so Rust uses pre-trim until `analysis:loudness-partial` + `audio_update_replay_gain`. */
function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null {
if (!trackId) return null;
if (!stableLoudnessGainByTrackId[trackId]) return null;
const v = cachedLoudnessGainByTrackId[trackId];
return Number.isFinite(v) ? v : null;
}
function resetLoudnessBackfillStateForTrackId(trackId: string) {
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
delete analysisBackfillInFlightByTrackId[k];
analysisBackfillAttemptsByTrackId[k] = 0;
}
}
async function reseedLoudnessForTrackId(trackId: string) {
if (!trackId) return;
const auth = useAuthStore.getState();
if (auth.normalizationEngine !== 'loudness') return;
clearLoudnessCacheStateForTrackId(trackId);
resetLoudnessBackfillStateForTrackId(trackId);
if (auth.normalizationEngine === 'loudness') {
usePlayerStore.setState({
normalizationNowDb: null,
normalizationTargetLufs: auth.loudnessTargetLufs,
normalizationEngineLive: 'loudness',
});
}
try {
await invoke('analysis_delete_loudness_for_track', { trackId });
} catch (e) {
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
}
usePlayerStore.getState().updateReplayGainForCurrentTrack();
const url = buildStreamUrl(trackId);
try {
await invoke('analysis_enqueue_seed_from_url', { trackId, url });
} catch (e) {
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
}
}
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
const bins = row ? coerceWaveformBins(row.bins) : null;
if (!bins || bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
});
return;
}
usePlayerStore.setState({
waveformBins: bins,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
}
}
/** When `syncPlayingEngine` is false, only update `cachedLoudnessGainByTrackId` (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */
async function refreshLoudnessForTrack(
trackId: string,
opts?: { syncPlayingEngine?: boolean },
) {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: useAuthStore.getState().loudnessTargetLufs,
});
if (!row || !Number.isFinite(row.recommendedGainDb)) {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
const auth = useAuthStore.getState();
const attempts = analysisBackfillAttemptsByTrackId[trackId] ?? 0;
if (auth.normalizationEngine === 'loudness'
&& !analysisBackfillInFlightByTrackId[trackId]
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
analysisBackfillInFlightByTrackId[trackId] = true;
analysisBackfillAttemptsByTrackId[trackId] = attempts + 1;
const url = buildStreamUrl(trackId);
emitNormalizationDebug('backfill:enqueue', { trackId, url, attempt: attempts + 1 });
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
delete analysisBackfillInFlightByTrackId[trackId];
});
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
}
usePlayerStore.setState({
normalizationDbgSource: 'refresh:miss',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
});
return;
}
cachedLoudnessGainByTrackId[trackId] = row.recommendedGainDb;
stableLoudnessGainByTrackId[trackId] = true;
analysisBackfillAttemptsByTrackId[trackId] = 0;
emitNormalizationDebug('refresh:hit', { trackId, row });
usePlayerStore.setState({
normalizationDbgSource: 'refresh:hit',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: row.recommendedGainDb,
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
});
if (syncEngine) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
} catch {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
emitNormalizationDebug('refresh:error', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
}
}
/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */
const LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS = 40;
function prefetchLoudnessForEnqueuedTracks(
incoming: Track[],
mergedQueue: Track[],
queueIndex: number,
) {
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
const ids = new Set<string>();
const next = mergedQueue[queueIndex + 1];
if (next?.id) ids.add(next.id);
let n = 0;
for (const t of incoming) {
if (n >= LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS) break;
if (t?.id) {
ids.add(t.id);
n++;
}
}
for (const id of ids) {
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
}
}
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) { async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
try { try {
const res = await invoke<{ path: string; size: number } | null>( const res = await invoke<{ path: string; size: number } | null>(
@@ -648,6 +947,9 @@ function handleAudioProgress(current_time: number, duration: number) {
// Byte pre-download — runs early so bytes are cached by chain time. // Byte pre-download — runs early so bytes are cached by chain time.
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) { if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
bytePreloadingId = nextTrack.id; bytePreloadingId = nextTrack.id;
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
// waveformBins and would replace the current track's seekbar while still playing it.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', { console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id, nextTrackId: nextTrack.id,
@@ -658,12 +960,18 @@ function handleAudioProgress(current_time: number, duration: number) {
gaplessEnabled, gaplessEnabled,
}); });
} }
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
}).catch(() => {});
} }
// Gapless chain — decode + chain into Sink 30s before track boundary. // Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) { if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id; gaplessPreloadingId = nextTrack.id;
// Ensure loudness gain is already cached for the chained request payload.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState(); const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left, // Auto-mode neighbours for the *next* track: current track on its left,
// queue[nextIdx+1] on its right. // queue[nextIdx+1] on its right.
@@ -672,9 +980,9 @@ function handleAudioProgress(current_time: number, duration: number) {
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null); : (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
const replayGainDb = resolveReplayGainDb( const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour, nextTrack, track, nextNeighbour,
authState.replayGainEnabled, authState.replayGainMode, isReplayGainActive(), authState.replayGainMode,
); );
const replayGainPeak = authState.replayGainEnabled const replayGainPeak = isReplayGainActive()
? (nextTrack.replayGainPeak ?? null) ? (nextTrack.replayGainPeak ?? null)
: null; : null;
invoke('audio_chain_preload', { invoke('audio_chain_preload', {
@@ -683,9 +991,11 @@ function handleAudioProgress(current_time: number, duration: number) {
durationHint: nextTrack.duration, durationHint: nextTrack.duration,
replayGainDb, replayGainDb,
replayGainPeak, replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
preGainDb: authState.replayGainPreGainDb, preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb, fallbackDb: authState.replayGainFallbackDb,
hiResEnabled: authState.enableHiRes, hiResEnabled: authState.enableHiRes,
analysisTrackId: nextTrack.id,
}).catch(() => {}); }).catch(() => {});
} }
} }
@@ -707,7 +1017,12 @@ function handleAudioEnded() {
const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false; isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); usePlayerStore.setState({
isPlaying: false,
progress: 0,
currentTime: 0,
buffered: 0,
});
setTimeout(() => { setTimeout(() => {
if (repeatMode === 'one' && currentTrack) { if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue, false); usePlayerStore.getState().playTrack(currentTrack, queue, false);
@@ -751,6 +1066,10 @@ function handleAudioTrackSwitched(duration: number) {
usePlayerStore.setState({ usePlayerStore.setState({
currentTrack: nextTrack, currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex, queueIndex: newIndex,
isPlaying: true, isPlaying: true,
progress: 0, progress: 0,
@@ -759,6 +1078,14 @@ function handleAudioTrackSwitched(duration: number) {
scrobbled: false, scrobbled: false,
lastfmLoved: false, lastfmLoved: false,
}); });
emitNormalizationDebug('track-switched', {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
// Report Now Playing to Navidrome + Last.fm // Report Now Playing to Navidrome + Last.fm
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
@@ -824,6 +1151,83 @@ export function initAudioListeners(): () => void {
listen<void>('audio:ended', () => handleAudioEnded()), listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)), listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!Number.isFinite(payload.gainDb)) return;
if (stableLoudnessGainByTrackId[current.id]) return;
cachedLoudnessGainByTrackId[current.id] = payload.gainDb;
emitNormalizationDebug('partial-loudness:apply', {
trackId: current.id,
gainDb: payload.gainDb,
targetLufs: payload.targetLufs,
});
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}),
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentRaw = usePlayerStore.getState().currentTrack?.id;
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
if (currentId && payloadTrackId === currentId) {
void refreshWaveformForTrack(currentRaw!);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
}
// Backfill finished for another id (e.g. next in queue): refresh loudness cache only
// so `cachedLoudnessGainByTrackId` is ready before `audio_play` / gapless chain.
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
}),
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
if (!payload) return;
const engine =
payload.engine === 'loudness' || payload.engine === 'replaygain'
? payload.engine
: 'off';
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
const prev = usePlayerStore.getState();
// Avoid UI flicker from noisy duplicate emits and transient nulls.
if (
engine === prev.normalizationEngineLive
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
) {
return;
}
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
return;
}
const nowMs = Date.now();
const isFirstNumericGain =
engine === 'loudness'
&& nowDb != null
&& prev.normalizationNowDb == null;
if (
!isFirstNumericGain
&& nowMs - lastNormalizationUiUpdateAtMs < 120
&& engine === prev.normalizationEngineLive
) {
return;
}
lastNormalizationUiUpdateAtMs = nowMs;
emitNormalizationDebug('event:audio:normalization-state', {
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
payload,
});
usePlayerStore.setState({
normalizationEngineLive: engine,
normalizationNowDb: nowDb,
normalizationTargetLufs: targetLufs,
normalizationDbgSource: 'event:audio:normalization-state',
normalizationDbgLastEventAt: Date.now(),
});
}),
listen<string>('audio:preload-ready', ({ payload }) => { listen<string>('audio:preload-ready', ({ payload }) => {
const tid = streamUrlTrackId(payload); const tid = streamUrlTrackId(payload);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -847,17 +1251,98 @@ export function initAudioListeners(): () => void {
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState(); const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {}); invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
usePlayerStore.setState({
normalizationEngineLive: normCfg.normalizationEngine,
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
normalizationNowDb: null,
normalizationDbgSource: 'init:set-normalization',
});
emitNormalizationDebug('init:set-normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invoke('audio_set_normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb,
}).catch(() => {});
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
if (bootTrackId) {
void refreshWaveformForTrack(bootTrackId);
}
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
}
if (audioOutputDevice) { if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {}); invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
} }
// Keep audio settings in sync whenever auth store changes. // Keep audio settings in sync whenever auth store changes.
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
const unsubAuth = useAuthStore.subscribe((state) => { const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', { invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled, enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs, secs: state.crossfadeSecs,
}).catch(() => {}); }).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
if (!normChanged) return;
const onlyPreAnalysisChanged =
state.normalizationEngine === prevNormEngine
&& state.loudnessTargetLufs === prevNormTarget
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
normalizationNowDb: state.normalizationEngine === 'loudness'
? usePlayerStore.getState().normalizationNowDb
: null,
normalizationDbgSource: 'auth:normalization-changed',
});
emitNormalizationDebug('auth:normalization-changed', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invoke('audio_set_normalization', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb,
}).catch(() => {});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (onlyPreAnalysisChanged) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
} else if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
} else {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
});
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (!currentId) return;
if (detail.trackId && detail.trackId !== currentId) return;
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
}); });
// ── MPRIS / OS media controls sync ─────────────────────────────────────── // ── MPRIS / OS media controls sync ───────────────────────────────────────
@@ -1019,6 +1504,7 @@ export function initAudioListeners(): () => void {
return () => { return () => {
unsubAuth(); unsubAuth();
unsubAnalysisSync();
unsubMpris(); unsubMpris();
unsubDiscordPlayer(); unsubDiscordPlayer();
unsubDiscordAuth(); unsubDiscordAuth();
@@ -1033,6 +1519,16 @@ export const usePlayerStore = create<PlayerState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
currentTrack: null, currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
normalizationDbgSource: null,
normalizationDbgTrackId: null,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: null,
normalizationDbgCacheUpdatedAt: null,
normalizationDbgLastEventAt: null,
currentRadio: null, currentRadio: null,
currentPlaybackSource: null, currentPlaybackSource: null,
enginePreloadedTrackId: null, enginePreloadedTrackId: null,
@@ -1159,6 +1655,10 @@ export const usePlayerStore = create<PlayerState>()(
buffered: 0, buffered: 0,
currentTime: 0, currentTime: 0,
currentRadio: null, currentRadio: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null, currentPlaybackSource: null,
enginePreloadedTrackId: null, enginePreloadedTrackId: null,
scheduledPauseAtMs: null, scheduledPauseAtMs: null,
@@ -1199,6 +1699,10 @@ export const usePlayerStore = create<PlayerState>()(
set({ set({
currentRadio: station, currentRadio: station,
currentTrack: null, currentTrack: null,
waveformBins: null,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null, currentPlaybackSource: null,
queue: [], queue: [],
queueIndex: 0, queueIndex: 0,
@@ -1306,6 +1810,8 @@ export const usePlayerStore = create<PlayerState>()(
set({ set({
currentTrack: track, currentTrack: track,
currentRadio: null, currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue, queue: newQueue,
queueIndex: idx >= 0 ? idx : 0, queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress, progress: initialProgress,
@@ -1330,24 +1836,28 @@ export const usePlayerStore = create<PlayerState>()(
authState.hotCacheDownloadDir || null, authState.hotCacheDownloadDir || null,
); );
} }
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true); setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0; const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb( const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour, track, prevTrack, nextNeighbour,
authState.replayGainEnabled, authState.replayGainMode, isReplayGainActive(), authState.replayGainMode,
); );
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null; const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', { invoke('audio_play', {
url, url,
volume: state.volume, volume: state.volume,
durationHint: track.duration, durationHint: track.duration,
replayGainDb, replayGainDb,
replayGainPeak, replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
preGainDb: authState.replayGainPreGainDb, preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb, fallbackDb: authState.replayGainFallbackDb,
manual, manual,
hiResEnabled: authState.enableHiRes, hiResEnabled: authState.enableHiRes,
analysisTrackId: track.id,
}) })
.then(() => { .then(() => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
@@ -1511,9 +2021,9 @@ export const usePlayerStore = create<PlayerState>()(
const authStateCold = useAuthStore.getState(); const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb( const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext, trackToPlay, coldPrev, coldNext,
authStateCold.replayGainEnabled, authStateCold.replayGainMode, isReplayGainActive(), authStateCold.replayGainMode,
); );
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null; const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? ''; const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true); setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
@@ -1524,10 +2034,12 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: trackToPlay.duration, durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold, replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold, replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb, preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb, fallbackDb: authStateCold.replayGainFallbackDb,
manual: false, manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes, hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: trackToPlay.id,
}).then(() => { }).then(() => {
if (playGeneration === gen && currentTime > 1) { if (playGeneration === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error); invoke('audio_seek', { seconds: currentTime }).catch(console.error);
@@ -1545,9 +2057,9 @@ export const usePlayerStore = create<PlayerState>()(
const authStateCold = useAuthStore.getState(); const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb( const replayGainDbCold = resolveReplayGainDb(
currentTrack, coldPrev, coldNext, currentTrack, coldPrev, coldNext,
authStateCold.replayGainEnabled, authStateCold.replayGainMode, isReplayGainActive(), authStateCold.replayGainMode,
); );
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? ''; const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true); setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
@@ -1558,10 +2070,12 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration, durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold, replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold, replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
preGainDb: authStateCold.replayGainPreGainDb, preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb, fallbackDb: authStateCold.replayGainFallbackDb,
manual: false, manual: false,
hiResEnabled: useAuthStore.getState().enableHiRes, hiResEnabled: useAuthStore.getState().enableHiRes,
analysisTrackId: currentTrack.id,
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
@@ -1851,6 +2365,7 @@ export const usePlayerStore = create<PlayerState>()(
...state.queue.slice(firstAutoIdx), ...state.queue.slice(firstAutoIdx),
]; ];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime); syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex);
return { queue: newQueue }; return { queue: newQueue };
}); });
}, },
@@ -1897,6 +2412,7 @@ export const usePlayerStore = create<PlayerState>()(
? state.queueIndex + tracks.length ? state.queueIndex + tracks.length
: state.queueIndex; : state.queueIndex;
syncQueueToServer(newQueue, state.currentTrack, state.currentTime); syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex);
return { queue: newQueue, queueIndex: newQueueIndex }; return { queue: newQueue, queueIndex: newQueueIndex };
}); });
}, },
@@ -1988,12 +2504,22 @@ export const usePlayerStore = create<PlayerState>()(
currentTrack, currentTrack,
currentTime: serverTime > 0 ? serverTime : localTime, currentTime: serverTime > 0 ? serverTime : localTime,
}); });
void refreshWaveformForTrack(currentTrack.id);
} }
} catch (e) { } catch (e) {
console.error('Failed to initialize queue from server', e); console.error('Failed to initialize queue from server', e);
} }
}, },
reanalyzeLoudnessForTrack: async (trackId: string) => {
try {
showToast('Recalculating loudness for this track…', 2000, 'info');
} catch {
// no-op
}
await reseedLoudnessForTrackId(trackId);
},
updateReplayGainForCurrentTrack: () => { updateReplayGainForCurrentTrack: () => {
const { currentTrack, queue, queueIndex, volume } = get(); const { currentTrack, queue, queueIndex, volume } = get();
if (!currentTrack || !currentTrack.id) return; if (!currentTrack || !currentTrack.id) return;
@@ -2002,16 +2528,28 @@ export const usePlayerStore = create<PlayerState>()(
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb( const replayGainDb = resolveReplayGainDb(
currentTrack, prev, next, currentTrack, prev, next,
authState.replayGainEnabled, authState.replayGainMode, isReplayGainActive(), authState.replayGainMode,
); );
const replayGainPeak = authState.replayGainEnabled const replayGainPeak = isReplayGainActive()
? (currentTrack.replayGainPeak ?? null) ? (currentTrack.replayGainPeak ?? null)
: null; : null;
invoke('audio_update_replay_gain', { const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
const cachedLoud = cachedLoudnessGainByTrackId[currentTrack.id];
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud : null;
set(prevState => ({
normalizationNowDb:
normalization.normalizationEngineLive === 'loudness'
? (prevState.normalizationNowDb ?? cachedLoudDb)
: normalization.normalizationNowDb,
normalizationTargetLufs: normalization.normalizationTargetLufs,
normalizationEngineLive: normalization.normalizationEngineLive,
}));
invoke('audio_update_replay_gain', {
volume, volume,
replayGainDb, replayGainDb,
replayGainPeak, replayGainPeak,
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
preGainDb: authState.replayGainPreGainDb, preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb, fallbackDb: authState.replayGainFallbackDb,
}).catch(console.error); }).catch(console.error);
+18
View File
@@ -1989,6 +1989,24 @@ html[data-platform="windows"] .player-bar.floating {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.queue-current-tech-metric {
margin: 0;
padding: 0;
border: none;
background: transparent;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
color: inherit;
cursor: pointer;
text-decoration: none;
border-radius: 2px;
}
.queue-current-tech-metric:hover {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.queue-divider { .queue-divider {
padding: var(--space-3) var(--space-4) 0; padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0; flex-shrink: 0;