From 86704473edb54c0d29c8a659a5d63669e18b9b0e Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 25 Apr 2026 22:09:38 +0300 Subject: [PATCH 1/6] fix(audio): stabilize loudness normalization and streaming startup behavior Improve normalization consistency by separating cache refresh for non-playing tracks, hardening track-id cache lookups, and keeping UI gain state aligned with actual playback. Also reduce startup stalls by preferring non-seekable streaming when format hints are missing and by tuning ALSA/analysis paths to avoid underrun-prone churn. --- src-tauri/Cargo.lock | 75 +++ src-tauri/Cargo.toml | 2 + .../patches/cpal-0.15.3/src/host/alsa/mod.rs | 9 +- src-tauri/src/analysis_cache.rs | 559 ++++++++++++++++ src-tauri/src/audio.rs | 629 +++++++++++++++++- src-tauri/src/lib.rs | 214 ++++++ src/components/QueuePanel.tsx | 22 + src/components/WaveformSeek.tsx | 41 +- src/pages/Settings.tsx | 66 +- src/store/analysisSync.ts | 29 + src/store/authStore.ts | 15 + src/store/hotCacheStore.ts | 4 + src/store/offlineStore.ts | 4 + src/store/playerStore.ts | 460 ++++++++++++- 14 files changed, 2092 insertions(+), 37 deletions(-) create mode 100644 src-tauri/src/analysis_cache.rs create mode 100644 src/store/analysisSync.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7d140601..c7d7e38a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -944,6 +944,15 @@ dependencies = [ "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]] name = "dasp_sample" version = "0.11.0" @@ -1169,6 +1178,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "either" version = "1.15.0" @@ -1302,6 +1323,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fastrand" version = "1.9.0" @@ -1908,6 +1941,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "heck" version = "0.4.1" @@ -2548,6 +2590,17 @@ dependencies = [ "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]] name = "linux-raw-sys" version = "0.3.8" @@ -3657,6 +3710,7 @@ version = "1.43.0" dependencies = [ "biquad", "discord-rich-presence", + "ebur128", "futures-util", "id3", "libc", @@ -3665,6 +3719,7 @@ dependencies = [ "reqwest 0.12.28", "ringbuf", "rodio", + "rusqlite", "serde", "serde_json", "souvlaki", @@ -4116,6 +4171,20 @@ dependencies = [ "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]] name = "rustc-hash" version = "2.1.2" @@ -6115,6 +6184,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9b587f0..53a8dd13 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,6 +49,8 @@ lofty = "0.22" sysinfo = { version = "0.33", default-features = false, features = ["disk"] } id3 = "1.16.4" symphonia-adapter-libopus = "0.2.7" +rusqlite = { version = "0.37", features = ["bundled"] } +ebur128 = "0.1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/patches/cpal-0.15.3/src/host/alsa/mod.rs b/src-tauri/patches/cpal-0.15.3/src/host/alsa/mod.rs index 009ddef9..9c3b58d9 100644 --- a/src-tauri/patches/cpal-0.15.3/src/host/alsa/mod.rs +++ b/src-tauri/patches/cpal-0.15.3/src/host/alsa/mod.rs @@ -1063,10 +1063,11 @@ fn set_hw_params_from_format( hw_params.set_buffer_size(v as alsa::pcm::Frames)?; } BufferSize::Default => { - // These values together represent a moderate latency and wakeup interval. - // Without them, we are at the mercy of the device - hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?; - hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?; + // Larger buffer than upstream cpal default: after Symphonia seeks or CPU + // spikes (analysis, demux) the mixer needs more margin before ALSA underruns. + // ~40 ms period, ~200 ms total buffer (device may round to nearest supported). + hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?; + hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?; } } diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs new file mode 100644 index 00000000..257c9683 --- /dev/null +++ b/src-tauri/src/analysis_cache.rs @@ -0,0 +1,559 @@ +use std::path::PathBuf; +use std::io::Cursor; +use std::sync::Mutex; +use std::time::{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 = 1; +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, + 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, +} + +/// Ranged HTTP seeding uses `stream:` (see `playback_identity`); backfill +/// and IPC often use the bare ``. Rows may exist under either key. +fn track_id_cache_variants(id: &str) -> Vec { + 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 { + 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) }) + } + + 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, 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, 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, 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) +} + +pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<(), String> { + let Some(cache) = app.try_state::() else { + return Ok(()); + }; + let key = TrackKey { + track_id: track_id.to_string(), + md5_16kb: md5_first_16kb(bytes), + }; + cache.touch_track_status(&key, "queued")?; + + let waveform = WaveformEntry { + bins: derive_waveform_bins(bytes, 500), + 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)) = + analyze_loudness_from_audio_bytes(bytes, -16.0) + { + 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(()) +} + +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 { + 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 +} + +fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(f64, f64, f64, f64)> { + if bytes.is_empty() { + return None; + } + + 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 ebu: Option = None; + let mut ebu_channels: u32 = 0; + let mut sample_peak_abs = 0.0_f64; + let mut fed_any_frames = false; + 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(); + if 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::::new(decoded.capacity() as u64, spec); + samples.copy_interleaved_ref(decoded); + for &s in samples.samples() { + let v = (s as f64).abs(); + if v.is_finite() && v > sample_peak_abs { + sample_peak_abs = v; + } + } + let Some(ref mut ebu) = ebu else { + crate::app_deprintln!("[analysis] loudness failed: ebu not initialized"); + return None; + }; + match ebu.add_frames_f32(samples.samples()) { + Ok(_) => fed_any_frames = true, + Err(e) => { + crate::app_deprintln!("[analysis] loudness add_frames failed: {}", e); + return None; + } + } + loop_i = loop_i.wrapping_add(1); + if loop_i % 128 == 0 { + std::thread::yield_now(); + } + } + + 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: no decoder output"); + 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 { + // Fallback to sample peak if true-peak is not available for this stream/codec. + 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)) +} + +fn analysis_db_path(app: &tauri::AppHandle) -> Result { + 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(()) +} diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 3be25d25..afc56b1b 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -21,7 +21,41 @@ use symphonia::core::{ units::{self, Time}, }; use futures_util::StreamExt; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PartialWaveformPayload { + track_id: Option, + bins: Vec, + known_until_sec: f64, + duration_sec: f64, + is_partial: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PartialLoudnessPayload { + track_id: Option, + gain_db: f32, + target_lufs: f32, + is_partial: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WaveformUpdatedPayload { + track_id: String, + is_partial: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct NormalizationStatePayload { + engine: String, + current_gain_db: Option, + target_lufs: f32, +} + // ─── 10-Band Graphic Equalizer ──────────────────────────────────────────────── @@ -1021,17 +1055,20 @@ async fn track_download_task( gen: u64, gen_arc: Arc, http_client: reqwest::Client, + app: AppHandle, url: String, initial_response: reqwest::Response, mut prod: HeapProducer, done: Arc, promote_cache_slot: Arc>>, + normalization_target_lufs: Arc, ) { let mut downloaded: u64 = 0; let mut reconnects: u32 = 0; let mut next_response: Option = Some(initial_response); let mut capture: Vec = Vec::new(); let mut capture_over_limit = false; + let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { let response = if let Some(r) = next_response.take() { r @@ -1120,12 +1157,29 @@ async fn track_download_task( capture_over_limit = true; } } + if !capture_over_limit + && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) + { + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs); + last_partial_loudness_emit = Instant::now(); + } offset += pushed; downloaded += pushed as u64; } } } if !capture_over_limit && !capture.is_empty() { + if let Some(track_id) = playback_identity(&url) { + if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { + crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); + } else { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { track_id, is_partial: false }, + ); + } + } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url: url.clone(), data: capture, @@ -1144,12 +1198,15 @@ async fn ranged_download_task( gen: u64, gen_arc: Arc, http_client: reqwest::Client, + app: AppHandle, + duration_hint: f64, url: String, initial_response: reqwest::Response, buf: Arc>>, downloaded_to: Arc, done: Arc, promote_cache_slot: Arc>>, + normalization_target_lufs: Arc, ) { let total_size = buf.lock().unwrap().len(); let mut downloaded: usize = 0; @@ -1157,6 +1214,8 @@ async fn ranged_download_task( let mut next_response: Option = Some(initial_response); let dl_started = Instant::now(); let mut next_progress_mb: usize = 1; + let mut last_partial_emit = Instant::now(); + let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { let response = if let Some(r) = next_response.take() { @@ -1231,6 +1290,51 @@ async fn ranged_download_task( } downloaded += n; downloaded_to.store(downloaded, Ordering::SeqCst); + if downloaded >= 4096 + && total_size > 0 + && last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded) + { + let bins = + derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); + if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, + target_lufs, + is_partial: true, + }, + ); + crate::app_deprintln!( + "[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}", + (downloaded as f32 / total_size as f32) * 100.0, + provisional_db, + target_lufs, + playback_identity(&url) + ); + } + last_partial_loudness_emit = Instant::now(); + }; + let known_until_sec = if duration_hint > 0.0 { + (duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint) + } else { + 0.0 + }; + let _ = app.emit( + "analysis:waveform-partial", + PartialWaveformPayload { + track_id: playback_identity(&url), + bins, + known_until_sec, + duration_sec: duration_hint.max(0.0), + is_partial: true, + }, + ); + last_partial_emit = Instant::now(); + } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32; @@ -1262,11 +1366,144 @@ async fn ranged_download_task( if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { let data = buf.lock().unwrap().clone(); + if let Some(track_id) = playback_identity(&url) { + if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); + } else { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { track_id, is_partial: false }, + ); + } + } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay"); } } +/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more +/// to summarize, so we slow emits and keep UI responsive without CPU spikes. +fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration { + const MB: usize = 1024 * 1024; + if downloaded <= 3 * MB { + Duration::from_millis(280) + } else if downloaded <= 10 * MB { + Duration::from_millis(650) + } else { + Duration::from_millis(1100) + } +} + +/// Max centered-byte samples examined per bin for partial waveforms (full track +/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap). +const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048; + +fn peak_centered_byte_sampled(region: &[u8]) -> u8 { + if region.is_empty() { + return 0; + } + let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP; + let mut peak: u8 = 0; + if region.len() <= cap { + for &b in region { + let centered = if b >= 128 { b - 128 } else { 128 - b }; + if centered > peak { + peak = centered; + } + } + } else { + let step = (region.len() / cap).max(1); + let mut i = 0; + while i < region.len() { + let b = region[i]; + let centered = if b >= 128 { b - 128 } else { 128 - b }; + if centered > peak { + peak = centered; + } + i = i.saturating_add(step); + } + let b = region[region.len() - 1]; + let centered = if b >= 128 { b - 128 } else { 128 - b }; + if centered > peak { + peak = centered; + } + } + peak +} + +/// Partial waveform without cloning the whole download buffer and without +/// holding `buf` locked across all bins (that would stall the decoder's `read()`). +fn derive_partial_waveform_bins_short_locks( + buf: &Arc>>, + downloaded: usize, + bin_count: usize, +) -> Vec { + if downloaded == 0 || bin_count == 0 { + return Vec::new(); + } + let len = downloaded; + let mut out = vec![0u8; bin_count]; + for (i, slot) in out.iter_mut().enumerate() { + let start = i * len / bin_count; + let end = ((i + 1) * len / bin_count).max(start + 1).min(len); + let peak = { + let b = buf.lock().unwrap(); + if start >= b.len() { + 0u8 + } else { + let end = end.min(b.len()); + peak_centered_byte_sampled(&b[start..end]) + } + }; + *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; + } + out +} + +fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) { + if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES { + crate::app_deprintln!( + "[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}", + bytes.len(), + PARTIAL_LOUDNESS_MIN_BYTES + ); + return; + } + // Lightweight fallback based on buffered bytes count to keep CPU low. + let mb = bytes.len() as f32 / (1024.0 * 1024.0); + let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5); + let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0); + crate::app_deprintln!( + "[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}", + bytes.len(), + gain_db, + target_lufs, + playback_identity(url) + ); + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(url), + gain_db: gain_db as f32, + target_lufs, + is_partial: true, + }, + ); +} + +fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option { + if total_size == 0 || downloaded == 0 { + return None; + } + let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0); + // Move from startup attenuation toward a more realistic late-stream level. + // This avoids staying near -2 dB and then jumping hard when final LUFS lands. + let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0); + let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0); + let shaped = progress.powf(0.75); + Some(start_db + (end_db - start_db) * shaped) +} + fn content_type_to_hint(ct: &str) -> Option { let ct = ct.to_ascii_lowercase(); if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) } @@ -1999,6 +2236,10 @@ pub struct AudioEngine { /// When true, audio_play chains sources to the existing Sink instead of /// creating a new one, achieving sample-accurate gapless transitions. pub gapless_enabled: Arc, + /// 0=off, 1=replaygain, 2=loudness (future runtime loudness engine). + pub normalization_engine: Arc, + /// Target loudness in LUFS for loudness engine (future use). + pub normalization_target_lufs: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. pub(crate) chained_info: Arc>>, @@ -2015,6 +2256,10 @@ pub struct AudioEngine { /// Active radio session state. None for regular (non-radio) tracks. /// Dropping the value aborts the HTTP download task via RadioLiveState::Drop. pub(crate) radio_state: Mutex>, + /// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can + /// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null` + /// (otherwise `compute_gain` would treat that as unity gain and playback "jumps"). + pub(crate) current_playback_url: Arc>>, } pub struct AudioCurrent { @@ -2190,11 +2435,13 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { // Set PipeWire / PulseAudio latency hints before the first open. #[cfg(target_os = "linux")] { + // Match cpal ALSA ~200 ms headroom: larger quantum reduces underruns when + // the decoder thread catches up after seek or competes with other work. if std::env::var("PIPEWIRE_LATENCY").is_err() { - std::env::set_var("PIPEWIRE_LATENCY", "4096/48000"); + std::env::set_var("PIPEWIRE_LATENCY", "8192/48000"); } if std::env::var("PULSE_LATENCY_MSEC").is_err() { - std::env::set_var("PULSE_LATENCY_MSEC", "85"); + std::env::set_var("PULSE_LATENCY_MSEC", "170"); } } @@ -2276,12 +2523,15 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())), fading_out_sink: Arc::new(Mutex::new(None)), gapless_enabled: Arc::new(AtomicBool::new(false)), + normalization_engine: Arc::new(AtomicU32::new(0)), + normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())), chained_info: Arc::new(Mutex::new(None)), samples_played: Arc::new(AtomicU64::new(0)), current_sample_rate: Arc::new(AtomicU32::new(0)), current_channels: Arc::new(AtomicU32::new(2)), gapless_switch_at: Arc::new(AtomicU64::new(0)), radio_state: Mutex::new(None), + current_playback_url: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -2343,6 +2593,101 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool { } } +fn resolve_loudness_gain_from_cache( + app: &AppHandle, + url: &str, + target_lufs: f32, + requested_loudness_gain_db: Option, +) -> Option { + // Never trust `requested` alone: the frontend may pass the *next* track's gain + // while `current_playback_url` still lags one play behind. Always prefer a + // cache row for **this** URL's track_id; use `requested` only on cache miss + // (provisional / pre-seed from JS). + let Some(track_id) = playback_identity(url) else { + if let Some(r) = requested_loudness_gain_db { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}", + r + ); + } + return requested_loudness_gain_db; + }; + let Some(cache) = app.try_state::() else { + if let Some(r) = requested_loudness_gain_db { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}", + r, + track_id + ); + } + return requested_loudness_gain_db; + }; + // Also touch waveform row here so playback path verifies current context is present. + let _ = cache.get_latest_waveform_for_track(&track_id); + match cache.get_latest_loudness_for_track(&track_id) { + Ok(Some(row)) if row.integrated_lufs.is_finite() => { + let recommended = crate::analysis_cache::recommended_gain_for_target( + row.integrated_lufs, + row.true_peak, + target_lufs as f64, + ) as f32; + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache track_id={} gain_db={:.2} target_lufs={:.2} integrated_lufs={:.2} updated_at={}", + track_id, + recommended, + target_lufs, + row.integrated_lufs, + row.updated_at + ); + Some(recommended) + } + Ok(Some(row)) => { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}", + track_id, + row.integrated_lufs + ); + None + } + Ok(None) => { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-miss track_id={}", + track_id + ); + if let Some(r) = requested_loudness_gain_db { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}", + track_id, + r + ); + Some(r) + } else { + None + } + } + Err(e) => { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-error track_id={} err={}", + track_id, + e + ); + None + } + } +} + +/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until +/// analysis exists — must never return `None` here or `compute_gain` uses unity. +fn loudness_gain_db_or_startup( + app: &AppHandle, + url: &str, + target_lufs: f32, + requested: Option, +) -> Option { + resolve_loudness_gain_from_cache(app, url, target_lufs, requested) + .or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)) +} + /// Take (consume) completed manual-stream bytes if they correspond to `url`. pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option> { let mut guard = state.stream_completed_cache.lock().unwrap(); @@ -2439,23 +2784,104 @@ async fn fetch_data( /// can produce inter-sample peaks slightly above ±1.0 → audible distortion. /// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping. const MASTER_HEADROOM: f32 = 0.891_254; +const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024; +const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 350; +/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up +/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor. +const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0; fn compute_gain( + normalization_engine: u32, replay_gain_db: Option, replay_gain_peak: Option, + loudness_gain_db: Option, pre_gain_db: f32, fallback_db: f32, volume: f32, ) -> (f32, f32) { - let gain_linear = replay_gain_db - .map(|db| 10f32.powf((db + pre_gain_db) / 20.0)) - .unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)); - let peak = replay_gain_peak.unwrap_or(1.0).max(0.001); + let gain_linear = match normalization_engine { + 2 => loudness_gain_db + .map(|db| 10f32.powf(db / 20.0)) + .unwrap_or(1.0), + 1 => replay_gain_db + .map(|db| 10f32.powf((db + pre_gain_db) / 20.0)) + .unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)), + _ => 1.0, + }; + let peak = if normalization_engine == 1 { + replay_gain_peak.unwrap_or(1.0).max(0.001) + } else { + 1.0 + }; let gain_linear = gain_linear.min(1.0 / peak); let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); (gain_linear, effective) } +fn normalization_engine_name(mode: u32) -> &'static str { + match mode { + 1 => "replaygain", + 2 => "loudness", + _ => "off", + } +} + +fn gain_linear_to_db(gain_linear: f32) -> Option { + if gain_linear.is_finite() && gain_linear > 0.0 { + Some(20.0 * gain_linear.log10()) + } else { + None + } +} + +/// `audio:normalization-state` “Now dB” for the UI: omit a number while loudness +/// mode is still on the **startup safety trim** only (no cache row / no explicit +/// requested gain from analysis), so users do not read `-6 dB` as measured LUFS. +fn loudness_ui_current_gain_db( + norm_mode: u32, + resolved_loudness_gain_db: Option, + gain_linear: f32, +) -> Option { + if norm_mode == 2 && resolved_loudness_gain_db.is_none() { + None + } else { + gain_linear_to_db(gain_linear) + } +} + +fn ramp_sink_volume(sink: Arc, from: f32, to: f32) { + let from = from.clamp(0.0, 1.0); + let to = to.clamp(0.0, 1.0); + if (to - from).abs() < 0.002 { + sink.set_volume(to); + return; + } + static RAMP_GEN: AtomicU64 = AtomicU64::new(0); + let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1; + std::thread::spawn(move || { + let delta = (to - from).abs(); + // Stretch large corrections to avoid audible "step down" moments. + let (steps, step_ms): (usize, u64) = if delta > 0.30 { + (24, 35) + } else if delta > 0.18 { + (18, 30) + } else if delta > 0.10 { + (14, 24) + } else { + (8, 16) + }; + for i in 1..=steps { + if RAMP_GEN.load(Ordering::SeqCst) != my_gen { + return; + } + let t = i as f32 / steps as f32; + let v = from + (to - from) * t; + sink.set_volume(v.clamp(0.0, 1.0)); + std::thread::sleep(Duration::from_millis(step_ms)); + } + }); +} + // ─── Commands ───────────────────────────────────────────────────────────────── #[tauri::command] @@ -2465,6 +2891,7 @@ pub async fn audio_play( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + loudness_gain_db: Option, pre_gain_db: f32, fallback_db: f32, manual: bool, // true = user-initiated skip → bypass crossfade, start immediately @@ -2541,6 +2968,11 @@ pub async fn audio_play( old.stop(); } + // Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from + // a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not + // the previous URL still stored until the sink swap completes. + *state.current_playback_url.lock().unwrap() = Some(url.clone()); + // Extract format hint from URL for better symphonia probing. Strip the // query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) // don't latch onto random query-param substrings; only accept short @@ -2646,7 +3078,11 @@ pub async fn audio_play( .is_some_and(|v| v.to_ascii_lowercase().contains("bytes")); let total_size = response.content_length(); - if let (true, Some(total)) = (supports_range, total_size) { + // Guardrail: when format/container hint is unknown, some demuxers may + // seek near EOF during probe. With a progressively downloaded ranged + // source that can delay first audible samples until most/all bytes are + // fetched. Prefer sequential streaming in that case for faster start. + if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) { let total_usize = total as usize; crate::app_deprintln!( "[stream] RangedHttpSource selected — total={} KB, hint={:?}", @@ -2660,12 +3096,15 @@ pub async fn audio_play( gen, state.generation.clone(), audio_http_client(&state), + app.clone(), + duration_hint, url.clone(), response, buf.clone(), downloaded_to.clone(), done.clone(), state.stream_completed_cache.clone(), + state.normalization_target_lufs.clone(), )); let reader = RangedHttpSource { buf, @@ -2683,8 +3122,8 @@ pub async fn audio_play( } } else { crate::app_deprintln!( - "[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}", - supports_range, total_size + "[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}", + supports_range, total_size, stream_hint ); let buffer_cap = total_size .map(|n| n as usize) @@ -2697,11 +3136,13 @@ pub async fn audio_play( gen, state.generation.clone(), audio_http_client(&state), + app.clone(), url.clone(), response, prod, done.clone(), state.stream_completed_cache.clone(), + state.normalization_target_lufs.clone(), )); let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::>(); @@ -2735,7 +3176,45 @@ pub async fn audio_play( return Ok(()); } - let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); + let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); + let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db); + let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let startup_loudness_gain_db = if norm_mode == 2 { + loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + } else { + resolved_loudness_gain_db + }; + let (gain_linear, effective_volume) = compute_gain( + norm_mode, + replay_gain_db, + replay_gain_peak, + startup_loudness_gain_db, + pre_gain_db, + fallback_db, + volume, + ); + let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear); + crate::app_deprintln!( + "[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}", + playback_identity(&url), + normalization_engine_name(norm_mode), + replay_gain_db, + replay_gain_peak, + resolved_loudness_gain_db, + gain_linear, + current_gain_db, + target_lufs, + volume, + effective_volume + ); + let _ = app.emit( + "audio:normalization-state", + NormalizationStatePayload { + engine: normalization_engine_name(norm_mode).to_string(), + current_gain_db, + target_lufs, + }, + ); // Manual skips (user-initiated) bypass crossfade — the track should start immediately. let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual; @@ -2999,6 +3478,7 @@ pub async fn audio_play( state.current_sample_rate.clone(), state.current_channels.clone(), state.gapless_switch_at.clone(), + state.current_playback_url.clone(), ); Ok(()) @@ -3020,9 +3500,11 @@ pub async fn audio_chain_preload( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + loudness_gain_db: Option, pre_gain_db: f32, fallback_db: f32, hi_res_enabled: bool, + app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { // Idempotent: already chained this track → nothing to do. @@ -3087,7 +3569,22 @@ pub async fn audio_chain_preload( // current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl. // the still-playing current source). Volume for the chained track is // applied at the gapless transition in `spawn_progress_task`, not here. - let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); + let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); + let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let chain_loudness_db = if norm_mode == 2 { + loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + } else { + resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db) + }; + let (gain_linear, _effective_volume) = compute_gain( + norm_mode, + replay_gain_db, + replay_gain_peak, + chain_loudness_db, + pre_gain_db, + fallback_db, + volume, + ); let done_next = Arc::new(AtomicBool::new(false)); // Use a dedicated counter for the chained source — it will be swapped into @@ -3189,6 +3686,7 @@ fn spawn_progress_task( sample_rate_arc: Arc, channels_arc: Arc, gapless_switch_at: Arc, + current_playback_url: Arc>>, ) { tokio::spawn(async move { let mut near_end_ticks: u32 = 0; @@ -3239,6 +3737,7 @@ fn spawn_progress_task( // must only be called at the boundary, not at preload. { let mut cur = current_arc.lock().unwrap(); + let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); cur.replay_gain_linear = info.replay_gain_linear; cur.base_volume = info.base_volume; cur.duration_secs = info.duration_secs; @@ -3246,10 +3745,12 @@ fn spawn_progress_task( cur.play_started = Some(Instant::now()); if let Some(sink) = &cur.sink { let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); - sink.set_volume(effective); + ramp_sink_volume(Arc::clone(sink), prev_effective, effective); } } + *current_playback_url.lock().unwrap() = Some(info.url.clone()); + // Record the gapless switch timestamp for ghost-command guard. let switch_ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -3411,6 +3912,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu #[tauri::command] pub fn audio_stop(state: State<'_, AudioEngine>) { state.generation.fetch_add(1, Ordering::SeqCst); + *state.current_playback_url.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; *state.stream_completed_cache.lock().unwrap() = None; // Drop RadioLiveState → triggers Drop → task.abort() → TCP released. @@ -3525,9 +4027,11 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str #[tauri::command] pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) { let mut cur = state.current.lock().unwrap(); + let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); cur.base_volume = volume.clamp(0.0, 1.0); if let Some(sink) = &cur.sink { - sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0)); + let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); + ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective); } } @@ -3536,17 +4040,67 @@ pub fn audio_update_replay_gain( volume: f32, replay_gain_db: Option, replay_gain_peak: Option, + loudness_gain_db: Option, pre_gain_db: f32, fallback_db: f32, + app: AppHandle, state: State<'_, AudioEngine>, ) { - let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume); + let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); + let url_for_loudness = if norm_mode == 2 { + state.current_playback_url.lock().unwrap().clone() + } else { + None + }; + let resolved_loudness_gain_db = url_for_loudness + .as_deref() + .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)); + let effective_loudness_db = if norm_mode == 2 { + match url_for_loudness.as_deref() { + Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db), + None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)), + } + } else { + loudness_gain_db + }; + let (gain_linear, effective) = compute_gain( + norm_mode, + replay_gain_db, + replay_gain_peak, + effective_loudness_db, + pre_gain_db, + fallback_db, + volume, + ); + let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear); + crate::app_deprintln!( + "[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}", + normalization_engine_name(norm_mode), + replay_gain_db, + replay_gain_peak, + loudness_gain_db, + gain_linear, + current_gain_db, + target_lufs, + volume, + effective + ); let mut cur = state.current.lock().unwrap(); + let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0); cur.replay_gain_linear = gain_linear; cur.base_volume = volume.clamp(0.0, 1.0); if let Some(sink) = &cur.sink { - sink.set_volume(effective); + ramp_sink_volume(Arc::clone(sink), prev_effective, effective); } + let _ = app.emit( + "audio:normalization-state", + NormalizationStatePayload { + engine: normalization_engine_name(norm_mode).to_string(), + current_gain_db, + target_lufs, + }, + ); } /// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions. @@ -3636,6 +4190,16 @@ pub async fn audio_preload( response.bytes().await.map_err(|e| e.to_string())?.into() }; let _ = duration_hint; // kept in API for compatibility + if let Some(track_id) = playback_identity(&url) { + if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); + } else { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { track_id, is_partial: false }, + ); + } + } let url_for_emit = url.clone(); *state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data }); let _ = app.emit("audio:preload-ready", url_for_emit); @@ -3780,6 +4344,8 @@ pub async fn audio_play_radio( cur.fadeout_samples = Some(fadeout_samples); } + *state.current_playback_url.lock().unwrap() = Some(url.clone()); + state.current_sample_rate.store(sample_rate, Ordering::Relaxed); state.current_channels.store(channels as u32, Ordering::Relaxed); @@ -3798,6 +4364,7 @@ pub async fn audio_play_radio( state.current_sample_rate.clone(), state.current_channels.clone(), state.gapless_switch_at.clone(), + state.current_playback_url.clone(), ); Ok(()) @@ -3980,6 +4547,36 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { state.gapless_enabled.store(enabled, Ordering::Relaxed); } +#[tauri::command] +pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) { + let mode = match engine.as_str() { + "replaygain" => 1, + "loudness" => 2, + _ => 0, + }; + state.normalization_engine.store(mode, Ordering::Relaxed); + let target = target_lufs.clamp(-30.0, -8.0); + state + .normalization_target_lufs + .store(target.to_bits(), Ordering::Relaxed); + crate::app_deprintln!( + "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}", + engine, + normalization_engine_name(mode), + target + ); + let _ = app.emit( + "audio:normalization-state", + NormalizationStatePayload { + engine: normalization_engine_name(mode).to_string(), + // At mode-switch time the effective track gain may not be recalculated yet. + // Emit `None` and let audio_play/audio_update_replay_gain publish actual value. + current_gain_db: None, + target_lufs: target, + }, + ); +} + // ─── Device-change watcher ──────────────────────────────────────────────────── // // Polls every 3 s for two conditions: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4a39196..6122200a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod audio; +mod analysis_cache; pub mod cli; mod discord; pub(crate) mod logging; @@ -59,6 +60,12 @@ fn sync_cancel_flags() -> &'static Mutex>> { FLAGS.get_or_init(|| Mutex::new(HashMap::new())) } +/// Tracks analysis backfill jobs already running per track_id. +fn analysis_backfill_inflight() -> &'static Mutex> { + static SET: OnceLock>> = 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. /// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. type TrayState = Mutex>; @@ -67,6 +74,34 @@ type TrayState = Mutex>; /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct WaveformCachePayload { + bins: Vec, + 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] fn greet(name: &str) -> String { format!("Hello, {}!", name) @@ -1131,6 +1166,126 @@ fn check_dir_accessible(path: String) -> bool { 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, String> { + let key = analysis_cache::TrackKey { track_id, md5_16kb }; + let row = cache.get_waveform(&key)?; + 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, String> { + let row = cache.get_latest_waveform_for_track(&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, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, 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_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::() { + 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::(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 ────────────────────────────────────────────────────── /// Streams an HTTP response body directly to `dest_path` in small chunks. @@ -1151,6 +1306,47 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path Ok(()) } +fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { + analysis_cache::seed_from_bytes(app, track_id, bytes) + .map_err(|e| { + crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); + e + })?; + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.to_string(), + is_partial: false, + }, + ); + let has_loudness = app + .try_state::() + .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. /// Returns the absolute file path so TypeScript can store it and later /// construct a `psysonic-local://` URL for the audio engine. @@ -1217,6 +1413,8 @@ async fn download_track_offline( .await .map_err(|e| e.to_string())?; + enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await; + Ok(path_str) } @@ -1765,6 +1963,8 @@ async fn download_track_hot_cache( .await .map_err(|e| e.to_string())?; + enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await; + let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) @@ -1818,6 +2018,8 @@ async fn promote_stream_cache_to_hot_cache( .await .map_err(|e| e.to_string())?; + let _ = enqueue_analysis_seed(&app, &track_id, &bytes); + let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) @@ -3582,6 +3784,13 @@ pub fn run() { })) .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 ───────────────────────────────── // Remove OS window decorations on all Linux so the React TitleBar // can take over. The frontend checks is_tiling_wm() to decide @@ -3820,6 +4029,7 @@ pub fn run() { audio::audio_play_radio, audio::audio_set_crossfade, audio::audio_set_gapless, + audio::audio_set_normalization, audio::audio_list_devices, audio::audio_canonicalize_selected_device, audio::audio_default_output_device_name, @@ -3850,6 +4060,10 @@ pub fn run() { fetch_json_url, fetch_icy_metadata, resolve_stream_url, + analysis_get_waveform, + analysis_get_waveform_for_track, + analysis_get_loudness_for_track, + analysis_enqueue_seed_from_url, download_track_offline, delete_offline_track, get_offline_cache_size, diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 6cd523e2..20268c19 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -258,6 +258,9 @@ export default function QueuePanel() { const contextMenu = usePlayerStore(s => s.contextMenu); 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 crossfadeSecs = useAuthStore(s => s.crossfadeSecs); @@ -267,6 +270,8 @@ export default function QueuePanel() { const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); 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 setTab = useLyricsStore(s => s.setTab); @@ -478,6 +483,14 @@ export default function QueuePanel() { const rgParts = formatQueueReplayGainParts(currentTrack, t); const baseLine = baseParts.join(' · '); const rgLine = rgParts.join(' · '); + const liveGainLabel = normalizationNowDb != null + ? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB` + : '—'; + const targetLabel = normalizationEngineLive === 'loudness' + ? (normalizationTargetLufs != null ? `${normalizationTargetLufs} LUFS` : '-16 LUFS') + : normalizationEngineLive === 'replaygain' + ? `ReplayGain (${replayGainMode})` + : t('queue.normOff', { defaultValue: 'Off' }); if (!baseLine && !rgLine && !playbackSource) return null; const showRgLine = expandReplayGain && !!rgLine; return ( @@ -522,6 +535,15 @@ export default function QueuePanel() { {' · '}{rgLine} )} + {(normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness') && ( + + {t('queue.normNow', { defaultValue: 'Now' })} + {' · '}{liveGainLabel} + {' · '} + {t('queue.normTarget', { defaultValue: 'Target' })} + {' · '}{targetLabel} + + )} ); diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 3f710723..7781c6d1 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -824,6 +824,7 @@ export default function WaveformSeek({ trackId }: Props) { const SEEK_COMMIT_PROGRESS_EPS = 0.02; const canvasRef = useRef(null); const heightsRef = useRef(null); + const lastHeightsTrackIdRef = useRef(undefined); const progressRef = useRef(usePlayerStore.getState().progress); const bufferedRef = useRef(usePlayerStore.getState().buffered); const isDragging = useRef(false); @@ -832,6 +833,10 @@ export default function WaveformSeek({ trackId }: Props) { const [hoverPct, setHoverPct] = useState(null); const seek = usePlayerStore(s => s.seek); + const waveformBins = usePlayerStore(s => s.waveformBins); + const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial); + const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec); + const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); @@ -843,10 +848,44 @@ export default function WaveformSeek({ trackId }: Props) { useEffect(() => { if (!trackId) { heightsRef.current = null; + lastHeightsTrackIdRef.current = undefined; + return; + } + if (waveformBins && waveformBins.length > 0) { + const src = waveformBins; + const h = new Float32Array(BAR_COUNT); + const effectiveDur = waveformDurationSec > 0 ? waveformDurationSec : duration; + const knownFrac = waveformIsPartial && effectiveDur > 0 + ? Math.max(0, Math.min(1, waveformKnownUntilSec / effectiveDur)) + : 1; + const knownBars = Math.max(0, Math.min(BAR_COUNT, Math.floor(knownFrac * BAR_COUNT))); + for (let i = 0; i < BAR_COUNT; i++) { + if (i >= knownBars) { + h[i] = 0.08; // unknown tail baseline while partial waveform is still loading + continue; + } + 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))); + } + // Partial -> full handoff: blend with previous heights for a smoother + // visual transition and avoid a hard "jump" in the waveform shape. + if (lastHeightsTrackIdRef.current === trackId && heightsRef.current && heightsRef.current.length === BAR_COUNT) { + const prev = heightsRef.current; + const mixed = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + mixed[i] = prev[i] * 0.45 + h[i] * 0.55; + } + heightsRef.current = mixed; + } else { + heightsRef.current = h; + } + lastHeightsTrackIdRef.current = trackId; return; } heightsRef.current = makeHeights(trackId); - }, [trackId]); + lastHeightsTrackIdRef.current = trackId; + }, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]); // Imperative subscription — no React re-renders from progress changes. // Static styles draw here; animated styles only update refs. diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8236b535..c03a2a0c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2224,12 +2224,71 @@ export default function Settings() {
{t('settings.replayGainDesc')}
{auth.replayGainEnabled && (
+
+ Engine: + + +
+ {auth.normalizationEngine === 'loudness' && ( + <> +
+ Apple-like automatic normalization. ReplayGain tags are ignored in this mode. +
+
+ Target LUFS: + + + +
+ + )} + {auth.normalizationEngine === 'replaygain' && (
{t('settings.replayGainMode')}:
- {auth.replayGainMode === 'auto' && ( + )} + {auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
{t('settings.replayGainAutoDesc')}
)}
)} - {auth.replayGainEnabled && ( + {auth.replayGainEnabled && auth.normalizationEngine === 'replaygain' && (
diff --git a/src/store/analysisSync.ts b/src/store/analysisSync.ts new file mode 100644 index 00000000..31f4f7f5 --- /dev/null +++ b/src/store/analysisSync.ts @@ -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(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; + if (!ce?.detail) return; + listener(ce.detail); + }; + window.addEventListener(EVENT_NAME, wrapped as EventListener); + return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener); +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 93f3f75a..d7afd2c2 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -20,6 +20,7 @@ export interface ServerProfile { export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; export type LoggingMode = 'off' | 'normal' | 'debug'; +export type NormalizationEngine = 'off' | 'replaygain' | 'loudness'; export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } @@ -49,6 +50,8 @@ interface AuthState { excludeAudiobooks: boolean; customGenreBlacklist: string[]; replayGainEnabled: boolean; + normalizationEngine: NormalizationEngine; + loudnessTargetLufs: -16 | -14 | -12; replayGainMode: 'track' | 'album' | 'auto'; replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) @@ -205,6 +208,8 @@ interface AuthState { setExcludeAudiobooks: (v: boolean) => void; setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; + setNormalizationEngine: (v: NormalizationEngine) => void; + setLoudnessTargetLufs: (v: -16 | -14 | -12) => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; @@ -315,6 +320,8 @@ export const useAuthStore = create()( excludeAudiobooks: false, customGenreBlacklist: [], replayGainEnabled: false, + normalizationEngine: 'off', + loudnessTargetLufs: -14, replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, @@ -442,6 +449,14 @@ export const useAuthStore = create()( set({ replayGainEnabled: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); }, + setNormalizationEngine: (v) => { + set({ normalizationEngine: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, + setLoudnessTargetLufs: (v) => { + set({ loudnessTargetLufs: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, setReplayGainMode: (v) => { set({ replayGainMode: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); diff --git a/src/store/hotCacheStore.ts b/src/store/hotCacheStore.ts index 1ac26e8b..960a549b 100644 --- a/src/store/hotCacheStore.ts +++ b/src/store/hotCacheStore.ts @@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate'; import type { Track } from './playerStore'; +import { emitAnalysisStorageChanged } from './analysisSync'; /** How many queue slots after the current index are eviction-protected (1 = current + next only). */ export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1; @@ -96,6 +97,7 @@ export const useHotCacheStore = create()( delete next[entryKey(serverId, trackId)]; return { entries: next }; }); + emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' }); }, totalBytes: () => @@ -167,6 +169,7 @@ export const useHotCacheStore = create()( }).catch(() => {}); sum -= meta.sizeBytes || 0; delete entries[key]; + emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' }); } set({ entries }); @@ -175,6 +178,7 @@ export const useHotCacheStore = create()( clearAllDisk: async (customDir: string | null) => { await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {}); set({ entries: {} }); + emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' }); }, }), { diff --git a/src/store/offlineStore.ts b/src/store/offlineStore.ts index 8021e952..880768cd 100644 --- a/src/store/offlineStore.ts +++ b/src/store/offlineStore.ts @@ -6,6 +6,7 @@ import type { SubsonicSong } from '../api/subsonic'; import { useAuthStore } from './authStore'; import { showToast } from '../utils/toast'; import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore'; +import { emitAnalysisStorageChanged } from './analysisSync'; export interface OfflineTrackMeta { id: string; @@ -299,6 +300,9 @@ export const useOfflineStore = create()( }).catch(() => {}); }), ); + for (const trackId of album.trackIds) { + emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' }); + } set(state => { const tracks = { ...state.tracks }; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ccf7956f..95fbd81e 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; import { useHotCacheStore } from './hotCacheStore'; +import { onAnalysisStorageChanged } from './analysisSync'; export interface Track { id: string; @@ -143,6 +144,19 @@ async function buildInfiniteQueueCandidates( interface PlayerState { currentTrack: Track | null; + waveformBins: number[] | null; + waveformIsPartial: boolean; + waveformKnownUntilSec: number; + waveformDurationSec: number; + 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; /** Latches the source used to start the currently playing track. */ currentPlaybackSource: PlaybackSourceKind | null; @@ -241,6 +255,29 @@ interface PlayerState { closeSongInfo: () => void; } +type WaveformCachePayload = { + bins: number[]; + binCount: number; + isPartial: boolean; + knownUntilSec: number; + durationSec: number; + updatedAt: number; +}; + +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 ───────────────────────────────────────── // isAudioPaused — true when the Rust audio engine has a loaded-but-paused track. @@ -259,6 +296,66 @@ let radioFetching = false; // Artist ID used to start the current radio session — persists across track // advances so proactive loading works even when songs lack artistId. let currentRadioArtistId: string | null = null; +let cachedLoudnessGainByTrackId: Record = {}; +let stableLoudnessGainByTrackId: Record = {}; +let lastNormalizationUiUpdateAtMs = 0; + +function emitNormalizationDebug(step: string, details?: Record) { + 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. let seekDebounce: ReturnType | null = null; @@ -267,6 +364,9 @@ let seekDebounce: ReturnType | null = null; let seekTarget: number | null = null; let seekTargetSetAt = 0; const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000; +const analysisBackfillInFlightByTrackId: Record = {}; +const analysisBackfillAttemptsByTrackId: Record = {}; +const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2; // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. let seekFallbackRetryTimer: ReturnType | null = null; let seekFallbackRetryStartedAt = 0; @@ -481,6 +581,102 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) { useHotCacheStore.getState().touchPlayed(trackId, serverId); } +function isReplayGainActive() { + const a = useAuthStore.getState(); + return a.normalizationEngine === 'replaygain' && a.replayGainEnabled; +} + +async function refreshWaveformForTrack(trackId: string) { + if (!trackId) return; + try { + const row = await invoke('analysis_get_waveform_for_track', { trackId }); + if (!row || !Array.isArray(row.bins) || row.bins.length === 0) { + usePlayerStore.setState({ + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + }); + return; + } + usePlayerStore.setState({ + waveformBins: row.bins, + waveformIsPartial: !!row.isPartial, + waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0, + waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0, + }); + } 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('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 }); + } +} + async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) { try { const res = await invoke<{ path: string; size: number } | null>( @@ -567,7 +763,19 @@ function handleAudioProgress(current_time: number, duration: number) { const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; - usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); + const stateNow = usePlayerStore.getState(); + if (stateNow.waveformIsPartial) { + usePlayerStore.setState({ + currentTime: displayTime, + progress, + buffered: 0, + // Keep known span aligned with playback position during partial stage: + // if playback moved forward, this part is definitely known already. + waveformKnownUntilSec: Math.max(stateNow.waveformKnownUntilSec || 0, displayTime), + }); + } else { + usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); + } // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) if (progress >= 0.5 && !store.scrobbled) { @@ -641,6 +849,9 @@ function handleAudioProgress(current_time: number, duration: number) { // Byte pre-download — runs early so bytes are cached by chain time. if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) { bytePreloadingId = nextTrack.id; + // Keep analysis context warm for the upcoming track before any source swap. + void refreshWaveformForTrack(nextTrack.id); + void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); if (import.meta.env.DEV) { console.info('[psysonic][preload-request]', { nextTrackId: nextTrack.id, @@ -657,6 +868,8 @@ function handleAudioProgress(current_time: number, duration: number) { // Gapless chain — decode + chain into Sink 30s before track boundary. if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) { gaplessPreloadingId = nextTrack.id; + // Ensure loudness gain is already cached for the chained request payload. + void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); const authState = useAuthStore.getState(); // Auto-mode neighbours for the *next* track: current track on its left, // queue[nextIdx+1] on its right. @@ -665,9 +878,9 @@ function handleAudioProgress(current_time: number, duration: number) { : (repeatMode === 'all' && queue.length > 0 ? queue[0] : null); const replayGainDb = resolveReplayGainDb( nextTrack, track, nextNeighbour, - authState.replayGainEnabled, authState.replayGainMode, + isReplayGainActive(), authState.replayGainMode, ); - const replayGainPeak = authState.replayGainEnabled + const replayGainPeak = isReplayGainActive() ? (nextTrack.replayGainPeak ?? null) : null; invoke('audio_chain_preload', { @@ -676,6 +889,7 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, + loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null, preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, @@ -700,7 +914,14 @@ function handleAudioEnded() { const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); isAudioPaused = false; - usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); + usePlayerStore.setState({ + isPlaying: false, + progress: 0, + currentTime: 0, + buffered: 0, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + }); setTimeout(() => { if (repeatMode === 'one' && currentTrack) { usePlayerStore.getState().playTrack(currentTrack, queue, false); @@ -744,6 +965,13 @@ function handleAudioTrackSwitched(duration: number) { usePlayerStore.setState({ currentTrack: nextTrack, + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), + normalizationDbgSource: 'track-switched', + normalizationDbgTrackId: nextTrack.id, queueIndex: newIndex, isPlaying: true, progress: 0, @@ -752,6 +980,14 @@ function handleAudioTrackSwitched(duration: number) { scrobbled: 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 const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); @@ -817,6 +1053,90 @@ export function initAudioListeners(): () => void { listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), + listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => { + const current = usePlayerStore.getState().currentTrack; + if (!current || !payload) return; + const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); + if (payloadTrackId && payloadTrackId !== current.id) return; + if (!payload.isPartial || !payload?.bins?.length) { + usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 }); + return; + } + usePlayerStore.setState({ + waveformBins: payload.bins, + waveformIsPartial: true, + waveformKnownUntilSec: Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0, + waveformDurationSec: Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0), + }); + }), + 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 currentId = usePlayerStore.getState().currentTrack?.id; + if (currentId && payloadTrackId === currentId) { + void refreshWaveformForTrack(currentId); + 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('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(); + if (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('audio:preload-ready', ({ payload }) => { const tid = streamUrlTrackId(payload); if (import.meta.env.DEV) { @@ -840,17 +1160,83 @@ export function initAudioListeners(): () => void { const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState(); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).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, + }).catch(() => {}); + if (normCfg.normalizationEngine === 'loudness') { + const currentId = usePlayerStore.getState().currentTrack?.id; + if (currentId) { + void refreshLoudnessForTrack(currentId).finally(() => { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }); + } + } if (audioOutputDevice) { invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {}); } // Keep audio settings in sync whenever auth store changes. + let prevNormEngine = normCfg.normalizationEngine; + let prevNormTarget = normCfg.loudnessTargetLufs; const unsubAuth = useAuthStore.subscribe((state) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, secs: state.crossfadeSecs, }).catch(() => {}); invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); + const normChanged = + state.normalizationEngine !== prevNormEngine + || state.loudnessTargetLufs !== prevNormTarget; + if (!normChanged) return; + prevNormEngine = state.normalizationEngine; + prevNormTarget = state.loudnessTargetLufs; + 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, + }).catch(() => {}); + if (state.normalizationEngine === 'loudness') { + const currentId = usePlayerStore.getState().currentTrack?.id; + 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 ─────────────────────────────────────── @@ -1012,6 +1398,7 @@ export function initAudioListeners(): () => void { return () => { unsubAuth(); + unsubAnalysisSync(); unsubMpris(); unsubDiscordPlayer(); unsubDiscordAuth(); @@ -1026,6 +1413,19 @@ export const usePlayerStore = create()( persist( (set, get) => ({ currentTrack: null, + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', + normalizationDbgSource: null, + normalizationDbgTrackId: null, + normalizationDbgCacheGainDb: null, + normalizationDbgCacheTargetLufs: null, + normalizationDbgCacheUpdatedAt: null, + normalizationDbgLastEventAt: null, currentRadio: null, currentPlaybackSource: null, enginePreloadedTrackId: null, @@ -1152,6 +1552,13 @@ export const usePlayerStore = create()( buffered: 0, currentTime: 0, currentRadio: null, + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', currentPlaybackSource: null, enginePreloadedTrackId: null, scheduledPauseAtMs: null, @@ -1192,6 +1599,13 @@ export const usePlayerStore = create()( set({ currentRadio: station, currentTrack: null, + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', currentPlaybackSource: null, queue: [], queueIndex: 0, @@ -1271,6 +1685,11 @@ export const usePlayerStore = create()( set({ currentTrack: track, currentRadio: null, + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), queue: newQueue, queueIndex: idx >= 0 ? idx : 0, progress: initialProgress, @@ -1295,20 +1714,23 @@ export const usePlayerStore = create()( authState.hotCacheDownloadDir || null, ); } + void refreshWaveformForTrack(track.id); + void refreshLoudnessForTrack(track.id); setDeferHotCachePrefetch(true); const playIdx = idx >= 0 ? idx : 0; const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; const replayGainDb = resolveReplayGainDb( 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', { url, volume: state.volume, durationHint: track.duration, replayGainDb, replayGainPeak, + loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null, preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, manual, @@ -1432,9 +1854,9 @@ export const usePlayerStore = create()( const authStateCold = useAuthStore.getState(); const replayGainDbCold = resolveReplayGainDb( 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 ?? ''; setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId); @@ -1445,6 +1867,7 @@ export const usePlayerStore = create()( durationHint: trackToPlay.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, + loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null, preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, @@ -1466,9 +1889,9 @@ export const usePlayerStore = create()( const authStateCold = useAuthStore.getState(); const replayGainDbCold = resolveReplayGainDb( 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 ?? ''; setDeferHotCachePrefetch(true); const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId); @@ -1479,6 +1902,7 @@ export const usePlayerStore = create()( durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, + loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null, preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, @@ -1895,16 +2319,26 @@ export const usePlayerStore = create()( const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; const replayGainDb = resolveReplayGainDb( currentTrack, prev, next, - authState.replayGainEnabled, authState.replayGainMode, + isReplayGainActive(), authState.replayGainMode, ); - const replayGainPeak = authState.replayGainEnabled + const replayGainPeak = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null; - invoke('audio_update_replay_gain', { + const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex); + set(prevState => ({ + normalizationNowDb: + normalization.normalizationEngineLive === 'loudness' + ? prevState.normalizationNowDb + : normalization.normalizationNowDb, + normalizationTargetLufs: normalization.normalizationTargetLufs, + normalizationEngineLive: normalization.normalizationEngineLive, + })); + invoke('audio_update_replay_gain', { volume, replayGainDb, replayGainPeak, + loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null, preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, }).catch(console.error); From e009fd18234176aff37bddd49b61f33dbda04695 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 25 Apr 2026 22:36:55 +0300 Subject: [PATCH 2/6] fix(settings): make normalization modes exclusive and refine LUFS UI Replace the mixed ReplayGain/Loudness controls with a single exclusive mode selector, hide RG-specific controls when LUFS is active, and align the queue tech badge behavior between RG and LUFS. Also update LUFS targets to -10..-16 ordering and default loudness target to -12. --- src/components/QueuePanel.tsx | 33 +++++++---- src/pages/Settings.tsx | 102 ++++++++++++++++++---------------- src/store/authStore.ts | 6 +- 3 files changed, 81 insertions(+), 60 deletions(-) diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 20268c19..bb1edfaf 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -483,16 +483,16 @@ export default function QueuePanel() { const rgParts = formatQueueReplayGainParts(currentTrack, t); const baseLine = baseParts.join(' · '); const rgLine = rgParts.join(' · '); + const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness'; const liveGainLabel = normalizationNowDb != null ? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB` : '—'; - const targetLabel = normalizationEngineLive === 'loudness' - ? (normalizationTargetLufs != null ? `${normalizationTargetLufs} LUFS` : '-16 LUFS') - : normalizationEngineLive === 'replaygain' - ? `ReplayGain (${replayGainMode})` - : t('queue.normOff', { defaultValue: 'Off' }); + const targetLabel = normalizationTargetLufs != null + ? `${normalizationTargetLufs} LUFS` + : '-16 LUFS'; if (!baseLine && !rgLine && !playbackSource) return null; - const showRgLine = expandReplayGain && !!rgLine; + const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine; + const showLufsLine = isLoudnessActive && expandReplayGain; return (
@@ -515,7 +515,7 @@ export default function QueuePanel() { )} {baseLine && {baseLine}} - {rgLine && ( + {!isLoudnessActive && rgLine && ( )} + {isLoudnessActive && ( + + )}
{showRgLine && ( @@ -535,12 +548,12 @@ export default function QueuePanel() { {' · '}{rgLine} )} - {(normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness') && ( + {showLufsLine && ( - {t('queue.normNow', { defaultValue: 'Now' })} + Loudness {' · '}{liveGainLabel} {' · '} - {t('queue.normTarget', { defaultValue: 'Target' })} + TGT {' · '}{targetLabel} )} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c03a2a0c..b93227a9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2217,59 +2217,67 @@ export default function Settings() { icon={} >
- {/* Replay Gain */} + {/* Normalization */}
-
{t('settings.replayGain')}
-
{t('settings.replayGainDesc')}
-
- -
- {auth.replayGainEnabled && ( -
-
- Engine: - - +
{t('settings.normalization', { defaultValue: 'Normalization' })}
+
+ {t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })}
+
+
+ + + +
+
+ {auth.normalizationEngine !== 'off' && ( +
{auth.normalizationEngine === 'loudness' && ( <> -
- Apple-like automatic normalization. ReplayGain tags are ignored in this mode. -
Target LUFS: +
@@ -2321,7 +2329,7 @@ export default function Settings() { )}
)} - {auth.replayGainEnabled && auth.normalizationEngine === 'replaygain' && ( + {auth.normalizationEngine === 'replaygain' && (
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index d7afd2c2..b4e58c41 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -51,7 +51,7 @@ interface AuthState { customGenreBlacklist: string[]; replayGainEnabled: boolean; normalizationEngine: NormalizationEngine; - loudnessTargetLufs: -16 | -14 | -12; + loudnessTargetLufs: -16 | -14 | -12 | -10; replayGainMode: 'track' | 'album' | 'auto'; replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) @@ -209,7 +209,7 @@ interface AuthState { setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; setNormalizationEngine: (v: NormalizationEngine) => void; - setLoudnessTargetLufs: (v: -16 | -14 | -12) => void; + setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; @@ -321,7 +321,7 @@ export const useAuthStore = create()( customGenreBlacklist: [], replayGainEnabled: false, normalizationEngine: 'off', - loudnessTargetLufs: -14, + loudnessTargetLufs: -12, replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, From 53cab7654ce39808a456a053b8820c2c9ee5f295 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 25 Apr 2026 23:38:27 +0300 Subject: [PATCH 3/6] feat(player,queue): loudness strip controls and normalization readout fixes - Add Tauri command to delete loudness_cache rows for a track and helpers in AnalysisCache. - Queue tech strip: click dB to reseed loudness; LUFS target picker via body portal; metric styling aligned with strip (no link chrome). - Player store: reseed clears local cache and replays analysis seed; show loudness dB from SQLite cache when live state is still null; allow first numeric normalization-state update through the short duplicate filter. - audio_update_replay_gain: resolve loudness from the requested gain when playback URL is not pinned yet. --- src-tauri/src/analysis_cache.rs | 19 ++++ src-tauri/src/audio.rs | 6 +- src-tauri/src/lib.rs | 9 ++ src/components/QueuePanel.tsx | 152 ++++++++++++++++++++++++++++++-- src/store/playerStore.ts | 79 ++++++++++++++++- src/styles/layout.css | 18 ++++ 6 files changed, 273 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index 257c9683..ac2ab4fa 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -83,6 +83,25 @@ impl AnalysisCache { 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 { + 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) + } + 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())?; diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index afc56b1b..dacf8c47 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -4053,9 +4053,13 @@ pub fn audio_update_replay_gain( } else { None }; + // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` + // so `loudness_ui_current_gain_db` can show a number (otherwise `and_then` + // drops the requested gain entirely). let resolved_loudness_gain_db = url_for_loudness .as_deref() - .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)); + .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)) + .or(loudness_gain_db); let effective_loudness_db = if norm_mode == 2 { match url_for_loudness.as_deref() { Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2e43ff2..f99eda29 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1250,6 +1250,14 @@ fn analysis_get_loudness_for_track( }})) } +#[tauri::command] +fn analysis_delete_loudness_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_loudness_for_track_id(&track_id) +} + #[tauri::command] fn analysis_enqueue_seed_from_url( track_id: String, @@ -4099,6 +4107,7 @@ pub fn run() { analysis_get_waveform, analysis_get_waveform_for_track, analysis_get_loudness_for_track, + analysis_delete_loudness_for_track, analysis_enqueue_seed_from_url, download_track_offline, delete_offline_track, diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 1e4dfa68..fa20f0b0 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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 { useOrbitStore } from '../store/orbitStore'; import OrbitGuestQueue from './OrbitGuestQueue'; @@ -319,10 +320,17 @@ function QueuePanelHostOrSolo() { const [showRemainingTime, setShowRemainingTime] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); + const [lufsTgtOpen, setLufsTgtOpen] = useState(false); + const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState({}); + const lufsTgtBtnRef = useRef(null); + const lufsTgtMenuRef = useRef(null); const expandReplayGain = useThemeStore(s => s.expandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); const crossfadeBtnRef = useRef(null); const crossfadePopoverRef = useRef(null); + const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack); + const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs); + const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs); useEffect(() => { if (!showCrossfadePopover) return; @@ -337,6 +345,64 @@ function QueuePanelHostOrSolo() { return () => document.removeEventListener('mousedown', handle); }, [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 const psyDragFromIdxRef = useRef(null); @@ -533,9 +599,8 @@ function QueuePanelHostOrSolo() { const liveGainLabel = normalizationNowDb != null ? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB` : '—'; - const targetLabel = normalizationTargetLufs != null - ? `${normalizationTargetLufs} LUFS` - : '-16 LUFS'; + const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs; + const targetLabel = `${tgtNum} LUFS`; if (!baseLine && !rgLine && !playbackSource) return null; const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine; const showLufsLine = isLoudnessActive && expandReplayGain; @@ -597,10 +662,85 @@ function QueuePanelHostOrSolo() { {showLufsLine && ( Loudness - {' · '}{liveGainLabel} + {' · '} + {' · '} TGT - {' · '}{targetLabel} + {' · '} + + {lufsTgtOpen && + createPortal( +
e.stopPropagation()} + > + {([-10, -12, -14, -16] as const).map((v) => ( + + ))} +
, + document.body, + )}
)}
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 905bf9cc..68ddf437 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -209,8 +209,9 @@ interface PlayerState { next: (manual?: boolean) => void; previous: () => void; seek: (progress: number) => void; - setVolume: (v: number) => void; + setVolume: (v: number) => void; updateReplayGainForCurrentTrack: () => void; + reanalyzeLoudnessForTrack: (trackId: string) => Promise; setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; @@ -593,6 +594,59 @@ function isReplayGainActive() { 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]; + } +} + +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 { @@ -1128,7 +1182,15 @@ export function initAudioListeners(): () => void { return; } const nowMs = Date.now(); - if (nowMs - lastNormalizationUiUpdateAtMs < 120 && engine === prev.normalizationEngineLive) { + const isFirstNumericGain = + engine === 'loudness' + && nowDb != null + && prev.normalizationNowDb == null; + if ( + !isFirstNumericGain + && nowMs - lastNormalizationUiUpdateAtMs < 120 + && engine === prev.normalizationEngineLive + ) { return; } lastNormalizationUiUpdateAtMs = nowMs; @@ -2418,6 +2480,15 @@ export const usePlayerStore = create()( } }, + reanalyzeLoudnessForTrack: async (trackId: string) => { + try { + showToast('Recalculating loudness for this track…', 2000, 'info'); + } catch { + // no-op + } + await reseedLoudnessForTrackId(trackId); + }, + updateReplayGainForCurrentTrack: () => { const { currentTrack, queue, queueIndex, volume } = get(); if (!currentTrack || !currentTrack.id) return; @@ -2433,10 +2504,12 @@ export const usePlayerStore = create()( : null; 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 + ? (prevState.normalizationNowDb ?? cachedLoudDb) : normalization.normalizationNowDb, normalizationTargetLufs: normalization.normalizationTargetLufs, normalizationEngineLive: normalization.normalizationEngineLive, diff --git a/src/styles/layout.css b/src/styles/layout.css index d1e4c62b..32d3528f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1989,6 +1989,24 @@ html[data-platform="windows"] .player-bar.floating { 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 { padding: var(--space-3) var(--space-4) 0; flex-shrink: 0; From c6fc3ec844c33d861ec3a9681024cd52d06662d4 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sun, 26 Apr 2026 00:49:10 +0300 Subject: [PATCH 4/6] fix(waveform): stabilize progressive rendering and reduce streaming contention Improve seekbar fallback behavior by using consistent bar-based rendering and smoother transitions to analyzed waveform data. Reduce hot-path analysis overhead during ranged streaming and add controls to clear cached waveform entries. --- src-tauri/src/analysis_cache.rs | 327 +++++++++++++++++++++++++------- src-tauri/src/audio.rs | 59 ++++-- src-tauri/src/lib.rs | 8 + src/components/WaveformSeek.tsx | 163 +++++++++++----- src/locales/en.ts | 3 + src/locales/ru.ts | 3 + src/pages/Settings.tsx | 34 ++++ src/store/playerStore.ts | 57 ++++-- 8 files changed, 504 insertions(+), 150 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index ac2ab4fa..f3a57486 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -14,7 +14,7 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use tauri::Manager; -pub const WAVEFORM_ALGO_VERSION: i64 = 1; +pub const WAVEFORM_ALGO_VERSION: i64 = 3; pub const LOUDNESS_ALGO_VERSION: i64 = 1; #[derive(Debug, Clone)] @@ -102,6 +102,18 @@ impl AnalysisCache { Ok(total) } + /// Remove all cached waveform rows across all tracks/variants. + pub fn delete_all_waveforms(&self) -> Result { + 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())?; @@ -313,8 +325,20 @@ pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> }; cache.touch_track_status(&key, "queued")?; + let wf_bins: Vec; + let loudness_opt: Option<(f64, f64, f64, f64)>; + match analyze_loudness_and_waveform(bytes, -16.0, 500) { + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { + wf_bins = bins; + loudness_opt = Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)); + } + None => { + wf_bins = derive_waveform_bins(bytes, 500); + loudness_opt = None; + } + } let waveform = WaveformEntry { - bins: derive_waveform_bins(bytes, 500), + bins: wf_bins, bin_count: 500, is_partial: false, known_until_sec: 0.0, @@ -323,9 +347,7 @@ pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> }; cache.upsert_waveform(&key, &waveform)?; - if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = - analyze_loudness_from_audio_bytes(bytes, -16.0) - { + if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt { let loudness = LoudnessEntry { integrated_lufs, true_peak, @@ -372,11 +394,48 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { out } -fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(f64, f64, f64, f64)> { - if bytes.is_empty() { +struct PcmScanResult { + bins: Vec, + loudness: Option<(f64, f64, f64, f64)>, +} + +/// Decoded PCM peak envelope (mono mix) → `bin_count` bars, for seekbar display. +/// Two decode passes: count frames, then fill bins. Returns `None` if probing/decoding fails. +pub fn derive_waveform_bins_from_audio_bytes(bytes: &[u8], bin_count: usize) -> Option> { + 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, None)?; + Some(scanned.bins) +} +/// 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)> { + 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)> { let source = Box::new(Cursor::new(bytes.to_vec())); let mss = MediaSourceStream::new(source, Default::default()); let hint = Hint::new(); @@ -388,33 +447,128 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( .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() - }) + 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::::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 { + let bin_count = bin_max.len(); + if bin_count == 0 { + return Vec::new(); + } + let mut sorted: Vec = 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, + loudness_target_lufs: Option, +) -> Option { + 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()) - { + 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 = 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() { @@ -424,7 +578,6 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( if packet.track_id() != track_id { continue; } - let decoded = match decoder.decode(&packet) { Ok(buf) => buf, Err(SymphoniaError::DecodeError(_)) => continue, @@ -433,7 +586,12 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( }; let spec = *decoded.spec(); - if ebu.is_none() { + 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) { @@ -452,70 +610,101 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( } } } + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); samples.copy_interleaved_ref(decoded); - for &s in samples.samples() { - let v = (s as f64).abs(); - if v.is_finite() && v > sample_peak_abs { - sample_peak_abs = v; - } - } - let Some(ref mut ebu) = ebu else { - crate::app_deprintln!("[analysis] loudness failed: ebu not initialized"); - return None; - }; - match ebu.add_frames_f32(samples.samples()) { - Ok(_) => fed_any_frames = true, - Err(e) => { - crate::app_deprintln!("[analysis] loudness add_frames failed: {}", e); - return None; + 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(); } } - 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: no decoder output"); - return None; - }; - let integrated_lufs = match ebu.loudness_global() { - Ok(v) => v, - Err(e) => { - crate::app_deprintln!("[analysis] loudness_global failed: {}", e); + 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; } - }; - 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(_) => {} + 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) => { - true_peak_ok = false; - crate::app_deprintln!("[analysis] true_peak unavailable: {}", e); - break; + 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 { - // Fallback to sample peak if true-peak is not available for this stream/codec. - true_peak = sample_peak_abs; - } + 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 + }; - let recommended_gain_db = recommended_gain_for_target(integrated_lufs, true_peak, target_lufs); - Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) + Some(PcmScanResult { bins, loudness }) } fn analysis_db_path(app: &tauri::AppHandle) -> Result { diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index dacf8c47..3f85cbd7 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1215,6 +1215,7 @@ async fn ranged_download_task( let dl_started = Instant::now(); let mut next_progress_mb: usize = 1; let mut last_partial_emit = Instant::now(); + let mut last_partial_emit_downloaded: usize = 0; let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { @@ -1292,10 +1293,14 @@ async fn ranged_download_task( downloaded_to.store(downloaded, Ordering::SeqCst); if downloaded >= 4096 && total_size > 0 + && downloaded.saturating_sub(last_partial_emit_downloaded) >= PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA && last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded) { - let bins = - derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); + // Keep streaming path lightweight: avoid expensive PCM decode + // in the hot loop (it can steal CPU and cause audible underruns + // while playback is starting). We emit a fast sampled waveform + // here and let full analysis/backfill produce final bins later. + let bins = derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) { @@ -1334,6 +1339,7 @@ async fn ranged_download_task( }, ); last_partial_emit = Instant::now(); + last_partial_emit_downloaded = downloaded; } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { @@ -1386,11 +1392,11 @@ async fn ranged_download_task( fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration { const MB: usize = 1024 * 1024; if downloaded <= 3 * MB { - Duration::from_millis(280) + Duration::from_millis(260) } else if downloaded <= 10 * MB { - Duration::from_millis(650) + Duration::from_millis(620) } else { - Duration::from_millis(1100) + Duration::from_millis(980) } } @@ -2785,7 +2791,8 @@ async fn fetch_data( /// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping. const MASTER_HEADROOM: f32 = 0.891_254; const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024; -const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 350; +const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900; +const PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA: usize = 192 * 1024; /// Until integrated LUFS is known, stay clearly below "full" level so a follow-up /// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor. const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0; @@ -3688,6 +3695,22 @@ fn spawn_progress_task( gapless_switch_at: Arc, current_playback_url: Arc>>, ) { + fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 { + #[cfg(target_os = "linux")] + { + // Keep progress aligned with audible output (ALSA/PipeWire/Pulse + // queue). We mirror the quantum policy used for stream open/reopen. + let rate = sample_rate_hz.max(1.0); + let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 }; + // Add a small scheduler/mixer cushion so UI doesn't run ahead. + return (frames / rate) + 0.012; + } + #[cfg(not(target_os = "linux"))] + { + 0.0 + } + } + tokio::spawn(async move { let mut near_end_ticks: u32 = 0; // Local done-flag reference; swapped on gapless transition. @@ -3775,21 +3798,25 @@ fn spawn_progress_task( let samples = samples_played.load(Ordering::Relaxed) as f64; let divisor = (rate * ch).max(1.0); - let dur = { + // Read playback snapshot under a single lock to minimize contention + // with seek/play/pause commands that also touch `current`. + let (dur, paused_at) = { let cur = current_arc.lock().unwrap(); - cur.duration_secs - }; - let is_paused = { - let cur = current_arc.lock().unwrap(); - cur.paused_at.is_some() + (cur.duration_secs, cur.paused_at) }; + let is_paused = paused_at.is_some(); - let pos = if is_paused { - let cur = current_arc.lock().unwrap(); - cur.paused_at.unwrap_or(0.0) + let pos_raw = if let Some(p) = paused_at { + p } else { (samples / divisor).min(dur.max(0.001)) }; + let progress_latency = if is_paused { + 0.0 + } else { + estimated_output_latency_secs(rate) + }; + let pos = (pos_raw - progress_latency).max(0.0); app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); @@ -3801,7 +3828,7 @@ fn spawn_progress_task( let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64; let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 }; - if dur > end_threshold && pos >= dur - end_threshold { + if dur > end_threshold && pos_raw >= dur - end_threshold { near_end_ticks += 1; // At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms. if near_end_ticks >= 10 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f99eda29..4fdef949 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1258,6 +1258,13 @@ fn analysis_delete_loudness_for_track( cache.delete_loudness_for_track_id(&track_id) } +#[tauri::command] +fn analysis_delete_all_waveforms( + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_all_waveforms() +} + #[tauri::command] fn analysis_enqueue_seed_from_url( track_id: String, @@ -4108,6 +4115,7 @@ pub fn run() { 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, delete_offline_track, diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 7781c6d1..070a61a3 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -8,6 +8,8 @@ function fmt(s: number): string { const BAR_COUNT = 500; const SEG_COUNT = 60; +const FLAT_WAVE_NORM = 0.06; +const WAVE_MORPH_MS = 1000; // ── animation state ─────────────────────────────────────────────────────────── @@ -96,6 +98,22 @@ export function makeHeights(trackId: string): Float32Array { // ── 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 waveformBarThickness(logicalH: number, norm: number): number { + const safeNorm = Math.max(FLAT_WAVE_NORM, norm); + return Math.max(1, safeNorm * logicalH); +} + function drawWaveform( canvas: HTMLCanvasElement, heights: Float32Array | null, @@ -108,9 +126,38 @@ function drawWaveform( const { played, buffered: buffCol, unplayed } = getColors(); 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.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; return; } @@ -122,7 +169,7 @@ function drawWaveform( ctx.fillStyle = unplayed; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT < buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -132,7 +179,7 @@ function drawWaveform( for (let i = 0; i < BAR_COUNT; i++) { const frac = i / BAR_COUNT; if (frac < progress || frac >= buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -140,29 +187,14 @@ function drawWaveform( if (progress > 0) { ctx.globalAlpha = 1; ctx.fillStyle = played; - ctx.shadowColor = played; - ctx.shadowBlur = 5; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT >= progress) break; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } - ctx.shadowBlur = 0; } 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) { @@ -824,7 +856,6 @@ export default function WaveformSeek({ trackId }: Props) { const SEEK_COMMIT_PROGRESS_EPS = 0.02; const canvasRef = useRef(null); const heightsRef = useRef(null); - const lastHeightsTrackIdRef = useRef(undefined); const progressRef = useRef(usePlayerStore.getState().progress); const bufferedRef = useRef(usePlayerStore.getState().buffered); const isDragging = useRef(false); @@ -848,43 +879,73 @@ export default function WaveformSeek({ trackId }: Props) { useEffect(() => { if (!trackId) { heightsRef.current = null; - lastHeightsTrackIdRef.current = undefined; return; } if (waveformBins && waveformBins.length > 0) { const src = waveformBins; const h = new Float32Array(BAR_COUNT); - const effectiveDur = waveformDurationSec > 0 ? waveformDurationSec : duration; - const knownFrac = waveformIsPartial && effectiveDur > 0 - ? Math.max(0, Math.min(1, waveformKnownUntilSec / effectiveDur)) - : 1; - const knownBars = Math.max(0, Math.min(BAR_COUNT, Math.floor(knownFrac * BAR_COUNT))); for (let i = 0; i < BAR_COUNT; i++) { - if (i >= knownBars) { - h[i] = 0.08; // unknown tail baseline while partial waveform is still loading - continue; - } 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))); } - // Partial -> full handoff: blend with previous heights for a smoother - // visual transition and avoid a hard "jump" in the waveform shape. - if (lastHeightsTrackIdRef.current === trackId && heightsRef.current && heightsRef.current.length === BAR_COUNT) { - const prev = heightsRef.current; - const mixed = new Float32Array(BAR_COUNT); - for (let i = 0; i < BAR_COUNT; i++) { - mixed[i] = prev[i] * 0.45 + h[i] * 0.55; - } - heightsRef.current = mixed; - } else { + const prev = heightsRef.current; + if (!prev || prev.length !== BAR_COUNT) { heightsRef.current = h; + return; } - lastHeightsTrackIdRef.current = trackId; - 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); } - heightsRef.current = makeHeights(trackId); - lastHeightsTrackIdRef.current = trackId; + 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, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]); // Imperative subscription — no React re-renders from progress changes. @@ -913,12 +974,20 @@ 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(() => { if (ANIMATED_STYLES.has(seekbarStyle)) return; const canvas = canvasRef.current; if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); - }, [seekbarStyle, trackId]); + }, [ + seekbarStyle, + trackId, + waveformBins, + waveformIsPartial, + waveformKnownUntilSec, + waveformDurationSec, + duration, + ]); // rAF loop — animated styles only. useEffect(() => { diff --git a/src/locales/en.ts b/src/locales/en.ts index bf61a1f1..a95ceba7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -656,9 +656,12 @@ export const enTranslation = { hotCacheTrackCount: 'Tracks in cache:', cacheMaxLabel: 'Max. size', cacheClearBtn: 'Clear Cache', + waveformCacheClearBtn: 'Clear waveform cache', cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearConfirm: 'Clear Everything', cacheClearCancel: 'Cancel', + waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).', + waveformCacheClearFailed: 'Failed to clear waveform cache.', offlineDirTitle: 'Offline Library (In-App)', offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.', offlineDirDefault: 'Default (App Data)', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 22a1e60a..555b193f 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -677,9 +677,12 @@ export const ruTranslation = { hotCacheTrackCount: 'Треков в кэше:', cacheMaxLabel: 'Лимит', cacheClearBtn: 'Очистить кэш', + waveformCacheClearBtn: 'Очистить кэш waveform', cacheClearWarning: 'Будут удалены и все офлайн-альбомы.', cacheClearConfirm: 'Очистить всё', cacheClearCancel: 'Отмена', + waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).', + waveformCacheClearFailed: 'Не удалось очистить кэш waveform.', offlineDirTitle: 'Офлайн-библиотека (в приложении)', offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.', offlineDirDefault: 'По умолчанию (данные приложения)', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a9d38142..28f9cd98 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; import { useHotCacheStore } from '../store/hotCacheStore'; +import { usePlayerStore } from '../store/playerStore'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; @@ -1788,6 +1789,29 @@ export default function Settings() { setClearing(false); }, [clearAllOffline, serverId]); + const handleClearWaveformCache = useCallback(async () => { + setClearing(true); + try { + const deleted = await invoke('analysis_delete_all_waveforms'); + usePlayerStore.setState({ + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + }); + 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 () => { setLfmError(null); let token: string; @@ -3032,6 +3056,16 @@ export default function Settings() { {t('settings.cacheClearBtn')} )} +
+ +
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 68ddf437..e2210483 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -824,19 +824,11 @@ function handleAudioProgress(current_time: number, duration: number) { const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; - const stateNow = usePlayerStore.getState(); - if (stateNow.waveformIsPartial) { - usePlayerStore.setState({ - currentTime: displayTime, - progress, - buffered: 0, - // Keep known span aligned with playback position during partial stage: - // if playback moved forward, this part is definitely known already. - waveformKnownUntilSec: Math.max(stateNow.waveformKnownUntilSec || 0, displayTime), - }); - } else { - usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); - } + // Do not couple `waveformKnownUntilSec` to playhead: for partial streams it + // must track **download/analysis progress** (emitted in `analysis:waveform-partial`). + // Using `displayTime` here broke the seekbar after seeks — bins only cover the + // buffered prefix of the file while `knownUntil` jumped with the seek position. + usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) if (progress >= 0.5 && !store.scrobbled) { @@ -1123,11 +1115,37 @@ export function initAudioListeners(): () => void { usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 }); return; } - usePlayerStore.setState({ - waveformBins: payload.bins, - waveformIsPartial: true, - waveformKnownUntilSec: Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0, - waveformDurationSec: Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0), + const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0; + const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0); + usePlayerStore.setState((prev) => { + const prevBins = prev.waveformBins; + const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0; + if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) { + return { + waveformBins: payload.bins, + waveformIsPartial: true, + waveformKnownUntilSec: nextKnownUntilSec, + waveformDurationSec: nextDurationSec, + }; + } + const len = Math.max(prevBins.length, payload.bins.length); + const merged = new Array(len); + for (let i = 0; i < len; i++) { + merged[i] = i < prevBins.length ? prevBins[i] : 0; + } + const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len))); + const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len))); + if (nextKnownBars > prevKnownBars) { + for (let i = prevKnownBars; i < nextKnownBars; i++) { + if (i < payload.bins.length) merged[i] = payload.bins[i]; + } + } + return { + waveformBins: merged, + waveformIsPartial: true, + waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec), + waveformDurationSec: nextDurationSec, + }; }); }), listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { @@ -1151,6 +1169,9 @@ export function initAudioListeners(): () => void { if (!payloadTrackId) return; const currentId = usePlayerStore.getState().currentTrack?.id; if (currentId && payloadTrackId === currentId) { + if (!payload.isPartial) { + usePlayerStore.setState({ waveformIsPartial: false }); + } void refreshWaveformForTrack(currentId); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId }); From 4b60495e38245c6e8eb4cc9c43685cec871884ef Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sun, 26 Apr 2026 02:29:54 +0300 Subject: [PATCH 5/6] feat(playback): loudness bind rules, analysis seeding, and normalization UI Resolve integrated loudness from SQLite only at decode bind; keep pre-trim until a row exists, then allow provisional gain from live updates. Pass DB-stable hints from the web app into play and gapless preload. Default pre-measurement attenuation -4.5 dB with an icon reset in Settings; drop redundant normalization copy and shorten pre-trim descriptions. analysis_cache seed_from_bytes returns an outcome and skips redundant waveform work on cache hits; wire callers and related frontend/backend glue. --- src-tauri/src/analysis_cache.rs | 151 +++++++--- src-tauri/src/audio.rs | 507 ++++++++++++++++++-------------- src-tauri/src/lib.rs | 66 ++++- src/components/WaveformSeek.tsx | 38 ++- src/locales/de.ts | 6 + src/locales/en.ts | 6 + src/locales/es.ts | 6 + src/locales/fr.ts | 6 + src/locales/nb.ts | 6 + src/locales/nl.ts | 6 + src/locales/ru.ts | 6 + src/locales/zh.ts | 6 + src/main.tsx | 16 + src/pages/Settings.tsx | 123 +++++--- src/store/authStore.ts | 43 ++- src/store/playerStore.ts | 182 ++++++------ 16 files changed, 749 insertions(+), 425 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index f3a57486..429d796a 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::io::Cursor; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use ebur128::{EbuR128, Mode as Ebur128Mode}; use rusqlite::{params, Connection, OptionalExtension}; @@ -315,51 +315,122 @@ pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_ recommended_gain_db.clamp(-24.0, 24.0) } -pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<(), String> { +/// 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 { + let started = Instant::now(); let Some(cache) = app.try_state::() else { - return Ok(()); + 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), }; - cache.touch_track_status(&key, "queued")?; - - let wf_bins: Vec; - let loudness_opt: Option<(f64, f64, f64, f64)>; - match analyze_loudness_and_waveform(bytes, -16.0, 500) { - Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { - wf_bins = bins; - loudness_opt = Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)); - } - None => { - wf_bins = derive_waveform_bins(bytes, 500); - loudness_opt = None; + 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); } } - 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)?; + crate::app_deprintln!( + "[analysis][waveform] build start track_id={} bytes={} md5_16kb={}", + track_id, + bytes.len(), + key.md5_16kb + ); - 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, + 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_loudness(&key, &loudness)?; + 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 + ); + } } - cache.touch_track_status(&key, "ready")?; - Ok(()) + match build { + Ok(_) => Ok(SeedFromBytesOutcome::Upserted), + Err(e) => Err(e), + } } fn now_unix_ts() -> i64 { @@ -399,20 +470,6 @@ struct PcmScanResult { loudness: Option<(f64, f64, f64, f64)>, } -/// Decoded PCM peak envelope (mono mix) → `bin_count` bars, for seekbar display. -/// Two decode passes: count frames, then fill bins. Returns `None` if probing/decoding fails. -pub fn derive_waveform_bins_from_audio_bytes(bytes: &[u8], bin_count: usize) -> Option> { - 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, None)?; - Some(scanned.bins) -} - /// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count. fn analyze_loudness_and_waveform( bytes: &[u8], diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 3f85cbd7..cc1793e4 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -22,16 +22,6 @@ use symphonia::core::{ }; use futures_util::StreamExt; use tauri::{AppHandle, Emitter, Manager, State}; -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct PartialWaveformPayload { - track_id: Option, - bins: Vec, - known_until_sec: f64, - duration_sec: f64, - is_partial: bool, -} - #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct PartialLoudnessPayload { @@ -1062,6 +1052,8 @@ async fn track_download_task( done: Arc, promote_cache_slot: Arc>>, normalization_target_lufs: Arc, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { let mut downloaded: u64 = 0; let mut reconnects: u32 = 0; @@ -1161,7 +1153,11 @@ async fn track_download_task( && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs); + let pre_db = f32::from_bits( + loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0); + emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db); last_partial_loudness_emit = Instant::now(); } offset += pushed; @@ -1170,14 +1166,19 @@ async fn track_download_task( } } if !capture_over_limit && !capture.is_empty() { - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { - crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + if let Some(track_id) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { + Err(e) => crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { @@ -1199,7 +1200,7 @@ async fn ranged_download_task( gen_arc: Arc, http_client: reqwest::Client, app: AppHandle, - duration_hint: f64, + _duration_hint: f64, url: String, initial_response: reqwest::Response, buf: Arc>>, @@ -1207,6 +1208,8 @@ async fn ranged_download_task( done: Arc, promote_cache_slot: Arc>>, normalization_target_lufs: Arc, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { let total_size = buf.lock().unwrap().len(); let mut downloaded: usize = 0; @@ -1214,8 +1217,6 @@ async fn ranged_download_task( let mut next_response: Option = Some(initial_response); let dl_started = Instant::now(); let mut next_progress_mb: usize = 1; - let mut last_partial_emit = Instant::now(); - let mut last_partial_emit_downloaded: usize = 0; let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { @@ -1291,55 +1292,27 @@ async fn ranged_download_task( } downloaded += n; downloaded_to.store(downloaded, Ordering::SeqCst); - if downloaded >= 4096 + if downloaded >= PARTIAL_LOUDNESS_MIN_BYTES && total_size > 0 - && downloaded.saturating_sub(last_partial_emit_downloaded) >= PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA - && last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded) + && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - // Keep streaming path lightweight: avoid expensive PCM decode - // in the hot loop (it can steal CPU and cause audible underruns - // while playback is starting). We emit a fast sampled waveform - // here and let full analysis/backfill produce final bins later. - let bins = derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); - if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) { - let _ = app.emit( - "analysis:loudness-partial", - PartialLoudnessPayload { - track_id: playback_identity(&url), - gain_db: provisional_db, - target_lufs, - is_partial: true, - }, - ); - crate::app_deprintln!( - "[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}", - (downloaded as f32 / total_size as f32) * 100.0, - provisional_db, + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed)) + .clamp(-24.0, 0.0); + if let Some(provisional_db) = + provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db) + { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, target_lufs, - playback_identity(&url) - ); - } - last_partial_loudness_emit = Instant::now(); - }; - let known_until_sec = if duration_hint > 0.0 { - (duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint) - } else { - 0.0 - }; - let _ = app.emit( - "analysis:waveform-partial", - PartialWaveformPayload { - track_id: playback_identity(&url), - bins, - known_until_sec, - duration_sec: duration_hint.max(0.0), - is_partial: true, - }, - ); - last_partial_emit = Instant::now(); - last_partial_emit_downloaded = downloaded; + is_partial: true, + }, + ); + } + last_partial_loudness_emit = Instant::now(); } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { @@ -1372,14 +1345,19 @@ async fn ranged_download_task( if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { let data = buf.lock().unwrap().clone(); - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { - crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + if let Some(track_id) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); @@ -1387,86 +1365,13 @@ async fn ranged_download_task( } } -/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more -/// to summarize, so we slow emits and keep UI responsive without CPU spikes. -fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration { - const MB: usize = 1024 * 1024; - if downloaded <= 3 * MB { - Duration::from_millis(260) - } else if downloaded <= 10 * MB { - Duration::from_millis(620) - } else { - Duration::from_millis(980) - } -} - -/// Max centered-byte samples examined per bin for partial waveforms (full track -/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap). -const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048; - -fn peak_centered_byte_sampled(region: &[u8]) -> u8 { - if region.is_empty() { - return 0; - } - let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP; - let mut peak: u8 = 0; - if region.len() <= cap { - for &b in region { - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - } - } else { - let step = (region.len() / cap).max(1); - let mut i = 0; - while i < region.len() { - let b = region[i]; - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - i = i.saturating_add(step); - } - let b = region[region.len() - 1]; - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - } - peak -} - -/// Partial waveform without cloning the whole download buffer and without -/// holding `buf` locked across all bins (that would stall the decoder's `read()`). -fn derive_partial_waveform_bins_short_locks( - buf: &Arc>>, - downloaded: usize, - bin_count: usize, -) -> Vec { - if downloaded == 0 || bin_count == 0 { - return Vec::new(); - } - let len = downloaded; - let mut out = vec![0u8; bin_count]; - for (i, slot) in out.iter_mut().enumerate() { - let start = i * len / bin_count; - let end = ((i + 1) * len / bin_count).max(start + 1).min(len); - let peak = { - let b = buf.lock().unwrap(); - if start >= b.len() { - 0u8 - } else { - let end = end.min(b.len()); - peak_centered_byte_sampled(&b[start..end]) - } - }; - *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; - } - out -} - -fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) { +fn emit_partial_loudness_from_bytes( + app: &AppHandle, + url: &str, + bytes: &[u8], + target_lufs: f32, + pre_analysis_attenuation_db: f32, +) { if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES { crate::app_deprintln!( "[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}", @@ -1477,7 +1382,17 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta } // Lightweight fallback based on buffered bytes count to keep CPU low. let mb = bytes.len() as f32 / (1024.0 * 1024.0); - let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5); + let pre_floor = pre_analysis_attenuation_db.clamp(-24.0, 0.0); + // Target-derived hint (e.g. -12 LUFS → -1 dB). Old `(hint).clamp(pre, 0)` left + // the hint when it lay inside [pre, 0] — e.g. -1 with pre=-6, so AAC/M4A + // streaming often sat at -1 dB until full analysis. Combine with user trim: + // stricter (more negative) pre wins; milder pre still caps vs the hint. + let heuristic_floor = (target_lufs + 11.0).clamp(-6.0, 0.0); + let floor_db = if pre_floor < heuristic_floor { + pre_floor + } else { + pre_floor.max(heuristic_floor) + }; let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0); crate::app_deprintln!( "[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}", @@ -1497,14 +1412,19 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta ); } -fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option { +fn provisional_loudness_gain_from_progress( + downloaded: usize, + total_size: usize, + target_lufs: f32, + start_db_in: f32, +) -> Option { if total_size == 0 || downloaded == 0 { return None; } let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0); // Move from startup attenuation toward a more realistic late-stream level. // This avoids staying near -2 dB and then jumping hard when final LUFS lands. - let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0); + let start_db = start_db_in.clamp(-24.0, 0.0).min(0.0); let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0); let shaped = progress.powf(0.75); Some(start_db + (end_db - start_db) * shaped) @@ -2246,6 +2166,8 @@ pub struct AudioEngine { pub normalization_engine: Arc, /// Target loudness in LUFS for loudness engine (future use). pub normalization_target_lufs: Arc, + /// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings). + pub loudness_pre_analysis_attenuation_db: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. pub(crate) chained_info: Arc>>, @@ -2266,6 +2188,9 @@ pub struct AudioEngine { /// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null` /// (otherwise `compute_gain` would treat that as unity gain and playback "jumps"). pub(crate) current_playback_url: Arc>>, + /// Subsonic song id last passed from JS with `audio_play` (trimmed). Used + /// for loudness/waveform cache when the URL is `psysonic-local://…`. + pub(crate) current_analysis_track_id: Arc>>, } pub struct AudioCurrent { @@ -2531,6 +2456,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { gapless_enabled: Arc::new(AtomicBool::new(false)), normalization_engine: Arc::new(AtomicU32::new(0)), normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())), + loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())), chained_info: Arc::new(Mutex::new(None)), samples_played: Arc::new(AtomicU64::new(0)), current_sample_rate: Arc::new(AtomicU32::new(0)), @@ -2538,6 +2464,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { gapless_switch_at: Arc::new(AtomicU64::new(0)), radio_state: Mutex::new(None), current_playback_url: Arc::new(Mutex::new(None)), + current_analysis_track_id: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -2592,6 +2519,18 @@ fn playback_identity(url: &str) -> Option { None } +/// Stable id for analysis cache rows and `analysis:waveform-updated`. +/// Prefer the Subsonic track id from the frontend: `psysonic-local://` URLs +/// only map to `local:path` in `playback_identity`, which does not match +/// `analysis_get_waveform_for_track(trackId)` or the UI's `currentTrack.id`. +fn analysis_cache_track_id(logical_track_id: Option<&str>, url: &str) -> Option { + let logical = logical_track_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + logical.or_else(|| playback_identity(url)) +} + fn same_playback_target(a_url: &str, b_url: &str) -> bool { match (playback_identity(a_url), playback_identity(b_url)) { (Some(a), Some(b)) => a == b, @@ -2603,30 +2542,23 @@ fn resolve_loudness_gain_from_cache( app: &AppHandle, url: &str, target_lufs: f32, - requested_loudness_gain_db: Option, + logical_track_id: Option<&str>, ) -> Option { - // Never trust `requested` alone: the frontend may pass the *next* track's gain - // while `current_playback_url` still lags one play behind. Always prefer a - // cache row for **this** URL's track_id; use `requested` only on cache miss - // (provisional / pre-seed from JS). - let Some(track_id) = playback_identity(url) else { - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}", - r - ); - } - return requested_loudness_gain_db; + // Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`) + // are applied in `audio_update_replay_gain` via `loudness_gain_db_or_startup(..., true, _)`. + let Some(track_id) = analysis_cache_track_id(logical_track_id, url) else { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-identity url_len={}", + url.len() + ); + return None; }; let Some(cache) = app.try_state::() else { - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}", - r, - track_id - ); - } - return requested_loudness_gain_db; + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}", + track_id + ); + return None; }; // Also touch waveform row here so playback path verifies current context is present. let _ = cache.get_latest_waveform_for_track(&track_id); @@ -2660,16 +2592,7 @@ fn resolve_loudness_gain_from_cache( "[normalization] resolve_loudness_gain source=cache-miss track_id={}", track_id ); - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}", - track_id, - r - ); - Some(r) - } else { - None - } + None } Err(e) => { crate::app_deprintln!( @@ -2682,16 +2605,43 @@ fn resolve_loudness_gain_from_cache( } } -/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until -/// analysis exists — must never return `None` here or `compute_gain` uses unity. +/// LUFS: DB-backed integrated LUFS only at bind time (`allow_js_when_uncached = false`); +/// after `analysis:loudness-partial`, `audio_update_replay_gain` passes `true` so finite +/// JS gain applies until SQLite catches up. Must never return `None` or `compute_gain` uses unity. fn loudness_gain_db_or_startup( app: &AppHandle, url: &str, target_lufs: f32, - requested: Option, + logical_track_id: Option<&str>, + pre_analysis_attenuation_db: f32, + allow_js_when_uncached: bool, + js_gain_db: Option, ) -> Option { - resolve_loudness_gain_from_cache(app, url, target_lufs, requested) - .or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)) + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + match resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id) { + Some(g) => Some(g), + None => { + if allow_js_when_uncached { + match js_gain_db { + Some(r) if r.is_finite() => Some(r), + _ => Some(pre), + } + } else { + Some(pre) + } + } + } +} + +#[inline] +fn loudness_pre_analysis_db_for_engine(state: &AudioEngine) -> f32 { + f32::from_bits( + state + .loudness_pre_analysis_attenuation_db + .load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0) + .min(0.0) } /// Take (consume) completed manual-stream bytes if they correspond to `url`. @@ -2792,10 +2742,6 @@ async fn fetch_data( const MASTER_HEADROOM: f32 = 0.891_254; const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024; const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900; -const PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA: usize = 192 * 1024; -/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up -/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor. -const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0; fn compute_gain( normalization_engine: u32, @@ -2891,6 +2837,9 @@ fn ramp_sink_volume(sink: Arc, from: f32, to: f32) { // ─── Commands ───────────────────────────────────────────────────────────────── +/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness +/// cache to the track when playing `psysonic-local://` (hot/offline). Optional +/// for HTTP streams (`playback_identity` is used as fallback). #[tauri::command] pub async fn audio_play( url: String, @@ -2903,6 +2852,7 @@ pub async fn audio_play( fallback_db: f32, manual: bool, // true = user-initiated skip → bypass crossfade, start immediately hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -2979,6 +2929,12 @@ pub async fn audio_play( // a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not // the previous URL still stored until the sink swap completes. *state.current_playback_url.lock().unwrap() = Some(url.clone()); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + *state.current_analysis_track_id.lock().unwrap() = logical_trim.clone(); + let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url); // Extract format hint from URL for better symphonia probing. Strip the // query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) @@ -3053,6 +3009,45 @@ pub async fn audio_play( len / 1024, local_hint ); + if let Some(ref seed_id) = cache_id_for_tasks { + let path_owned = std::path::PathBuf::from(path); + let app_seed = app.clone(); + let gen_seed = gen; + let gen_arc_seed = state.generation.clone(); + let seed_id = seed_id.clone(); + tokio::spawn(async move { + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + let data = match tokio::fs::read(&path_owned).await { + Ok(d) => d, + Err(_) => return, + }; + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { + return; + } + match crate::analysis_cache::seed_from_bytes(&app_seed, &seed_id, &data) { + Err(e) => crate::app_eprintln!( + "[analysis] local-file seed failed for {}: {}", + seed_id, + e + ), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app_seed.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: seed_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + }); + } let reader = LocalFileSource { file, len }; PlayInput::SeekableMedia { reader: Box::new(reader), @@ -3112,6 +3107,8 @@ pub async fn audio_play( done.clone(), state.stream_completed_cache.clone(), state.normalization_target_lufs.clone(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let reader = RangedHttpSource { buf, @@ -3150,6 +3147,8 @@ pub async fn audio_play( done.clone(), state.stream_completed_cache.clone(), state.normalization_target_lufs.clone(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::>(); @@ -3184,10 +3183,24 @@ pub async fn audio_play( } let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); - let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db); + let resolved_loudness_gain_db = resolve_loudness_gain_from_cache( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + ); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let startup_loudness_gain_db = if norm_mode == 2 { - loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + loudness_gain_db, + ) } else { resolved_loudness_gain_db }; @@ -3511,6 +3524,7 @@ pub async fn audio_chain_preload( pre_gain_db: f32, fallback_db: f32, hi_res_enabled: bool, + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -3571,6 +3585,11 @@ pub async fn audio_chain_preload( let raw_bytes = Arc::new(data); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Only `gain_linear` is needed — `effective_volume` is intentionally NOT // applied to the Sink here. `audio_chain_preload` runs ~30 s before the // current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl. @@ -3578,10 +3597,19 @@ pub async fn audio_chain_preload( // applied at the gapless transition in `spawn_progress_task`, not here. let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let chain_loudness_db = if norm_mode == 2 { - loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + loudness_gain_db, + ) } else { - resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db) + resolve_loudness_gain_from_cache(&app, &url, target_lufs, logical_trim.as_deref()) }; let (gain_linear, _effective_volume) = compute_gain( norm_mode, @@ -3940,6 +3968,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu pub fn audio_stop(state: State<'_, AudioEngine>) { state.generation.fetch_add(1, Ordering::SeqCst); *state.current_playback_url.lock().unwrap() = None; + *state.current_analysis_track_id.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; *state.stream_completed_cache.lock().unwrap() = None; // Drop RadioLiveState → triggers Drop → task.abort() → TCP released. @@ -4075,22 +4104,45 @@ pub fn audio_update_replay_gain( ) { let norm_mode = state.normalization_engine.load(Ordering::Relaxed); let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let url_for_loudness = if norm_mode == 2 { state.current_playback_url.lock().unwrap().clone() } else { None }; + let logical_for_loudness = state + .current_analysis_track_id + .lock() + .ok() + .and_then(|g| (*g).clone()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` // so `loudness_ui_current_gain_db` can show a number (otherwise `and_then` // drops the requested gain entirely). let resolved_loudness_gain_db = url_for_loudness .as_deref() - .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)) + .and_then(|u| { + resolve_loudness_gain_from_cache( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + ) + }) .or(loudness_gain_db); let effective_loudness_db = if norm_mode == 2 { match url_for_loudness.as_deref() { - Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db), - None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)), + Some(u) => loudness_gain_db_or_startup( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + pre_analysis_db, + true, + loudness_gain_db, + ), + None => loudness_gain_db.or(Some(pre_analysis_db)), } } else { loudness_gain_db @@ -4193,6 +4245,7 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State pub async fn audio_preload( url: String, duration_hint: f64, + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -4221,14 +4274,23 @@ pub async fn audio_preload( response.bytes().await.map_err(|e| e.to_string())?.into() }; let _ = duration_hint; // kept in API for compatibility - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { - crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } let url_for_emit = url.clone(); @@ -4579,7 +4641,13 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { } #[tauri::command] -pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) { +pub fn audio_set_normalization( + engine: String, + target_lufs: f32, + pre_analysis_attenuation_db: f32, + app: AppHandle, + state: State<'_, AudioEngine>, +) { let mode = match engine.as_str() { "replaygain" => 1, "loudness" => 2, @@ -4590,11 +4658,16 @@ pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state .normalization_target_lufs .store(target.to_bits(), Ordering::Relaxed); + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + state + .loudness_pre_analysis_attenuation_db + .store(pre.to_bits(), Ordering::Relaxed); crate::app_deprintln!( - "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}", + "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}", engine, normalization_engine_name(mode), - target + target, + pre ); let _ = app.emit( "audio:normalization-state", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4fdef949..5d6983bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1199,8 +1199,30 @@ fn analysis_get_waveform( md5_16kb: String, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let key = analysis_cache::TrackKey { track_id, md5_16kb }; + 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, @@ -1217,6 +1239,23 @@ fn analysis_get_waveform_for_track( cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, 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, @@ -1349,18 +1388,19 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path } fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { - analysis_cache::seed_from_bytes(app, track_id, bytes) - .map_err(|e| { - crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); - e - })?; - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { - track_id: track_id.to_string(), - is_partial: false, - }, - ); + 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::() .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 070a61a3..274b5b62 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -109,6 +109,24 @@ function easeOutCubic(t: number): number { 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); @@ -865,9 +883,6 @@ export default function WaveformSeek({ trackId }: Props) { const seek = usePlayerStore(s => s.seek); const waveformBins = usePlayerStore(s => s.waveformBins); - const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial); - const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec); - const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); @@ -882,18 +897,16 @@ export default function WaveformSeek({ trackId }: Props) { return; } if (waveformBins && waveformBins.length > 0) { - const src = waveformBins; - 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))); - } + 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(); @@ -946,7 +959,7 @@ export default function WaveformSeek({ trackId }: Props) { } // No analysis bins yet: render 500 flat bars immediately. heightsRef.current = makeFlatWaveHeights(); - }, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]); + }, [trackId, waveformBins]); // Imperative subscription — no React re-renders from progress changes. // Static styles draw here; animated styles only update refs. @@ -983,9 +996,6 @@ export default function WaveformSeek({ trackId }: Props) { seekbarStyle, trackId, waveformBins, - waveformIsPartial, - waveformKnownUntilSec, - waveformDurationSec, duration, ]); diff --git a/src/locales/de.ts b/src/locales/de.ts index 1e026994..ee5d6c94 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -847,6 +847,12 @@ export const deTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getaggte Dateien)', 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', crossfadeDesc: 'Überblendung zwischen Tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/en.ts b/src/locales/en.ts index a95ceba7..ae79cfe7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -853,6 +853,12 @@ export const enTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (tagged files)', 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', crossfadeDesc: 'Fade between tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/es.ts b/src/locales/es.ts index 3da001dd..848ac685 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -840,6 +840,12 @@ export const esTranslation = { replayGainAlbum: 'Álbum', replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)', 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', crossfadeDesc: 'Transición entre pistas', crossfadeSecs: '{{n}} s', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 77f4624d..96b25972 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -835,6 +835,12 @@ export const frTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pré-Gain (fichiers taggés)', 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 n’est 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é', crossfadeDesc: 'Fondu entre les pistes', crossfadeSecs: '{{n}} s', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d8434943..6f0dc8c5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -834,6 +834,12 @@ export const nbTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (taggede filer)', 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', crossfadeDesc: 'Tone mellom spor', crossfadeSecs: '{{n}}s', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 1cfb04ea..22796f55 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -834,6 +834,12 @@ export const nlTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getagde bestanden)', 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', crossfadeDesc: 'Fade tussen nummers', crossfadeSecs: '{{n}} s', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 555b193f..24c4883c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -886,6 +886,12 @@ export const ruTranslation = { replayGainAlbum: 'По альбому', replayGainPreGain: 'Предусиление (файлы с тегами)', replayGainFallback: 'Резерв (без тегов / радио)', + normalization: 'Нормализация', + loudnessTargetLufs: 'Целевой LUFS', + loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.', + loudnessPreAnalysisAttenuationReset: 'По умолчанию', crossfade: 'Кроссфейд', crossfadeDesc: 'Плавный переход между треками', crossfadeSecs: '{{n}} с', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 780c2307..3da16846 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -829,6 +829,12 @@ export const zhTranslation = { replayGainAlbum: '专辑', replayGainPreGain: '预增益(有标签文件)', replayGainFallback: '回退增益(无标签 / 收音机)', + normalization: '响度归一化', + loudnessTargetLufs: '目标 LUFS', + loudnessPreAnalysisAttenuation: '测量前衰减 (dB)', + loudnessPreAnalysisAttenuationDesc: + '在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。', + loudnessPreAnalysisAttenuationReset: '恢复默认', crossfade: '交叉淡入淡出', crossfadeDesc: '曲目间淡入淡出', crossfadeSecs: '{{n}} 秒', diff --git a/src/main.tsx b/src/main.tsx index 4de98ddf..f6c65bbf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,22 @@ try { // 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( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 28f9cd98..3d7f5f51 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -26,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; 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 { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; @@ -65,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 LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16]; + +function LoudnessLufsButtonGroup(props: { + value: LoudnessLufsPreset; + onSelect: (v: LoudnessLufsPreset) => void; +}) { + return ( +
+ {LOUDNESS_LUFS_BUTTON_ORDER.map(v => ( + + ))} +
+ ); +} + const CONTRIBUTORS = [ { github: 'jiezhuo', @@ -1795,9 +1828,6 @@ export default function Settings() { const deleted = await invoke('analysis_delete_all_waveforms'); usePlayerStore.setState({ waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, }); showToast( t('settings.waveformCacheCleared', { count: deleted }), @@ -2243,12 +2273,7 @@ export default function Settings() {
{/* Normalization */}
-
-
{t('settings.normalization', { defaultValue: 'Normalization' })}
-
- {t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })} -
-
+
{t('settings.normalization', { defaultValue: 'Normalization' })}
+ {auth.normalizationEngine === 'loudness' && ( +
+
+ + {t('settings.loudnessTargetLufs')} + + +
+
+ + {t('settings.loudnessPreAnalysisAttenuation')} + + auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + /> + + {auth.loudnessPreAnalysisAttenuationDb} dB + + +
+
+ {t('settings.loudnessPreAnalysisAttenuationDesc')} +
+
+ )} {auth.normalizationEngine !== 'off' && (
- {auth.normalizationEngine === 'loudness' && ( - <> -
- Target LUFS: - - - - -
- - )} {auth.normalizationEngine === 'replaygain' && (
{t('settings.replayGainMode')}: diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 5fbb5f8a..a2593c2f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -22,6 +22,27 @@ export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented 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 interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } @@ -51,7 +72,9 @@ interface AuthState { customGenreBlacklist: string[]; replayGainEnabled: boolean; normalizationEngine: NormalizationEngine; - loudnessTargetLufs: -16 | -14 | -12 | -10; + loudnessTargetLufs: LoudnessLufsPreset; + /** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */ + loudnessPreAnalysisAttenuationDb: number; replayGainMode: 'track' | 'album' | 'auto'; replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) @@ -212,7 +235,9 @@ interface AuthState { setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; setNormalizationEngine: (v: NormalizationEngine) => void; - setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void; + setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void; + setLoudnessPreAnalysisAttenuationDb: (v: number) => void; + resetLoudnessPreAnalysisAttenuationDbDefault: () => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; @@ -326,6 +351,7 @@ export const useAuthStore = create()( replayGainEnabled: false, normalizationEngine: 'off', loudnessTargetLufs: -12, + loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, @@ -462,6 +488,13 @@ export const useAuthStore = create()( 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) => { set({ replayGainMode: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); @@ -696,6 +729,10 @@ export const useAuthStore = create()( } catch { /* ignore */ } } + const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown }; + const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12); + const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb); + useAuthStore.setState({ mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), @@ -703,6 +740,8 @@ export const useAuthStore = create()( skipStarManualSkipCountsByKey: sanitizeSkipStarCounts( (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, ), + loudnessTargetLufs: targetSan, + loudnessPreAnalysisAttenuationDb: preSan, ...conflictingLegacyState, ...lyricsSourcesMigrated, ...wheelSmoothOneTime, diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e2210483..6f429c9d 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -148,9 +148,6 @@ async function buildInfiniteQueueCandidates( interface PlayerState { currentTrack: Track | null; waveformBins: number[] | null; - waveformIsPartial: boolean; - waveformKnownUntilSec: number; - waveformDurationSec: number; normalizationNowDb: number | null; normalizationTargetLufs: number | null; normalizationEngineLive: 'off' | 'replaygain' | 'loudness'; @@ -264,7 +261,8 @@ interface PlayerState { } type WaveformCachePayload = { - bins: number[]; + /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ + bins: number[] | Uint8Array; binCount: number; isPartial: boolean; knownUntilSec: number; @@ -272,6 +270,27 @@ type WaveformCachePayload = { updatedAt: number; }; +/** `Vec` 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).map(x => Number(x) & 255); + } catch { + return null; + } + } + return null; +} + type LoudnessCachePayload = { integratedLufs: number; truePeak: number; @@ -613,6 +632,14 @@ function clearLoudnessCacheStateForTrackId(trackId: string) { } } +/** 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]; @@ -651,20 +678,15 @@ async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; try { const row = await invoke('analysis_get_waveform_for_track', { trackId }); - if (!row || !Array.isArray(row.bins) || row.bins.length === 0) { + const bins = row ? coerceWaveformBins(row.bins) : null; + if (!bins || bins.length === 0) { usePlayerStore.setState({ waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, }); return; } usePlayerStore.setState({ - waveformBins: row.bins, - waveformIsPartial: !!row.isPartial, - waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0, - waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0, + waveformBins: bins, }); } catch { // best-effort; seekbar falls back to placeholder waveform @@ -738,6 +760,31 @@ async function refreshLoudnessForTrack( } } +/** 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(); + 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) { try { const res = await invoke<{ path: string; size: number } | null>( @@ -824,10 +871,6 @@ function handleAudioProgress(current_time: number, duration: number) { const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; - // Do not couple `waveformKnownUntilSec` to playhead: for partial streams it - // must track **download/analysis progress** (emitted in `analysis:waveform-partial`). - // Using `displayTime` here broke the seekbar after seeks — bins only cover the - // buffered prefix of the file while `knownUntil` jumped with the seek position. usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) @@ -915,7 +958,11 @@ function handleAudioProgress(current_time: number, duration: number) { 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. @@ -942,10 +989,11 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, - loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, + analysisTrackId: nextTrack.id, }).catch(() => {}); } } @@ -972,8 +1020,6 @@ function handleAudioEnded() { progress: 0, currentTime: 0, buffered: 0, - waveformIsPartial: false, - waveformKnownUntilSec: 0, }); setTimeout(() => { if (repeatMode === 'one' && currentTrack) { @@ -1019,9 +1065,6 @@ function handleAudioTrackSwitched(duration: number) { usePlayerStore.setState({ currentTrack: nextTrack, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), normalizationDbgSource: 'track-switched', normalizationDbgTrackId: nextTrack.id, @@ -1106,48 +1149,6 @@ export function initAudioListeners(): () => void { listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), - listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => { - const current = usePlayerStore.getState().currentTrack; - if (!current || !payload) return; - const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); - if (payloadTrackId && payloadTrackId !== current.id) return; - if (!payload.isPartial || !payload?.bins?.length) { - usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 }); - return; - } - const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0; - const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0); - usePlayerStore.setState((prev) => { - const prevBins = prev.waveformBins; - const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0; - if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) { - return { - waveformBins: payload.bins, - waveformIsPartial: true, - waveformKnownUntilSec: nextKnownUntilSec, - waveformDurationSec: nextDurationSec, - }; - } - const len = Math.max(prevBins.length, payload.bins.length); - const merged = new Array(len); - for (let i = 0; i < len; i++) { - merged[i] = i < prevBins.length ? prevBins[i] : 0; - } - const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len))); - const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len))); - if (nextKnownBars > prevKnownBars) { - for (let i = prevKnownBars; i < nextKnownBars; i++) { - if (i < payload.bins.length) merged[i] = payload.bins[i]; - } - } - return { - waveformBins: merged, - waveformIsPartial: true, - waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec), - waveformDurationSec: nextDurationSec, - }; - }); - }), listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { const current = usePlayerStore.getState().currentTrack; if (!current || !payload) return; @@ -1167,12 +1168,10 @@ export function initAudioListeners(): () => void { if (!payload?.trackId) return; const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); if (!payloadTrackId) return; - const currentId = usePlayerStore.getState().currentTrack?.id; + const currentRaw = usePlayerStore.getState().currentTrack?.id; + const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null; if (currentId && payloadTrackId === currentId) { - if (!payload.isPartial) { - usePlayerStore.setState({ waveformIsPartial: false }); - } - void refreshWaveformForTrack(currentId); + void refreshWaveformForTrack(currentRaw!); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId }); return; @@ -1265,7 +1264,12 @@ export function initAudioListeners(): () => void { 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) { @@ -1281,6 +1285,7 @@ export function initAudioListeners(): () => void { // 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) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, @@ -1289,10 +1294,16 @@ export function initAudioListeners(): () => void { invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); const normChanged = state.normalizationEngine !== prevNormEngine - || state.loudnessTargetLufs !== prevNormTarget; + || 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, @@ -1309,10 +1320,13 @@ export function initAudioListeners(): () => void { 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 (currentId) { + if (onlyPreAnalysisChanged) { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } else if (currentId) { void refreshLoudnessForTrack(currentId).finally(() => { usePlayerStore.getState().updateReplayGainForCurrentTrack(); }); @@ -1504,9 +1518,6 @@ export const usePlayerStore = create()( (set, get) => ({ currentTrack: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1643,9 +1654,6 @@ export const usePlayerStore = create()( currentTime: 0, currentRadio: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1690,9 +1698,6 @@ export const usePlayerStore = create()( currentRadio: station, currentTrack: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1804,9 +1809,6 @@ export const usePlayerStore = create()( currentTrack: track, currentRadio: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), queue: newQueue, queueIndex: idx >= 0 ? idx : 0, @@ -1848,11 +1850,12 @@ export const usePlayerStore = create()( durationHint: track.duration, replayGainDb, replayGainPeak, - loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(track.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, manual, hiResEnabled: authState.enableHiRes, + analysisTrackId: track.id, }) .then(() => { if (playGeneration !== gen) return; @@ -2029,11 +2032,12 @@ export const usePlayerStore = create()( durationHint: trackToPlay.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, - loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: trackToPlay.id, }).then(() => { if (playGeneration === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); @@ -2064,11 +2068,12 @@ export const usePlayerStore = create()( durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, - loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: currentTrack.id, }).catch((err: unknown) => { if (playGeneration !== gen) return; setDeferHotCachePrefetch(false); @@ -2358,6 +2363,7 @@ export const usePlayerStore = create()( ...state.queue.slice(firstAutoIdx), ]; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex); return { queue: newQueue }; }); }, @@ -2404,6 +2410,7 @@ export const usePlayerStore = create()( ? state.queueIndex + tracks.length : state.queueIndex; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex); return { queue: newQueue, queueIndex: newQueueIndex }; }); }, @@ -2495,6 +2502,7 @@ export const usePlayerStore = create()( currentTrack, currentTime: serverTime > 0 ? serverTime : localTime, }); + void refreshWaveformForTrack(currentTrack.id); } } catch (e) { console.error('Failed to initialize queue from server', e); From ea93f7fc4d17f147727a385a093b48d0a014745b Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sun, 26 Apr 2026 02:36:38 +0300 Subject: [PATCH 6/6] fix(player): keep seekbar waveform on current track during gapless preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byte-preload for the next track called refreshWaveformForTrack(next), which writes global waveformBins and replaced the playing track’s waveform when seeking near the end. Drop that call and ignore stale RPC results if currentTrack changed while the fetch was in flight. --- src/store/playerStore.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6f429c9d..61b2d9b9 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -678,6 +678,8 @@ async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; try { const row = await invoke('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({ @@ -945,8 +947,8 @@ function handleAudioProgress(current_time: number, duration: number) { // Byte pre-download — runs early so bytes are cached by chain time. if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) { bytePreloadingId = nextTrack.id; - // Keep analysis context warm for the upcoming track before any source swap. - void refreshWaveformForTrack(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) { console.info('[psysonic][preload-request]', {