diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 88af9704..37c8adad 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..429d796a --- /dev/null +++ b/src-tauri/src/analysis_cache.rs @@ -0,0 +1,824 @@ +use std::path::PathBuf; +use std::io::Cursor; +use std::sync::Mutex; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use ebur128::{EbuR128, Mode as Ebur128Mode}; +use rusqlite::{params, Connection, OptionalExtension}; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use tauri::Manager; + +pub const WAVEFORM_ALGO_VERSION: i64 = 3; +pub const LOUDNESS_ALGO_VERSION: i64 = 1; + +#[derive(Debug, Clone)] +pub struct TrackKey { + pub track_id: String, + pub md5_16kb: String, +} + +#[derive(Debug, Clone)] +pub struct WaveformEntry { + pub bins: Vec, + 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) }) + } + + /// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant). + pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result { + if track_id.trim().is_empty() { + return Ok(0); + } + let conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let mut total: u64 = 0; + for tid in track_id_cache_variants(track_id) { + let n = conn + .execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid]) + .map_err(|e| e.to_string())?; + total = total.saturating_add(n as u64); + } + Ok(total) + } + + /// Remove all cached waveform rows across all tracks/variants. + pub fn delete_all_waveforms(&self) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let n = conn + .execute("DELETE FROM waveform_cache", []) + .map_err(|e| e.to_string())?; + Ok(n as u64) + } + + pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> { + let now = now_unix_ts(); + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + 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) +} + +/// Result of [`seed_from_bytes`]: callers use it to avoid redundant UI events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeedFromBytesOutcome { + /// Wrote waveform (and loudness when PCM decode succeeded). + Upserted, + /// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version. + SkippedWaveformCacheHit, + /// `AnalysisCache` was not registered on the app handle. + SkippedNoAnalysisCache, +} + +pub fn seed_from_bytes( + app: &tauri::AppHandle, + track_id: &str, + bytes: &[u8], +) -> Result { + let started = Instant::now(); + let Some(cache) = app.try_state::() else { + crate::app_deprintln!( + "[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}", + track_id, + bytes.len() + ); + return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache); + }; + let key = TrackKey { + track_id: track_id.to_string(), + md5_16kb: md5_first_16kb(bytes), + }; + if let Some(existing) = cache.get_waveform(&key)? { + if !existing.bins.is_empty() { + crate::app_deprintln!( + "[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}", + track_id, + key.md5_16kb, + existing.bins.len(), + started.elapsed().as_millis() + ); + return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit); + } + } + crate::app_deprintln!( + "[analysis][waveform] build start track_id={} bytes={} md5_16kb={}", + track_id, + bytes.len(), + key.md5_16kb + ); + + let build = (|| -> Result<(bool, usize), String> { + cache.touch_track_status(&key, "queued")?; + + let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) { + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { + ( + bins, + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)), + true, + ) + } + None => (derive_waveform_bins(bytes, 500), None, false), + }; + let bins_len = wf_bins.len(); + let waveform = WaveformEntry { + bins: wf_bins, + bin_count: 500, + is_partial: false, + known_until_sec: 0.0, + duration_sec: 0.0, + updated_at: now_unix_ts(), + }; + cache.upsert_waveform(&key, &waveform)?; + + if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt { + let loudness = LoudnessEntry { + integrated_lufs, + true_peak, + recommended_gain_db, + target_lufs, + updated_at: now_unix_ts(), + }; + cache.upsert_loudness(&key, &loudness)?; + } + + cache.touch_track_status(&key, "ready")?; + Ok((used_pcm_decode, bins_len)) + })(); + + let elapsed_ms = started.elapsed().as_millis(); + match &build { + Ok((used_pcm_decode, bins_len)) => { + crate::app_deprintln!( + "[analysis][waveform] build done track_id={} elapsed_ms={} path={} bins_len={}", + track_id, + elapsed_ms, + if *used_pcm_decode { + "pcm_ebur128" + } else { + "byte_envelope" + }, + bins_len + ); + } + Err(e) => { + crate::app_deprintln!( + "[analysis][waveform] build failed track_id={} elapsed_ms={} err={}", + track_id, + elapsed_ms, + e + ); + } + } + + match build { + Ok(_) => Ok(SeedFromBytesOutcome::Upserted), + Err(e) => Err(e), + } +} + +fn now_unix_ts() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn md5_first_16kb(bytes: &[u8]) -> String { + let n = bytes.len().min(16 * 1024); + format!("{:x}", md5::compute(&bytes[..n])) +} + +fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { + if bin_count == 0 || bytes.is_empty() { + return Vec::new(); + } + let mut out = vec![0u8; bin_count]; + for (i, slot) in out.iter_mut().enumerate() { + let start = i * bytes.len() / bin_count; + let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len()); + let mut peak: u8 = 0; + for &b in &bytes[start..end] { + let centered = if b >= 128 { b - 128 } else { 128 - b }; + if centered > peak { + peak = centered; + } + } + *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; + } + out +} + +struct PcmScanResult { + bins: Vec, + loudness: Option<(f64, f64, f64, f64)>, +} + +/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count. +fn analyze_loudness_and_waveform( + bytes: &[u8], + target_lufs: f64, + bin_count: usize, +) -> Option<(f64, f64, f64, f64, Vec)> { + if bytes.is_empty() || bin_count == 0 { + return None; + } + let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?; + if decoded_frames == 0 { + return None; + } + let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?; + let (i, t, r, tgt) = scanned.loudness?; + Some((i, t, r, tgt, scanned.bins)) +} + +/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is +/// `codec_params.n_frames` when the container reports total track length — used +/// as a **fixed** waveform time axis so partial decodes do not remap every bin +/// when the buffer grows. +fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option)> { + let source = Box::new(Cursor::new(bytes.to_vec())); + let mss = MediaSourceStream::new(source, Default::default()); + let hint = Hint::new(); + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .ok()?; + let mut format = probed.format; + let track = format + .default_track() + .filter(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .or_else(|| { + format.tracks().iter().find(|t| { + t.codec_params.codec != CODEC_TYPE_NULL + && t.codec_params.sample_rate.is_some() + && t.codec_params.channels.is_some() + }) + }) + .or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?; + let track_id = track.id; + let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0); + let codec_params = track.codec_params.clone(); + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .ok()?; + + let mut total: u64 = 0; + let mut loop_i: u32 = 0; + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(_) => break, + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(buf) => buf, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(SymphoniaError::ResetRequired) => break, + Err(_) => break, + }; + let spec = *decoded.spec(); + let n_ch = spec.channels.count(); + if n_ch == 0 { + continue; + } + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); + samples.copy_interleaved_ref(decoded); + let n = samples.samples().len(); + if n < n_ch || n % n_ch != 0 { + continue; + } + total += (n / n_ch) as u64; + loop_i = loop_i.wrapping_add(1); + if loop_i % 128 == 0 { + std::thread::yield_now(); + } + } + if total == 0 { + None + } else { + Some((total, timeline_hint)) + } +} + +fn normalize_peak_bins(bin_max: &[f32]) -> Vec { + let bin_count = bin_max.len(); + if bin_count == 0 { + return Vec::new(); + } + let mut sorted: Vec = bin_max.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))]; + let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))]; + let range = (p99 - p5).max(1e-8); + let mut out = vec![0u8; bin_count]; + for i in 0..bin_count { + let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0); + let shaped = t.powf(0.52); + out[i] = (8.0 + shaped * 247.0).min(255.0) as u8; + } + out +} + +fn decode_scan_pcm( + bytes: &[u8], + bin_count: usize, + decoded_frames: u64, + timeline_hint: Option, + loudness_target_lufs: Option, +) -> Option { + let source = Box::new(Cursor::new(bytes.to_vec())); + let mss = MediaSourceStream::new(source, Default::default()); + let hint = Hint::new(); + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .ok()?; + let mut format = probed.format; + let track = format + .default_track() + .filter(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .or_else(|| { + format.tracks().iter().find(|t| { + t.codec_params.codec != CODEC_TYPE_NULL + && t.codec_params.sample_rate.is_some() + && t.codec_params.channels.is_some() + }) + }) + .or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?; + let track_id = track.id; + let codec_params = track.codec_params.clone(); + let mut decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) { + Ok(v) => v, + Err(e) => { + crate::app_deprintln!("[analysis] decoder make failed: {}", e); + return None; + } + }; + + let mut bin_max = vec![0.0f32; bin_count]; + let mut ebu: Option = None; + let mut ebu_channels: u32 = 0; + let mut sample_peak_abs = 0.0_f64; + let mut fed_any_frames = false; + let mut sample_idx: u64 = 0; + let mut loop_i: u32 = 0; + // Fixed timeline from metadata when available; otherwise fall back to decoded + // length (full-buffer analysis only — partial byte windows still shift, but + // then we usually lack n_frames anyway). + let bin_grid_frames = timeline_hint + .map(|n| n.max(decoded_frames)) + .unwrap_or(decoded_frames) + .max(1); + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(_) => break, + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(buf) => buf, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(SymphoniaError::ResetRequired) => break, + Err(_) => break, + }; + + let spec = *decoded.spec(); + let n_ch = spec.channels.count(); + if n_ch == 0 { + continue; + } + + if loudness_target_lufs.is_some() && ebu.is_none() { + let ch = spec.channels.count() as u32; + let sr = spec.rate; + match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) { + Ok(v) => { + ebu = Some(v); + ebu_channels = ch; + } + Err(e) => { + crate::app_deprintln!( + "[analysis] EbuR128 init failed: channels={} sample_rate={} err={}", + ch, + sr, + e + ); + return None; + } + } + } + + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); + samples.copy_interleaved_ref(decoded); + let slice = samples.samples(); + if slice.len() < n_ch || slice.len() % n_ch != 0 { + continue; + } + let frames = slice.len() / n_ch; + + for f in 0..frames { + let base = f * n_ch; + let mut acc = 0.0f32; + for c in 0..n_ch { + acc += slice[base + c]; + } + let mono = acc / (n_ch as f32); + let mag = mono.abs(); + if mag.is_finite() { + let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize; + let bin = bin.min(bin_count.saturating_sub(1)); + bin_max[bin] = bin_max[bin].max(mag); + } + for c in 0..n_ch { + let v = (slice[base + c] as f64).abs(); + if v.is_finite() && v > sample_peak_abs { + sample_peak_abs = v; + } + } + sample_idx += 1; + } + + if loudness_target_lufs.is_some() { + if let Some(e) = ebu.as_mut() { + match e.add_frames_f32(samples.samples()) { + Ok(_) => fed_any_frames = true, + Err(err) => { + crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err); + return None; + } + } + } + } + + loop_i = loop_i.wrapping_add(1); + if loop_i % 128 == 0 { + std::thread::yield_now(); + } + } + + let bins = normalize_peak_bins(&bin_max); + + let loudness = if let Some(target_lufs) = loudness_target_lufs { + if !fed_any_frames { + crate::app_deprintln!("[analysis] loudness failed: no decoded frames"); + return None; + } + let Some(ebu) = ebu else { + crate::app_deprintln!("[analysis] loudness failed: ebu not initialized"); + return None; + }; + let integrated_lufs = match ebu.loudness_global() { + Ok(v) => v, + Err(e) => { + crate::app_deprintln!("[analysis] loudness_global failed: {}", e); + return None; + } + }; + if !integrated_lufs.is_finite() { + crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite"); + return None; + } + let mut true_peak = 0.0_f64; + let mut true_peak_ok = true; + for ch in 0..ebu_channels { + match ebu.true_peak(ch) { + Ok(v) if v.is_finite() && v > true_peak => true_peak = v, + Ok(_) => {} + Err(e) => { + true_peak_ok = false; + crate::app_deprintln!("[analysis] true_peak unavailable: {}", e); + break; + } + } + } + if !true_peak_ok { + true_peak = sample_peak_abs; + } + let recommended_gain_db = + recommended_gain_for_target(integrated_lufs, true_peak, target_lufs); + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) + } else { + None + }; + + Some(PcmScanResult { bins, loudness }) +} + +fn analysis_db_path(app: &tauri::AppHandle) -> Result { + 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..cc1793e4 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -21,7 +21,31 @@ 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 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 +1045,22 @@ 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, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { 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 +1149,38 @@ 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)); + let pre_db = f32::from_bits( + loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0); + emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db); + last_partial_loudness_emit = Instant::now(); + } offset += pushed; downloaded += pushed as u64; } } } if !capture_over_limit && !capture.is_empty() { + if let Some(track_id) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { + Err(e) => crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url: url.clone(), data: capture, @@ -1144,12 +1199,17 @@ 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, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { let total_size = buf.lock().unwrap().len(); let mut downloaded: usize = 0; @@ -1157,6 +1217,7 @@ 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_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { let response = if let Some(r) = next_response.take() { @@ -1231,6 +1292,28 @@ async fn ranged_download_task( } downloaded += n; downloaded_to.store(downloaded, Ordering::SeqCst); + if downloaded >= PARTIAL_LOUDNESS_MIN_BYTES + && total_size > 0 + && 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)); + let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed)) + .clamp(-24.0, 0.0); + if let Some(provisional_db) = + provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db) + { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, + target_lufs, + is_partial: true, + }, + ); + } + last_partial_loudness_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 +1345,91 @@ 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) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay"); } } +fn emit_partial_loudness_from_bytes( + app: &AppHandle, + url: &str, + bytes: &[u8], + target_lufs: f32, + pre_analysis_attenuation_db: f32, +) { + if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES { + crate::app_deprintln!( + "[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}", + 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 pre_floor = pre_analysis_attenuation_db.clamp(-24.0, 0.0); + // Target-derived hint (e.g. -12 LUFS → -1 dB). Old `(hint).clamp(pre, 0)` left + // the hint when it lay inside [pre, 0] — e.g. -1 with pre=-6, so AAC/M4A + // streaming often sat at -1 dB until full analysis. Combine with user trim: + // stricter (more negative) pre wins; milder pre still caps vs the hint. + let heuristic_floor = (target_lufs + 11.0).clamp(-6.0, 0.0); + let floor_db = if pre_floor < heuristic_floor { + pre_floor + } else { + pre_floor.max(heuristic_floor) + }; + let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0); + crate::app_deprintln!( + "[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}", + 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, + start_db_in: f32, +) -> Option { + if total_size == 0 || downloaded == 0 { + return None; + } + let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0); + // Move from startup attenuation toward a more realistic late-stream level. + // This avoids staying near -2 dB and then jumping hard when final LUFS lands. + let start_db = start_db_in.clamp(-24.0, 0.0).min(0.0); + let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0); + let shaped = progress.powf(0.75); + Some(start_db + (end_db - start_db) * shaped) +} + 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 +2162,12 @@ 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, + /// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings). + pub loudness_pre_analysis_attenuation_db: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. pub(crate) chained_info: Arc>>, @@ -2015,6 +2184,13 @@ 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>>, + /// Subsonic song id last passed from JS with `audio_play` (trimmed). Used + /// for loudness/waveform cache when the URL is `psysonic-local://…`. + pub(crate) current_analysis_track_id: Arc>>, } pub struct AudioCurrent { @@ -2190,11 +2366,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 +2454,17 @@ 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())), + loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())), chained_info: Arc::new(Mutex::new(None)), samples_played: Arc::new(AtomicU64::new(0)), current_sample_rate: Arc::new(AtomicU32::new(0)), 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)), + current_analysis_track_id: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -2336,6 +2519,18 @@ fn playback_identity(url: &str) -> Option { None } +/// Stable id for analysis cache rows and `analysis:waveform-updated`. +/// Prefer the Subsonic track id from the frontend: `psysonic-local://` URLs +/// only map to `local:path` in `playback_identity`, which does not match +/// `analysis_get_waveform_for_track(trackId)` or the UI's `currentTrack.id`. +fn analysis_cache_track_id(logical_track_id: Option<&str>, url: &str) -> Option { + let logical = logical_track_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + logical.or_else(|| playback_identity(url)) +} + fn same_playback_target(a_url: &str, b_url: &str) -> bool { match (playback_identity(a_url), playback_identity(b_url)) { (Some(a), Some(b)) => a == b, @@ -2343,6 +2538,112 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool { } } +fn resolve_loudness_gain_from_cache( + app: &AppHandle, + url: &str, + target_lufs: f32, + logical_track_id: Option<&str>, +) -> Option { + // Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`) + // are applied in `audio_update_replay_gain` via `loudness_gain_db_or_startup(..., true, _)`. + let Some(track_id) = analysis_cache_track_id(logical_track_id, url) else { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-identity url_len={}", + url.len() + ); + return None; + }; + let Some(cache) = app.try_state::() else { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}", + track_id + ); + return None; + }; + // Also touch waveform row here so playback path verifies current context is present. + let _ = cache.get_latest_waveform_for_track(&track_id); + 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 + ); + None + } + Err(e) => { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-error track_id={} err={}", + track_id, + e + ); + None + } + } +} + +/// LUFS: DB-backed integrated LUFS only at bind time (`allow_js_when_uncached = false`); +/// after `analysis:loudness-partial`, `audio_update_replay_gain` passes `true` so finite +/// JS gain applies until SQLite catches up. Must never return `None` or `compute_gain` uses unity. +fn loudness_gain_db_or_startup( + app: &AppHandle, + url: &str, + target_lufs: f32, + logical_track_id: Option<&str>, + pre_analysis_attenuation_db: f32, + allow_js_when_uncached: bool, + js_gain_db: Option, +) -> Option { + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + match resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id) { + Some(g) => Some(g), + None => { + if allow_js_when_uncached { + match js_gain_db { + Some(r) if r.is_finite() => Some(r), + _ => Some(pre), + } + } else { + Some(pre) + } + } + } +} + +#[inline] +fn loudness_pre_analysis_db_for_engine(state: &AudioEngine) -> f32 { + f32::from_bits( + state + .loudness_pre_analysis_attenuation_db + .load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0) + .min(0.0) +} + /// Take (consume) completed manual-stream bytes if they correspond to `url`. pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option> { let mut guard = state.stream_completed_cache.lock().unwrap(); @@ -2439,25 +2740,106 @@ 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 = 900; 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 ───────────────────────────────────────────────────────────────── +/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness +/// cache to the track when playing `psysonic-local://` (hot/offline). Optional +/// for HTTP streams (`playback_identity` is used as fallback). #[tauri::command] pub async fn audio_play( url: String, @@ -2465,10 +2847,12 @@ 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 hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -2541,6 +2925,17 @@ 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()); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + *state.current_analysis_track_id.lock().unwrap() = logical_trim.clone(); + let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url); + // Extract format hint from URL for better symphonia probing. Strip the // query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) // don't latch onto random query-param substrings; only accept short @@ -2614,6 +3009,45 @@ pub async fn audio_play( len / 1024, local_hint ); + if let Some(ref seed_id) = cache_id_for_tasks { + let path_owned = std::path::PathBuf::from(path); + let app_seed = app.clone(); + let gen_seed = gen; + let gen_arc_seed = state.generation.clone(); + let seed_id = seed_id.clone(); + tokio::spawn(async move { + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + let data = match tokio::fs::read(&path_owned).await { + Ok(d) => d, + Err(_) => return, + }; + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { + return; + } + match crate::analysis_cache::seed_from_bytes(&app_seed, &seed_id, &data) { + Err(e) => crate::app_eprintln!( + "[analysis] local-file seed failed for {}: {}", + seed_id, + e + ), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app_seed.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: seed_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + }); + } let reader = LocalFileSource { file, len }; PlayInput::SeekableMedia { reader: Box::new(reader), @@ -2646,7 +3080,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 +3098,17 @@ 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(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let reader = RangedHttpSource { buf, @@ -2683,8 +3126,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 +3140,15 @@ 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(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::>(); @@ -2735,7 +3182,59 @@ 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, + logical_trim.as_deref(), + ); + let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); + let startup_loudness_gain_db = if norm_mode == 2 { + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + 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 +3498,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 +3520,12 @@ 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, + analysis_track_id: Option, + app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { // Idempotent: already chained this track → nothing to do. @@ -3082,12 +3585,41 @@ pub async fn audio_chain_preload( let raw_bytes = Arc::new(data); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Only `gain_linear` is needed — `effective_volume` is intentionally NOT // applied to the Sink here. `audio_chain_preload` runs ~30 s before the // current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl. // 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 pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); + let chain_loudness_db = if norm_mode == 2 { + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + loudness_gain_db, + ) + } else { + resolve_loudness_gain_from_cache(&app, &url, target_lufs, logical_trim.as_deref()) + }; + 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,7 +3721,24 @@ fn spawn_progress_task( sample_rate_arc: Arc, channels_arc: Arc, gapless_switch_at: Arc, + current_playback_url: Arc>>, ) { + fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 { + #[cfg(target_os = "linux")] + { + // Keep progress aligned with audible output (ALSA/PipeWire/Pulse + // queue). We mirror the quantum policy used for stream open/reopen. + let rate = sample_rate_hz.max(1.0); + let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 }; + // Add a small scheduler/mixer cushion so UI doesn't run ahead. + return (frames / rate) + 0.012; + } + #[cfg(not(target_os = "linux"))] + { + 0.0 + } + } + tokio::spawn(async move { let mut near_end_ticks: u32 = 0; // Local done-flag reference; swapped on gapless transition. @@ -3239,6 +3788,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 +3796,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) @@ -3274,21 +3826,25 @@ fn spawn_progress_task( let samples = samples_played.load(Ordering::Relaxed) as f64; let divisor = (rate * ch).max(1.0); - let dur = { + // Read playback snapshot under a single lock to minimize contention + // with seek/play/pause commands that also touch `current`. + let (dur, paused_at) = { let cur = current_arc.lock().unwrap(); - cur.duration_secs - }; - let is_paused = { - let cur = current_arc.lock().unwrap(); - cur.paused_at.is_some() + (cur.duration_secs, cur.paused_at) }; + let is_paused = paused_at.is_some(); - let pos = if is_paused { - let cur = current_arc.lock().unwrap(); - cur.paused_at.unwrap_or(0.0) + let pos_raw = if let Some(p) = paused_at { + p } else { (samples / divisor).min(dur.max(0.001)) }; + let progress_latency = if is_paused { + 0.0 + } else { + estimated_output_latency_secs(rate) + }; + let pos = (pos_raw - progress_latency).max(0.0); app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); @@ -3300,7 +3856,7 @@ fn spawn_progress_task( let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64; let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 }; - if dur > end_threshold && pos >= dur - end_threshold { + if dur > end_threshold && pos_raw >= dur - end_threshold { near_end_ticks += 1; // At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms. if near_end_ticks >= 10 { @@ -3411,6 +3967,8 @@ 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.current_analysis_track_id.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; *state.stream_completed_cache.lock().unwrap() = None; // Drop RadioLiveState → triggers Drop → task.abort() → TCP released. @@ -3525,9 +4083,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 +4096,94 @@ 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 pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); + let url_for_loudness = if norm_mode == 2 { + state.current_playback_url.lock().unwrap().clone() + } else { + None + }; + let logical_for_loudness = state + .current_analysis_track_id + .lock() + .ok() + .and_then(|g| (*g).clone()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` + // so `loudness_ui_current_gain_db` can show a number (otherwise `and_then` + // drops the requested gain entirely). + let resolved_loudness_gain_db = url_for_loudness + .as_deref() + .and_then(|u| { + resolve_loudness_gain_from_cache( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + ) + }) + .or(loudness_gain_db); + let effective_loudness_db = if norm_mode == 2 { + match url_for_loudness.as_deref() { + Some(u) => loudness_gain_db_or_startup( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + pre_analysis_db, + true, + loudness_gain_db, + ), + None => loudness_gain_db.or(Some(pre_analysis_db)), + } + } else { + loudness_gain_db + }; + 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. @@ -3608,6 +4245,7 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State pub async fn audio_preload( url: String, duration_hint: f64, + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -3636,6 +4274,25 @@ pub async fn audio_preload( response.bytes().await.map_err(|e| e.to_string())?.into() }; let _ = duration_hint; // kept in API for compatibility + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + } let url_for_emit = url.clone(); *state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data }); let _ = app.emit("audio:preload-ready", url_for_emit); @@ -3780,6 +4437,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 +4457,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 +4640,47 @@ 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, + pre_analysis_attenuation_db: 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); + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + state + .loudness_pre_analysis_attenuation_db + .store(pre.to_bits(), Ordering::Relaxed); + crate::app_deprintln!( + "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}", + engine, + normalization_engine_name(mode), + target, + pre + ); + 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 a6cc1202..a770ed2b 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) @@ -1158,6 +1193,180 @@ 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: track_id.clone(), + md5_16kb: md5_16kb.clone(), + }; + let row = cache.get_waveform(&key)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}", + track_id, + md5_16kb, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}", + track_id, + md5_16kb + ); + } + } + Ok(row.map(|v| WaveformCachePayload { + bins: v.bins, + bin_count: v.bin_count, + is_partial: v.is_partial, + known_until_sec: v.known_until_sec, + duration_sec: v.duration_sec, + updated_at: v.updated_at, + })) +} + +#[tauri::command] +fn analysis_get_waveform_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, String> { + let row = cache.get_latest_waveform_for_track(&track_id)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}", + track_id, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss track_id={}", + track_id + ); + } + } + Ok(row.map(|v| WaveformCachePayload { + bins: v.bins, + bin_count: v.bin_count, + is_partial: v.is_partial, + known_until_sec: v.known_until_sec, + duration_sec: v.duration_sec, + updated_at: v.updated_at, + })) +} + +#[tauri::command] +fn analysis_get_loudness_for_track( + track_id: String, + target_lufs: Option, + 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_delete_loudness_for_track( + track_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_loudness_for_track_id(&track_id) +} + +#[tauri::command] +fn analysis_delete_all_waveforms( + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_all_waveforms() +} + +#[tauri::command] +fn analysis_enqueue_seed_from_url( + track_id: String, + 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. @@ -1178,6 +1387,48 @@ 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 { + let outcome = analysis_cache::seed_from_bytes(app, track_id, bytes).map_err(|e| { + crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); + e + })?; + if outcome == analysis_cache::SeedFromBytesOutcome::Upserted { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.to_string(), + is_partial: false, + }, + ); + } + let has_loudness = app + .try_state::() + .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) + .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. @@ -1244,6 +1495,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) } @@ -1792,6 +2045,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()) @@ -1845,6 +2100,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()) @@ -3609,6 +3866,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 @@ -3847,6 +4111,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, @@ -3878,6 +4143,12 @@ 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_delete_loudness_for_track, + analysis_delete_all_waveforms, + 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 b0249dd3..0e4820ba 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,4 +1,5 @@ -import React, { useState, useRef, useMemo, useEffect } from 'react'; +import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useOrbitStore } from '../store/orbitStore'; import OrbitGuestQueue from './OrbitGuestQueue'; @@ -304,6 +305,9 @@ function QueuePanelHostOrSolo() { const suppressNextAutoScrollRef = useRef(false); 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); @@ -313,6 +317,8 @@ function QueuePanelHostOrSolo() { 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); @@ -320,10 +326,17 @@ function QueuePanelHostOrSolo() { const [showRemainingTime, setShowRemainingTime] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); + const [lufsTgtOpen, setLufsTgtOpen] = useState(false); + const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState({}); + const lufsTgtBtnRef = useRef(null); + const lufsTgtMenuRef = useRef(null); const expandReplayGain = useThemeStore(s => s.expandReplayGain); const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain); const crossfadeBtnRef = useRef(null); const crossfadePopoverRef = useRef(null); + const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack); + const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs); + const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs); useEffect(() => { if (!showCrossfadePopover) return; @@ -338,6 +351,64 @@ function QueuePanelHostOrSolo() { return () => document.removeEventListener('mousedown', handle); }, [showCrossfadePopover]); + useEffect(() => { + if (!lufsTgtOpen) return; + const handle = (e: MouseEvent) => { + if ( + lufsTgtBtnRef.current?.contains(e.target as Node) || + lufsTgtMenuRef.current?.contains(e.target as Node) + ) return; + setLufsTgtOpen(false); + }; + document.addEventListener('mousedown', handle); + return () => document.removeEventListener('mousedown', handle); + }, [lufsTgtOpen]); + + const updateLufsTgtPopStyle = () => { + if (!lufsTgtBtnRef.current) return; + const rect = lufsTgtBtnRef.current.getBoundingClientRect(); + const MARGIN = 6; + const WIDTH = 160; + const MAX_H = 220; + const spaceBelow = window.innerHeight - rect.bottom - MARGIN; + const spaceAbove = rect.top - MARGIN; + const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow; + const left = Math.min( + Math.max(rect.right - WIDTH, 8), + window.innerWidth - WIDTH - 8, + ); + setLufsTgtPopStyle({ + position: 'fixed', + left, + width: WIDTH, + ...(useAbove + ? { bottom: window.innerHeight - rect.top + MARGIN } + : { top: rect.bottom + MARGIN }), + maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow), + zIndex: 99998, + }); + }; + + useLayoutEffect(() => { + if (!lufsTgtOpen) return; + updateLufsTgtPopStyle(); + }, [lufsTgtOpen]); + + useEffect(() => { + if (!lufsTgtOpen) return; + const onResize = () => updateLufsTgtPopStyle(); + window.addEventListener('resize', onResize); + window.addEventListener('scroll', onResize, true); + return () => { + window.removeEventListener('resize', onResize); + window.removeEventListener('scroll', onResize, true); + }; + }, [lufsTgtOpen]); + + useEffect(() => { + if (!expandReplayGain) setLufsTgtOpen(false); + }, [expandReplayGain]); + // Tracks which queue index is being psy-dragged for opacity visual feedback const psyDragFromIdxRef = useRef(null); @@ -534,8 +605,15 @@ function QueuePanelHostOrSolo() { const rgParts = formatQueueReplayGainParts(currentTrack, t); const baseLine = baseParts.join(' · '); const rgLine = rgParts.join(' · '); + const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness'; + const liveGainLabel = normalizationNowDb != null + ? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB` + : '—'; + const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs; + const targetLabel = `${tgtNum} LUFS`; if (!baseLine && !rgLine && !playbackSource) return null; - const showRgLine = expandReplayGain && !!rgLine; + const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine; + const showLufsLine = isLoudnessActive && expandReplayGain; return (
@@ -558,7 +636,7 @@ function QueuePanelHostOrSolo() { )} {baseLine && {baseLine}} - {rgLine && ( + {!isLoudnessActive && rgLine && ( )} + {isLoudnessActive && ( + + )}
{showRgLine && ( @@ -578,6 +669,90 @@ function QueuePanelHostOrSolo() { {' · '}{rgLine} )} + {showLufsLine && ( + + Loudness + {' · '} + + {' · '} + TGT + {' · '} + + {lufsTgtOpen && + createPortal( +
e.stopPropagation()} + > + {([-10, -12, -14, -16] as const).map((v) => ( + + ))} +
, + document.body, + )} +
+ )}
); diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 3f710723..274b5b62 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -8,6 +8,8 @@ function fmt(s: number): string { const BAR_COUNT = 500; const SEG_COUNT = 60; +const FLAT_WAVE_NORM = 0.06; +const WAVE_MORPH_MS = 1000; // ── animation state ─────────────────────────────────────────────────────────── @@ -96,6 +98,40 @@ export function makeHeights(trackId: string): Float32Array { // ── draw functions ──────────────────────────────────────────────────────────── +function makeFlatWaveHeights(): Float32Array { + const h = new Float32Array(BAR_COUNT); + h.fill(FLAT_WAVE_NORM); + return h; +} + +function easeOutCubic(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return 1 - Math.pow(1 - x, 3); +} + +function binsToHeights(src: number[]): Float32Array { + const h = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; +} + +function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > eps) return false; + } + return true; +} + +function waveformBarThickness(logicalH: number, norm: number): number { + const safeNorm = Math.max(FLAT_WAVE_NORM, norm); + return Math.max(1, safeNorm * logicalH); +} + function drawWaveform( canvas: HTMLCanvasElement, heights: Float32Array | null, @@ -108,9 +144,38 @@ function drawWaveform( const { played, buffered: buffCol, unplayed } = getColors(); if (!heights) { - ctx.globalAlpha = 0.3; + // No waveform data yet: flat rail like `drawLineDot`, but do not return early + // before played/buffered — otherwise there is no visible playhead. + const cy = h / 2; + const lh = 2; + const dotR = 5; + ctx.globalAlpha = 0.35; ctx.fillStyle = unplayed; - ctx.fillRect(0, (h - 2) / 2, w, 2); + ctx.fillRect(0, cy - lh / 2, w, lh); + if (buffered > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh); + } + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + ctx.shadowBlur = 5; + ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh); + ctx.shadowBlur = 0; + } + ctx.globalAlpha = 1; + if (w > 0) { + const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w)); + ctx.shadowColor = played; + ctx.shadowBlur = 7; + ctx.beginPath(); + ctx.arc(dx, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = played; + ctx.fill(); + ctx.shadowBlur = 0; + } ctx.globalAlpha = 1; return; } @@ -122,7 +187,7 @@ function drawWaveform( ctx.fillStyle = unplayed; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT < buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -132,7 +197,7 @@ function drawWaveform( for (let i = 0; i < BAR_COUNT; i++) { const frac = i / BAR_COUNT; if (frac < progress || frac >= buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -140,29 +205,14 @@ function drawWaveform( if (progress > 0) { ctx.globalAlpha = 1; ctx.fillStyle = played; - ctx.shadowColor = played; - ctx.shadowBlur = 5; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT >= progress) break; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } - ctx.shadowBlur = 0; } ctx.globalAlpha = 1; - - // Fade both edges to transparent using destination-in gradient mask - const fadeW = Math.min(22, w * 0.07); - const mask = ctx.createLinearGradient(0, 0, w, 0); - mask.addColorStop(0, 'transparent'); - mask.addColorStop(fadeW / w, 'black'); - mask.addColorStop(1 - fadeW / w, 'black'); - mask.addColorStop(1, 'transparent'); - ctx.globalCompositeOperation = 'destination-in'; - ctx.fillStyle = mask; - ctx.fillRect(0, 0, w, h); - ctx.globalCompositeOperation = 'source-over'; } function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { @@ -832,6 +882,7 @@ export default function WaveformSeek({ trackId }: Props) { const [hoverPct, setHoverPct] = useState(null); const seek = usePlayerStore(s => s.seek); + const waveformBins = usePlayerStore(s => s.waveformBins); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); @@ -845,8 +896,70 @@ export default function WaveformSeek({ trackId }: Props) { heightsRef.current = null; return; } - heightsRef.current = makeHeights(trackId); - }, [trackId]); + if (waveformBins && waveformBins.length > 0) { + const h = binsToHeights(waveformBins); + const prev = heightsRef.current; + if (!prev || prev.length !== BAR_COUNT) { + heightsRef.current = h; + return; + } + if (heightsNearlyEqual(prev, h, 0.02)) { + heightsRef.current = h; + return; + } + const from = new Float32Array(prev); + const to = h; + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + } + if (heightsRef.current?.length === BAR_COUNT) { + const current = heightsRef.current; + let isAlreadyFlat = true; + for (let i = 0; i < BAR_COUNT; i++) { + if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) { + isAlreadyFlat = false; + break; + } + } + if (isAlreadyFlat) return; + const from = new Float32Array(current); + const to = makeFlatWaveHeights(); + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + } + // No analysis bins yet: render 500 flat bars immediately. + heightsRef.current = makeFlatWaveHeights(); + }, [trackId, waveformBins]); // Imperative subscription — no React re-renders from progress changes. // Static styles draw here; animated styles only update refs. @@ -874,12 +987,17 @@ export default function WaveformSeek({ trackId }: Props) { }); }, []); - // Initial draw for static styles when style or track changes. + // Initial draw for static styles when style, track, or waveform payload changes. useEffect(() => { if (ANIMATED_STYLES.has(seekbarStyle)) return; const canvas = canvasRef.current; if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); - }, [seekbarStyle, trackId]); + }, [ + seekbarStyle, + trackId, + waveformBins, + duration, + ]); // rAF loop — animated styles only. useEffect(() => { diff --git a/src/locales/de.ts b/src/locales/de.ts index 1e026994..ee5d6c94 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -847,6 +847,12 @@ export const deTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getaggte Dateien)', replayGainFallback: 'Fallback (ohne Tags / Radio)', + normalization: 'Normalisierung', + loudnessTargetLufs: 'Ziel-LUFS', + loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.', + loudnessPreAnalysisAttenuationReset: 'Standard', crossfade: 'Crossfade', crossfadeDesc: 'Überblendung zwischen Tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/en.ts b/src/locales/en.ts index bf61a1f1..ae79cfe7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -656,9 +656,12 @@ export const enTranslation = { hotCacheTrackCount: 'Tracks in cache:', cacheMaxLabel: 'Max. size', cacheClearBtn: 'Clear Cache', + waveformCacheClearBtn: 'Clear waveform cache', cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearConfirm: 'Clear Everything', cacheClearCancel: 'Cancel', + waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).', + waveformCacheClearFailed: 'Failed to clear waveform cache.', offlineDirTitle: 'Offline Library (In-App)', offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.', offlineDirDefault: 'Default (App Data)', @@ -850,6 +853,12 @@ export const enTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (tagged files)', replayGainFallback: 'Fallback (untagged / radio)', + normalization: 'Normalization', + loudnessTargetLufs: 'Target LUFS', + loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.', + loudnessPreAnalysisAttenuationReset: 'Reset to default', crossfade: 'Crossfade', crossfadeDesc: 'Fade between tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/es.ts b/src/locales/es.ts index 3da001dd..848ac685 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -840,6 +840,12 @@ export const esTranslation = { replayGainAlbum: 'Álbum', replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)', replayGainFallback: 'Respaldo (sin etiquetar / radio)', + normalization: 'Normalización', + loudnessTargetLufs: 'LUFS objetivo', + loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.', + loudnessPreAnalysisAttenuationReset: 'Predeterminado', crossfade: 'Crossfade', crossfadeDesc: 'Transición entre pistas', crossfadeSecs: '{{n}} s', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 77f4624d..96b25972 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -835,6 +835,12 @@ export const frTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pré-Gain (fichiers taggés)', replayGainFallback: 'Repli (sans tags / radio)', + normalization: 'Normalisation', + loudnessTargetLufs: 'LUFS cible', + loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Réduit le volume tant que la loudness du morceau n’est pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.', + loudnessPreAnalysisAttenuationReset: 'Valeur par défaut', crossfade: 'Fondu enchaîné', crossfadeDesc: 'Fondu entre les pistes', crossfadeSecs: '{{n}} s', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d8434943..6f0dc8c5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -834,6 +834,12 @@ export const nbTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (taggede filer)', replayGainFallback: 'Reserveverdi (uten tagger / radio)', + normalization: 'Normalisering', + loudnessTargetLufs: 'Mål-LUFS', + loudnessPreAnalysisAttenuation: 'Demping før måling (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.', + loudnessPreAnalysisAttenuationReset: 'Standard', crossfade: 'Crossfade', crossfadeDesc: 'Tone mellom spor', crossfadeSecs: '{{n}}s', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 1cfb04ea..22796f55 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -834,6 +834,12 @@ export const nlTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getagde bestanden)', replayGainFallback: 'Terugval (zonder tags / radio)', + normalization: 'Normalisatie', + loudnessTargetLufs: 'Doel-LUFS', + loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.', + loudnessPreAnalysisAttenuationReset: 'Standaard', crossfade: 'Overgang', crossfadeDesc: 'Fade tussen nummers', crossfadeSecs: '{{n}} s', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 22a1e60a..24c4883c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -677,9 +677,12 @@ export const ruTranslation = { hotCacheTrackCount: 'Треков в кэше:', cacheMaxLabel: 'Лимит', cacheClearBtn: 'Очистить кэш', + waveformCacheClearBtn: 'Очистить кэш waveform', cacheClearWarning: 'Будут удалены и все офлайн-альбомы.', cacheClearConfirm: 'Очистить всё', cacheClearCancel: 'Отмена', + waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).', + waveformCacheClearFailed: 'Не удалось очистить кэш waveform.', offlineDirTitle: 'Офлайн-библиотека (в приложении)', offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.', offlineDirDefault: 'По умолчанию (данные приложения)', @@ -883,6 +886,12 @@ export const ruTranslation = { replayGainAlbum: 'По альбому', replayGainPreGain: 'Предусиление (файлы с тегами)', replayGainFallback: 'Резерв (без тегов / радио)', + normalization: 'Нормализация', + loudnessTargetLufs: 'Целевой LUFS', + loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.', + loudnessPreAnalysisAttenuationReset: 'По умолчанию', crossfade: 'Кроссфейд', crossfadeDesc: 'Плавный переход между треками', crossfadeSecs: '{{n}} с', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 780c2307..3da16846 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -829,6 +829,12 @@ export const zhTranslation = { replayGainAlbum: '专辑', replayGainPreGain: '预增益(有标签文件)', replayGainFallback: '回退增益(无标签 / 收音机)', + normalization: '响度归一化', + loudnessTargetLufs: '目标 LUFS', + loudnessPreAnalysisAttenuation: '测量前衰减 (dB)', + loudnessPreAnalysisAttenuationDesc: + '在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。', + loudnessPreAnalysisAttenuationReset: '恢复默认', crossfade: '交叉淡入淡出', crossfadeDesc: '曲目间淡入淡出', crossfadeSecs: '{{n}} 秒', diff --git a/src/main.tsx b/src/main.tsx index 4de98ddf..f6c65bbf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,22 @@ try { // Ignore in non-Tauri runtimes. } +// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the +// user's persisted `loggingMode` until then — but waveform/audio may already +// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`). +try { + const raw = localStorage.getItem('psysonic-auth'); + if (raw) { + const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } }; + const mode = parsed.state?.loggingMode; + if (mode === 'off' || mode === 'normal' || mode === 'debug') { + void invoke('set_logging_mode', { mode }); + } + } +} catch { + // Ignore parse / non-Tauri. +} + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ec9d705f..3d7f5f51 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; import { useHotCacheStore } from '../store/hotCacheStore'; +import { usePlayerStore } from '../store/playerStore'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; @@ -25,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; -import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; +import { + useAuthStore, + DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, + ServerProfile, + MIX_MIN_RATING_FILTER_MAX_STARS, + type SeekbarStyle, + type LyricsSourceId, + type LyricsSourceConfig, + type LoggingMode, + type LoudnessLufsPreset, +} from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; @@ -64,6 +75,29 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; +const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16]; + +function LoudnessLufsButtonGroup(props: { + value: LoudnessLufsPreset; + onSelect: (v: LoudnessLufsPreset) => void; +}) { + return ( +
+ {LOUDNESS_LUFS_BUTTON_ORDER.map(v => ( + + ))} +
+ ); +} + const CONTRIBUTORS = [ { github: 'jiezhuo', @@ -1788,6 +1822,26 @@ export default function Settings() { setClearing(false); }, [clearAllOffline, serverId]); + const handleClearWaveformCache = useCallback(async () => { + setClearing(true); + try { + const deleted = await invoke('analysis_delete_all_waveforms'); + usePlayerStore.setState({ + waveformBins: null, + }); + showToast( + t('settings.waveformCacheCleared', { count: deleted }), + 3500, + 'success', + ); + } catch (e) { + console.error(e); + showToast(t('settings.waveformCacheClearFailed'), 4500, 'error'); + } finally { + setClearing(false); + } + }, [t]); + const startLastfmConnect = useCallback(async () => { setLfmError(null); let token: string; @@ -2217,19 +2271,89 @@ export default function Settings() { icon={} >
- {/* Replay Gain */} + {/* Normalization */}
-
-
{t('settings.replayGain')}
-
{t('settings.replayGainDesc')}
+
{t('settings.normalization', { defaultValue: 'Normalization' })}
+
+ + +
-
- {auth.replayGainEnabled && ( + {auth.normalizationEngine === 'loudness' && ( +
+
+ + {t('settings.loudnessTargetLufs')} + + +
+
+ + {t('settings.loudnessPreAnalysisAttenuation')} + + auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + /> + + {auth.loudnessPreAnalysisAttenuationDb} dB + + +
+
+ {t('settings.loudnessPreAnalysisAttenuationDesc')} +
+
+ )} + {auth.normalizationEngine !== 'off' && (
+ {auth.normalizationEngine === 'replaygain' && (
{t('settings.replayGainMode')}:
- {auth.replayGainMode === 'auto' && ( + )} + {auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
{t('settings.replayGainAutoDesc')}
)}
)} - {auth.replayGainEnabled && ( + {auth.normalizationEngine === 'replaygain' && (
@@ -2964,6 +3089,16 @@ export default function Settings() { {t('settings.cacheClearBtn')} )} +
+ +
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 4563b96d..a2593c2f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -20,6 +20,28 @@ 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'; + +/** Integrated-loudness target presets (Settings + analysis). */ +export type LoudnessLufsPreset = -16 | -14 | -12 | -10; + +const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10]; + +/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */ +export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5; + +function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset { + return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number) + ? (v as LoudnessLufsPreset) + : fallback; +} + +function clampLoudnessPreAnalysisAttenuationDb(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB; + const stepped = Math.round(n * 2) / 2; + return Math.max(-24, Math.min(0, stepped)); +} export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } @@ -49,6 +71,10 @@ interface AuthState { excludeAudiobooks: boolean; customGenreBlacklist: string[]; replayGainEnabled: boolean; + normalizationEngine: NormalizationEngine; + loudnessTargetLufs: LoudnessLufsPreset; + /** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */ + loudnessPreAnalysisAttenuationDb: number; replayGainMode: 'track' | 'album' | 'auto'; replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) @@ -208,6 +234,10 @@ interface AuthState { setExcludeAudiobooks: (v: boolean) => void; setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; + setNormalizationEngine: (v: NormalizationEngine) => void; + setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void; + setLoudnessPreAnalysisAttenuationDb: (v: number) => void; + resetLoudnessPreAnalysisAttenuationDbDefault: () => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; @@ -319,6 +349,9 @@ export const useAuthStore = create()( excludeAudiobooks: false, customGenreBlacklist: [], replayGainEnabled: false, + normalizationEngine: 'off', + loudnessTargetLufs: -12, + loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, @@ -447,6 +480,21 @@ 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(); + }, + setLoudnessPreAnalysisAttenuationDb: (v) => { + set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) }); + }, + resetLoudnessPreAnalysisAttenuationDbDefault: () => { + set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, setReplayGainMode: (v) => { set({ replayGainMode: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); @@ -681,6 +729,10 @@ export const useAuthStore = create()( } catch { /* ignore */ } } + const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown }; + const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12); + const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb); + useAuthStore.setState({ mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), @@ -688,6 +740,8 @@ export const useAuthStore = create()( skipStarManualSkipCountsByKey: sanitizeSkipStarCounts( (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, ), + loudnessTargetLufs: targetSan, + loudnessPreAnalysisAttenuationDb: preSan, ...conflictingLegacyState, ...lyricsSourcesMigrated, ...wheelSmoothOneTime, diff --git a/src/store/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 bc52056e..61b2d9b9 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'; import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { useOrbitStore } from './orbitStore'; import { estimateLivePosition } from '../api/orbit'; @@ -146,6 +147,16 @@ async function buildInfiniteQueueCandidates( interface PlayerState { currentTrack: Track | null; + waveformBins: number[] | null; + normalizationNowDb: number | null; + normalizationTargetLufs: number | null; + normalizationEngineLive: 'off' | 'replaygain' | 'loudness'; + normalizationDbgSource: string | null; + normalizationDbgTrackId: string | null; + normalizationDbgCacheGainDb: number | null; + normalizationDbgCacheTargetLufs: number | null; + normalizationDbgCacheUpdatedAt: number | null; + normalizationDbgLastEventAt: number | null; currentRadio: InternetRadioStation | null; /** Latches the source used to start the currently playing track. */ currentPlaybackSource: PlaybackSourceKind | null; @@ -195,8 +206,9 @@ interface PlayerState { next: (manual?: boolean) => void; previous: () => void; seek: (progress: number) => void; - setVolume: (v: number) => void; + setVolume: (v: number) => void; updateReplayGainForCurrentTrack: () => void; + reanalyzeLoudnessForTrack: (trackId: string) => Promise; setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; @@ -248,6 +260,51 @@ interface PlayerState { closeSongInfo: () => void; } +type WaveformCachePayload = { + /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ + bins: number[] | Uint8Array; + binCount: number; + isPartial: boolean; + knownUntilSec: number; + durationSec: number; + updatedAt: number; +}; + +/** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ +function coerceWaveformBins(bins: unknown): number[] | null { + if (bins == null) return null; + if (Array.isArray(bins)) { + return bins.length > 0 ? bins.map(x => Number(x) & 255) : null; + } + if (bins instanceof Uint8Array) { + return bins.length > 0 ? Array.from(bins) : null; + } + if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') { + const len = (bins as { length: number }).length; + if (len === 0) return null; + try { + return Array.from(bins as ArrayLike).map(x => Number(x) & 255); + } catch { + return null; + } + } + return null; +} + +type LoudnessCachePayload = { + integratedLufs: number; + truePeak: number; + 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. @@ -266,6 +323,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; @@ -274,6 +391,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; @@ -488,6 +608,185 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) { useHotCacheStore.getState().touchPlayed(trackId, serverId); } +function isReplayGainActive() { + const a = useAuthStore.getState(); + return a.normalizationEngine === 'replaygain' && a.replayGainEnabled; +} + +function loudnessCacheStateKeysForTrackId(trackId: string): string[] { + if (!trackId) return []; + const out: string[] = [trackId]; + if (trackId.startsWith('stream:')) { + const bare = trackId.slice('stream:'.length); + if (bare) out.push(bare); + } else { + out.push(`stream:${trackId}`); + } + return out; +} + +function clearLoudnessCacheStateForTrackId(trackId: string) { + for (const k of loudnessCacheStateKeysForTrackId(trackId)) { + delete cachedLoudnessGainByTrackId[k]; + delete stableLoudnessGainByTrackId[k]; + } +} + +/** Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit partial hints so Rust uses pre-trim until `analysis:loudness-partial` + `audio_update_replay_gain`. */ +function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null { + if (!trackId) return null; + if (!stableLoudnessGainByTrackId[trackId]) return null; + const v = cachedLoudnessGainByTrackId[trackId]; + return Number.isFinite(v) ? v : null; +} + +function resetLoudnessBackfillStateForTrackId(trackId: string) { + for (const k of loudnessCacheStateKeysForTrackId(trackId)) { + delete analysisBackfillInFlightByTrackId[k]; + analysisBackfillAttemptsByTrackId[k] = 0; + } +} + +async function reseedLoudnessForTrackId(trackId: string) { + if (!trackId) return; + const auth = useAuthStore.getState(); + if (auth.normalizationEngine !== 'loudness') return; + clearLoudnessCacheStateForTrackId(trackId); + resetLoudnessBackfillStateForTrackId(trackId); + if (auth.normalizationEngine === 'loudness') { + usePlayerStore.setState({ + normalizationNowDb: null, + normalizationTargetLufs: auth.loudnessTargetLufs, + normalizationEngineLive: 'loudness', + }); + } + try { + await invoke('analysis_delete_loudness_for_track', { trackId }); + } catch (e) { + console.error('[psysonic] analysis_delete_loudness_for_track failed:', e); + } + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + const url = buildStreamUrl(trackId); + try { + await invoke('analysis_enqueue_seed_from_url', { trackId, url }); + } catch (e) { + console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e); + } +} + +async function refreshWaveformForTrack(trackId: string) { + if (!trackId) return; + try { + const row = await invoke('analysis_get_waveform_for_track', { trackId }); + // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). + if (usePlayerStore.getState().currentTrack?.id !== trackId) return; + const bins = row ? coerceWaveformBins(row.bins) : null; + if (!bins || bins.length === 0) { + usePlayerStore.setState({ + waveformBins: null, + }); + return; + } + usePlayerStore.setState({ + waveformBins: bins, + }); + } catch { + // best-effort; seekbar falls back to placeholder waveform + } +} + +/** When `syncPlayingEngine` is false, only update `cachedLoudnessGainByTrackId` (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */ +async function refreshLoudnessForTrack( + trackId: string, + opts?: { syncPlayingEngine?: boolean }, +) { + if (!trackId) return; + const syncEngine = opts?.syncPlayingEngine !== false; + emitNormalizationDebug('refresh:start', { trackId }); + usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId }); + try { + const row = await invoke('analysis_get_loudness_for_track', { + trackId, + targetLufs: useAuthStore.getState().loudnessTargetLufs, + }); + if (!row || !Number.isFinite(row.recommendedGainDb)) { + delete cachedLoudnessGainByTrackId[trackId]; + delete stableLoudnessGainByTrackId[trackId]; + emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null }); + const auth = useAuthStore.getState(); + const attempts = analysisBackfillAttemptsByTrackId[trackId] ?? 0; + if (auth.normalizationEngine === 'loudness' + && !analysisBackfillInFlightByTrackId[trackId] + && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { + analysisBackfillInFlightByTrackId[trackId] = true; + analysisBackfillAttemptsByTrackId[trackId] = attempts + 1; + const url = buildStreamUrl(trackId); + emitNormalizationDebug('backfill:enqueue', { trackId, url, attempt: attempts + 1 }); + void invoke('analysis_enqueue_seed_from_url', { trackId, url }) + .then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 })) + .catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) })) + .finally(() => { + delete analysisBackfillInFlightByTrackId[trackId]; + }); + } else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) { + emitNormalizationDebug('backfill:throttled', { trackId, attempts }); + } + usePlayerStore.setState({ + normalizationDbgSource: 'refresh:miss', + normalizationDbgTrackId: trackId, + normalizationDbgCacheGainDb: null, + normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null, + normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null, + }); + return; + } + cachedLoudnessGainByTrackId[trackId] = row.recommendedGainDb; + stableLoudnessGainByTrackId[trackId] = true; + analysisBackfillAttemptsByTrackId[trackId] = 0; + emitNormalizationDebug('refresh:hit', { trackId, row }); + usePlayerStore.setState({ + normalizationDbgSource: 'refresh:hit', + normalizationDbgTrackId: trackId, + normalizationDbgCacheGainDb: row.recommendedGainDb, + normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null, + normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null, + }); + if (syncEngine) { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } + } catch { + delete cachedLoudnessGainByTrackId[trackId]; + delete stableLoudnessGainByTrackId[trackId]; + emitNormalizationDebug('refresh:error', { trackId }); + usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId }); + } +} + +/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */ +const LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS = 40; + +function prefetchLoudnessForEnqueuedTracks( + incoming: Track[], + mergedQueue: Track[], + queueIndex: number, +) { + if (useAuthStore.getState().normalizationEngine !== 'loudness') return; + const ids = new Set(); + const next = mergedQueue[queueIndex + 1]; + if (next?.id) ids.add(next.id); + let n = 0; + for (const t of incoming) { + if (n >= LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS) break; + if (t?.id) { + ids.add(t.id); + n++; + } + } + for (const id of ids) { + void refreshLoudnessForTrack(id, { syncPlayingEngine: false }); + } +} + async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) { try { const res = await invoke<{ path: string; size: number } | null>( @@ -648,6 +947,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; + // Loudness cache only — do not call refreshWaveformForTrack(next): it writes global + // waveformBins and would replace the current track's seekbar while still playing it. + void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); if (import.meta.env.DEV) { console.info('[psysonic][preload-request]', { nextTrackId: nextTrack.id, @@ -658,12 +960,18 @@ function handleAudioProgress(current_time: number, duration: number) { gaplessEnabled, }); } - invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); + invoke('audio_preload', { + url: nextUrl, + durationHint: nextTrack.duration, + analysisTrackId: nextTrack.id, + }).catch(() => {}); } // Gapless chain — decode + chain into Sink 30s before track boundary. 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. @@ -672,9 +980,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', { @@ -683,9 +991,11 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, + loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, + analysisTrackId: nextTrack.id, }).catch(() => {}); } } @@ -707,7 +1017,12 @@ 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, + }); setTimeout(() => { if (repeatMode === 'one' && currentTrack) { usePlayerStore.getState().playTrack(currentTrack, queue, false); @@ -751,6 +1066,10 @@ function handleAudioTrackSwitched(duration: number) { usePlayerStore.setState({ currentTrack: nextTrack, + waveformBins: null, + ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), + normalizationDbgSource: 'track-switched', + normalizationDbgTrackId: nextTrack.id, queueIndex: newIndex, isPlaying: true, progress: 0, @@ -759,6 +1078,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(); @@ -824,6 +1151,83 @@ 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; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { + const current = usePlayerStore.getState().currentTrack; + if (!current || !payload) return; + const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); + if (payloadTrackId && payloadTrackId !== current.id) return; + if (!Number.isFinite(payload.gainDb)) return; + if (stableLoudnessGainByTrackId[current.id]) return; + cachedLoudnessGainByTrackId[current.id] = payload.gainDb; + emitNormalizationDebug('partial-loudness:apply', { + trackId: current.id, + gainDb: payload.gainDb, + targetLufs: payload.targetLufs, + }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }), + listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => { + if (!payload?.trackId) return; + const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); + if (!payloadTrackId) return; + const currentRaw = usePlayerStore.getState().currentTrack?.id; + const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null; + if (currentId && payloadTrackId === currentId) { + void refreshWaveformForTrack(currentRaw!); + void refreshLoudnessForTrack(currentId); + emitNormalizationDebug('backfill:applied', { trackId: currentId }); + return; + } + // Backfill finished for another id (e.g. next in queue): refresh loudness cache only + // so `cachedLoudnessGainByTrackId` is ready before `audio_play` / gapless chain. + void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false }); + emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId }); + }), + listen('audio:normalization-state', ({ payload }) => { + if (!payload) return; + const engine = + payload.engine === 'loudness' || payload.engine === 'replaygain' + ? payload.engine + : 'off'; + const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null; + const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null; + const prev = usePlayerStore.getState(); + // Avoid UI flicker from noisy duplicate emits and transient nulls. + if ( + engine === prev.normalizationEngineLive + && normalizationAlmostEqual(nowDb, prev.normalizationNowDb) + && normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02) + ) { + return; + } + if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) { + return; + } + const nowMs = Date.now(); + const isFirstNumericGain = + engine === 'loudness' + && nowDb != null + && prev.normalizationNowDb == null; + if ( + !isFirstNumericGain + && nowMs - lastNormalizationUiUpdateAtMs < 120 + && engine === prev.normalizationEngineLive + ) { + return; + } + lastNormalizationUiUpdateAtMs = nowMs; + emitNormalizationDebug('event:audio:normalization-state', { + trackId: usePlayerStore.getState().currentTrack?.id ?? null, + payload, + }); + usePlayerStore.setState({ + normalizationEngineLive: engine, + normalizationNowDb: nowDb, + normalizationTargetLufs: targetLufs, + normalizationDbgSource: 'event:audio:normalization-state', + normalizationDbgLastEventAt: Date.now(), + }); + }), listen('audio:preload-ready', ({ payload }) => { const tid = streamUrlTrackId(payload); if (import.meta.env.DEV) { @@ -847,17 +1251,98 @@ 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, + preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb, + }).catch(() => {}); + const bootTrackId = usePlayerStore.getState().currentTrack?.id; + if (bootTrackId) { + void refreshWaveformForTrack(bootTrackId); + } + if (normCfg.normalizationEngine === 'loudness') { + const currentId = usePlayerStore.getState().currentTrack?.id; + if (currentId) { + void refreshLoudnessForTrack(currentId).finally(() => { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }); + } + } if (audioOutputDevice) { invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {}); } // Keep audio settings in sync whenever auth store changes. + let prevNormEngine = normCfg.normalizationEngine; + let prevNormTarget = normCfg.loudnessTargetLufs; + let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb; const unsubAuth = useAuthStore.subscribe((state) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, secs: state.crossfadeSecs, }).catch(() => {}); invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); + const normChanged = + state.normalizationEngine !== prevNormEngine + || state.loudnessTargetLufs !== prevNormTarget + || state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; + if (!normChanged) return; + const onlyPreAnalysisChanged = + state.normalizationEngine === prevNormEngine + && state.loudnessTargetLufs === prevNormTarget + && state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; + prevNormEngine = state.normalizationEngine; + prevNormTarget = state.loudnessTargetLufs; + prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb; + usePlayerStore.setState({ + normalizationEngineLive: state.normalizationEngine, + normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null, + normalizationNowDb: state.normalizationEngine === 'loudness' + ? usePlayerStore.getState().normalizationNowDb + : null, + normalizationDbgSource: 'auth:normalization-changed', + }); + emitNormalizationDebug('auth:normalization-changed', { + engine: state.normalizationEngine, + targetLufs: state.loudnessTargetLufs, + currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null, + }); + invoke('audio_set_normalization', { + engine: state.normalizationEngine, + targetLufs: state.loudnessTargetLufs, + preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb, + }).catch(() => {}); + if (state.normalizationEngine === 'loudness') { + const currentId = usePlayerStore.getState().currentTrack?.id; + if (onlyPreAnalysisChanged) { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } else if (currentId) { + void refreshLoudnessForTrack(currentId).finally(() => { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }); + } + } else { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } + }); + const unsubAnalysisSync = onAnalysisStorageChanged(detail => { + const currentId = usePlayerStore.getState().currentTrack?.id; + if (!currentId) return; + if (detail.trackId && detail.trackId !== currentId) return; + void refreshWaveformForTrack(currentId); + void refreshLoudnessForTrack(currentId); }); // ── MPRIS / OS media controls sync ─────────────────────────────────────── @@ -1019,6 +1504,7 @@ export function initAudioListeners(): () => void { return () => { unsubAuth(); + unsubAnalysisSync(); unsubMpris(); unsubDiscordPlayer(); unsubDiscordAuth(); @@ -1033,6 +1519,16 @@ export const usePlayerStore = create()( persist( (set, get) => ({ currentTrack: null, + waveformBins: null, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', + normalizationDbgSource: null, + normalizationDbgTrackId: null, + normalizationDbgCacheGainDb: null, + normalizationDbgCacheTargetLufs: null, + normalizationDbgCacheUpdatedAt: null, + normalizationDbgLastEventAt: null, currentRadio: null, currentPlaybackSource: null, enginePreloadedTrackId: null, @@ -1159,6 +1655,10 @@ export const usePlayerStore = create()( buffered: 0, currentTime: 0, currentRadio: null, + waveformBins: null, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', currentPlaybackSource: null, enginePreloadedTrackId: null, scheduledPauseAtMs: null, @@ -1199,6 +1699,10 @@ export const usePlayerStore = create()( set({ currentRadio: station, currentTrack: null, + waveformBins: null, + normalizationNowDb: null, + normalizationTargetLufs: null, + normalizationEngineLive: 'off', currentPlaybackSource: null, queue: [], queueIndex: 0, @@ -1306,6 +1810,8 @@ export const usePlayerStore = create()( set({ currentTrack: track, currentRadio: null, + waveformBins: null, + ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), queue: newQueue, queueIndex: idx >= 0 ? idx : 0, progress: initialProgress, @@ -1330,24 +1836,28 @@ 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: loudnessGainDbForEngineBind(track.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, manual, hiResEnabled: authState.enableHiRes, + analysisTrackId: track.id, }) .then(() => { if (playGeneration !== gen) return; @@ -1511,9 +2021,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); @@ -1524,10 +2034,12 @@ export const usePlayerStore = create()( durationHint: trackToPlay.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, + loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: trackToPlay.id, }).then(() => { if (playGeneration === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); @@ -1545,9 +2057,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); @@ -1558,10 +2070,12 @@ export const usePlayerStore = create()( durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, + loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: currentTrack.id, }).catch((err: unknown) => { if (playGeneration !== gen) return; setDeferHotCachePrefetch(false); @@ -1851,6 +2365,7 @@ export const usePlayerStore = create()( ...state.queue.slice(firstAutoIdx), ]; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex); return { queue: newQueue }; }); }, @@ -1897,6 +2412,7 @@ export const usePlayerStore = create()( ? state.queueIndex + tracks.length : state.queueIndex; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex); return { queue: newQueue, queueIndex: newQueueIndex }; }); }, @@ -1988,12 +2504,22 @@ export const usePlayerStore = create()( currentTrack, currentTime: serverTime > 0 ? serverTime : localTime, }); + void refreshWaveformForTrack(currentTrack.id); } } catch (e) { console.error('Failed to initialize queue from server', e); } }, + reanalyzeLoudnessForTrack: async (trackId: string) => { + try { + showToast('Recalculating loudness for this track…', 2000, 'info'); + } catch { + // no-op + } + await reseedLoudnessForTrackId(trackId); + }, + updateReplayGainForCurrentTrack: () => { const { currentTrack, queue, queueIndex, volume } = get(); if (!currentTrack || !currentTrack.id) return; @@ -2002,16 +2528,28 @@ 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); + const cachedLoud = cachedLoudnessGainByTrackId[currentTrack.id]; + const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud : null; + set(prevState => ({ + normalizationNowDb: + normalization.normalizationEngineLive === 'loudness' + ? (prevState.normalizationNowDb ?? cachedLoudDb) + : normalization.normalizationNowDb, + normalizationTargetLufs: normalization.normalizationTargetLufs, + normalizationEngineLive: normalization.normalizationEngineLive, + })); + invoke('audio_update_replay_gain', { volume, replayGainDb, replayGainPeak, + loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null, preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, }).catch(console.error); diff --git a/src/styles/layout.css b/src/styles/layout.css index d1e4c62b..32d3528f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1989,6 +1989,24 @@ html[data-platform="windows"] .player-bar.floating { text-overflow: ellipsis; } +.queue-current-tech-metric { + margin: 0; + padding: 0; + border: none; + background: transparent; + font: inherit; + letter-spacing: inherit; + line-height: inherit; + color: inherit; + cursor: pointer; + text-decoration: none; + border-radius: 2px; +} + +.queue-current-tech-metric:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + .queue-divider { padding: var(--space-3) var(--space-4) 0; flex-shrink: 0;