From 86704473edb54c0d29c8a659a5d63669e18b9b0e Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 25 Apr 2026 22:09:38 +0300 Subject: [PATCH] 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);