diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index f3a57486..429d796a 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::io::Cursor; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use ebur128::{EbuR128, Mode as Ebur128Mode}; use rusqlite::{params, Connection, OptionalExtension}; @@ -315,51 +315,122 @@ pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_ recommended_gain_db.clamp(-24.0, 24.0) } -pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<(), String> { +/// Result of [`seed_from_bytes`]: callers use it to avoid redundant UI events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeedFromBytesOutcome { + /// Wrote waveform (and loudness when PCM decode succeeded). + Upserted, + /// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version. + SkippedWaveformCacheHit, + /// `AnalysisCache` was not registered on the app handle. + SkippedNoAnalysisCache, +} + +pub fn seed_from_bytes( + app: &tauri::AppHandle, + track_id: &str, + bytes: &[u8], +) -> Result { + let started = Instant::now(); let Some(cache) = app.try_state::() else { - return Ok(()); + crate::app_deprintln!( + "[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}", + track_id, + bytes.len() + ); + return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache); }; let key = TrackKey { track_id: track_id.to_string(), md5_16kb: md5_first_16kb(bytes), }; - cache.touch_track_status(&key, "queued")?; - - let wf_bins: Vec; - let loudness_opt: Option<(f64, f64, f64, f64)>; - match analyze_loudness_and_waveform(bytes, -16.0, 500) { - Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { - wf_bins = bins; - loudness_opt = Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)); - } - None => { - wf_bins = derive_waveform_bins(bytes, 500); - loudness_opt = None; + if let Some(existing) = cache.get_waveform(&key)? { + if !existing.bins.is_empty() { + crate::app_deprintln!( + "[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}", + track_id, + key.md5_16kb, + existing.bins.len(), + started.elapsed().as_millis() + ); + return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit); } } - let waveform = WaveformEntry { - bins: wf_bins, - bin_count: 500, - is_partial: false, - known_until_sec: 0.0, - duration_sec: 0.0, - updated_at: now_unix_ts(), - }; - cache.upsert_waveform(&key, &waveform)?; + crate::app_deprintln!( + "[analysis][waveform] build start track_id={} bytes={} md5_16kb={}", + track_id, + bytes.len(), + key.md5_16kb + ); - if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt { - let loudness = LoudnessEntry { - integrated_lufs, - true_peak, - recommended_gain_db, - target_lufs, + let build = (|| -> Result<(bool, usize), String> { + cache.touch_track_status(&key, "queued")?; + + let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) { + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { + ( + bins, + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)), + true, + ) + } + None => (derive_waveform_bins(bytes, 500), None, false), + }; + let bins_len = wf_bins.len(); + let waveform = WaveformEntry { + bins: wf_bins, + bin_count: 500, + is_partial: false, + known_until_sec: 0.0, + duration_sec: 0.0, updated_at: now_unix_ts(), }; - cache.upsert_loudness(&key, &loudness)?; + cache.upsert_waveform(&key, &waveform)?; + + if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt { + let loudness = LoudnessEntry { + integrated_lufs, + true_peak, + recommended_gain_db, + target_lufs, + updated_at: now_unix_ts(), + }; + cache.upsert_loudness(&key, &loudness)?; + } + + cache.touch_track_status(&key, "ready")?; + Ok((used_pcm_decode, bins_len)) + })(); + + let elapsed_ms = started.elapsed().as_millis(); + match &build { + Ok((used_pcm_decode, bins_len)) => { + crate::app_deprintln!( + "[analysis][waveform] build done track_id={} elapsed_ms={} path={} bins_len={}", + track_id, + elapsed_ms, + if *used_pcm_decode { + "pcm_ebur128" + } else { + "byte_envelope" + }, + bins_len + ); + } + Err(e) => { + crate::app_deprintln!( + "[analysis][waveform] build failed track_id={} elapsed_ms={} err={}", + track_id, + elapsed_ms, + e + ); + } } - cache.touch_track_status(&key, "ready")?; - Ok(()) + match build { + Ok(_) => Ok(SeedFromBytesOutcome::Upserted), + Err(e) => Err(e), + } } fn now_unix_ts() -> i64 { @@ -399,20 +470,6 @@ struct PcmScanResult { loudness: Option<(f64, f64, f64, f64)>, } -/// Decoded PCM peak envelope (mono mix) → `bin_count` bars, for seekbar display. -/// Two decode passes: count frames, then fill bins. Returns `None` if probing/decoding fails. -pub fn derive_waveform_bins_from_audio_bytes(bytes: &[u8], bin_count: usize) -> Option> { - if bytes.is_empty() || bin_count == 0 { - return None; - } - let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?; - if decoded_frames == 0 { - return None; - } - let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, None)?; - Some(scanned.bins) -} - /// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count. fn analyze_loudness_and_waveform( bytes: &[u8], diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 3f85cbd7..cc1793e4 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -22,16 +22,6 @@ use symphonia::core::{ }; use futures_util::StreamExt; use tauri::{AppHandle, Emitter, Manager, State}; -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct PartialWaveformPayload { - track_id: Option, - bins: Vec, - known_until_sec: f64, - duration_sec: f64, - is_partial: bool, -} - #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct PartialLoudnessPayload { @@ -1062,6 +1052,8 @@ async fn track_download_task( done: Arc, promote_cache_slot: Arc>>, normalization_target_lufs: Arc, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { let mut downloaded: u64 = 0; let mut reconnects: u32 = 0; @@ -1161,7 +1153,11 @@ async fn track_download_task( && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs); + let pre_db = f32::from_bits( + loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0); + emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db); last_partial_loudness_emit = Instant::now(); } offset += pushed; @@ -1170,14 +1166,19 @@ async fn track_download_task( } } if !capture_over_limit && !capture.is_empty() { - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { - crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + if let Some(track_id) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) { + Err(e) => crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { @@ -1199,7 +1200,7 @@ async fn ranged_download_task( gen_arc: Arc, http_client: reqwest::Client, app: AppHandle, - duration_hint: f64, + _duration_hint: f64, url: String, initial_response: reqwest::Response, buf: Arc>>, @@ -1207,6 +1208,8 @@ async fn ranged_download_task( done: Arc, promote_cache_slot: Arc>>, normalization_target_lufs: Arc, + loudness_pre_analysis_attenuation_db: Arc, + cache_track_id: Option, ) { let total_size = buf.lock().unwrap().len(); let mut downloaded: usize = 0; @@ -1214,8 +1217,6 @@ async fn ranged_download_task( let mut next_response: Option = Some(initial_response); let dl_started = Instant::now(); let mut next_progress_mb: usize = 1; - let mut last_partial_emit = Instant::now(); - let mut last_partial_emit_downloaded: usize = 0; let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { @@ -1291,55 +1292,27 @@ async fn ranged_download_task( } downloaded += n; downloaded_to.store(downloaded, Ordering::SeqCst); - if downloaded >= 4096 + if downloaded >= PARTIAL_LOUDNESS_MIN_BYTES && total_size > 0 - && downloaded.saturating_sub(last_partial_emit_downloaded) >= PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA - && last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded) + && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - // Keep streaming path lightweight: avoid expensive PCM decode - // in the hot loop (it can steal CPU and cause audible underruns - // while playback is starting). We emit a fast sampled waveform - // here and let full analysis/backfill produce final bins later. - let bins = derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); - if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) { - let _ = app.emit( - "analysis:loudness-partial", - PartialLoudnessPayload { - track_id: playback_identity(&url), - gain_db: provisional_db, - target_lufs, - is_partial: true, - }, - ); - crate::app_deprintln!( - "[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}", - (downloaded as f32 / total_size as f32) * 100.0, - provisional_db, + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed)) + .clamp(-24.0, 0.0); + if let Some(provisional_db) = + provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db) + { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, target_lufs, - playback_identity(&url) - ); - } - last_partial_loudness_emit = Instant::now(); - }; - let known_until_sec = if duration_hint > 0.0 { - (duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint) - } else { - 0.0 - }; - let _ = app.emit( - "analysis:waveform-partial", - PartialWaveformPayload { - track_id: playback_identity(&url), - bins, - known_until_sec, - duration_sec: duration_hint.max(0.0), - is_partial: true, - }, - ); - last_partial_emit = Instant::now(); - last_partial_emit_downloaded = downloaded; + is_partial: true, + }, + ); + } + last_partial_loudness_emit = Instant::now(); } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { @@ -1372,14 +1345,19 @@ async fn ranged_download_task( if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { let data = buf.lock().unwrap().clone(); - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { - crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + if let Some(track_id) = cache_track_id { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); @@ -1387,86 +1365,13 @@ async fn ranged_download_task( } } -/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more -/// to summarize, so we slow emits and keep UI responsive without CPU spikes. -fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration { - const MB: usize = 1024 * 1024; - if downloaded <= 3 * MB { - Duration::from_millis(260) - } else if downloaded <= 10 * MB { - Duration::from_millis(620) - } else { - Duration::from_millis(980) - } -} - -/// Max centered-byte samples examined per bin for partial waveforms (full track -/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap). -const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048; - -fn peak_centered_byte_sampled(region: &[u8]) -> u8 { - if region.is_empty() { - return 0; - } - let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP; - let mut peak: u8 = 0; - if region.len() <= cap { - for &b in region { - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - } - } else { - let step = (region.len() / cap).max(1); - let mut i = 0; - while i < region.len() { - let b = region[i]; - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - i = i.saturating_add(step); - } - let b = region[region.len() - 1]; - let centered = if b >= 128 { b - 128 } else { 128 - b }; - if centered > peak { - peak = centered; - } - } - peak -} - -/// Partial waveform without cloning the whole download buffer and without -/// holding `buf` locked across all bins (that would stall the decoder's `read()`). -fn derive_partial_waveform_bins_short_locks( - buf: &Arc>>, - downloaded: usize, - bin_count: usize, -) -> Vec { - if downloaded == 0 || bin_count == 0 { - return Vec::new(); - } - let len = downloaded; - let mut out = vec![0u8; bin_count]; - for (i, slot) in out.iter_mut().enumerate() { - let start = i * len / bin_count; - let end = ((i + 1) * len / bin_count).max(start + 1).min(len); - let peak = { - let b = buf.lock().unwrap(); - if start >= b.len() { - 0u8 - } else { - let end = end.min(b.len()); - peak_centered_byte_sampled(&b[start..end]) - } - }; - *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; - } - out -} - -fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) { +fn emit_partial_loudness_from_bytes( + app: &AppHandle, + url: &str, + bytes: &[u8], + target_lufs: f32, + pre_analysis_attenuation_db: f32, +) { if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES { crate::app_deprintln!( "[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}", @@ -1477,7 +1382,17 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta } // Lightweight fallback based on buffered bytes count to keep CPU low. let mb = bytes.len() as f32 / (1024.0 * 1024.0); - let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5); + let pre_floor = pre_analysis_attenuation_db.clamp(-24.0, 0.0); + // Target-derived hint (e.g. -12 LUFS → -1 dB). Old `(hint).clamp(pre, 0)` left + // the hint when it lay inside [pre, 0] — e.g. -1 with pre=-6, so AAC/M4A + // streaming often sat at -1 dB until full analysis. Combine with user trim: + // stricter (more negative) pre wins; milder pre still caps vs the hint. + let heuristic_floor = (target_lufs + 11.0).clamp(-6.0, 0.0); + let floor_db = if pre_floor < heuristic_floor { + pre_floor + } else { + pre_floor.max(heuristic_floor) + }; let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0); crate::app_deprintln!( "[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}", @@ -1497,14 +1412,19 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta ); } -fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option { +fn provisional_loudness_gain_from_progress( + downloaded: usize, + total_size: usize, + target_lufs: f32, + start_db_in: f32, +) -> Option { if total_size == 0 || downloaded == 0 { return None; } let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0); // Move from startup attenuation toward a more realistic late-stream level. // This avoids staying near -2 dB and then jumping hard when final LUFS lands. - let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0); + let start_db = start_db_in.clamp(-24.0, 0.0).min(0.0); let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0); let shaped = progress.powf(0.75); Some(start_db + (end_db - start_db) * shaped) @@ -2246,6 +2166,8 @@ pub struct AudioEngine { pub normalization_engine: Arc, /// Target loudness in LUFS for loudness engine (future use). pub normalization_target_lufs: Arc, + /// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings). + pub loudness_pre_analysis_attenuation_db: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. pub(crate) chained_info: Arc>>, @@ -2266,6 +2188,9 @@ pub struct AudioEngine { /// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null` /// (otherwise `compute_gain` would treat that as unity gain and playback "jumps"). pub(crate) current_playback_url: Arc>>, + /// Subsonic song id last passed from JS with `audio_play` (trimmed). Used + /// for loudness/waveform cache when the URL is `psysonic-local://…`. + pub(crate) current_analysis_track_id: Arc>>, } pub struct AudioCurrent { @@ -2531,6 +2456,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { gapless_enabled: Arc::new(AtomicBool::new(false)), normalization_engine: Arc::new(AtomicU32::new(0)), normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())), + loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())), chained_info: Arc::new(Mutex::new(None)), samples_played: Arc::new(AtomicU64::new(0)), current_sample_rate: Arc::new(AtomicU32::new(0)), @@ -2538,6 +2464,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { gapless_switch_at: Arc::new(AtomicU64::new(0)), radio_state: Mutex::new(None), current_playback_url: Arc::new(Mutex::new(None)), + current_analysis_track_id: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -2592,6 +2519,18 @@ fn playback_identity(url: &str) -> Option { None } +/// Stable id for analysis cache rows and `analysis:waveform-updated`. +/// Prefer the Subsonic track id from the frontend: `psysonic-local://` URLs +/// only map to `local:path` in `playback_identity`, which does not match +/// `analysis_get_waveform_for_track(trackId)` or the UI's `currentTrack.id`. +fn analysis_cache_track_id(logical_track_id: Option<&str>, url: &str) -> Option { + let logical = logical_track_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + logical.or_else(|| playback_identity(url)) +} + fn same_playback_target(a_url: &str, b_url: &str) -> bool { match (playback_identity(a_url), playback_identity(b_url)) { (Some(a), Some(b)) => a == b, @@ -2603,30 +2542,23 @@ fn resolve_loudness_gain_from_cache( app: &AppHandle, url: &str, target_lufs: f32, - requested_loudness_gain_db: Option, + logical_track_id: Option<&str>, ) -> Option { - // Never trust `requested` alone: the frontend may pass the *next* track's gain - // while `current_playback_url` still lags one play behind. Always prefer a - // cache row for **this** URL's track_id; use `requested` only on cache miss - // (provisional / pre-seed from JS). - let Some(track_id) = playback_identity(url) else { - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}", - r - ); - } - return requested_loudness_gain_db; + // Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`) + // are applied in `audio_update_replay_gain` via `loudness_gain_db_or_startup(..., true, _)`. + let Some(track_id) = analysis_cache_track_id(logical_track_id, url) else { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-identity url_len={}", + url.len() + ); + return None; }; let Some(cache) = app.try_state::() else { - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}", - r, - track_id - ); - } - return requested_loudness_gain_db; + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}", + track_id + ); + return None; }; // Also touch waveform row here so playback path verifies current context is present. let _ = cache.get_latest_waveform_for_track(&track_id); @@ -2660,16 +2592,7 @@ fn resolve_loudness_gain_from_cache( "[normalization] resolve_loudness_gain source=cache-miss track_id={}", track_id ); - if let Some(r) = requested_loudness_gain_db { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}", - track_id, - r - ); - Some(r) - } else { - None - } + None } Err(e) => { crate::app_deprintln!( @@ -2682,16 +2605,43 @@ fn resolve_loudness_gain_from_cache( } } -/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until -/// analysis exists — must never return `None` here or `compute_gain` uses unity. +/// LUFS: DB-backed integrated LUFS only at bind time (`allow_js_when_uncached = false`); +/// after `analysis:loudness-partial`, `audio_update_replay_gain` passes `true` so finite +/// JS gain applies until SQLite catches up. Must never return `None` or `compute_gain` uses unity. fn loudness_gain_db_or_startup( app: &AppHandle, url: &str, target_lufs: f32, - requested: Option, + logical_track_id: Option<&str>, + pre_analysis_attenuation_db: f32, + allow_js_when_uncached: bool, + js_gain_db: Option, ) -> Option { - resolve_loudness_gain_from_cache(app, url, target_lufs, requested) - .or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)) + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + match resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id) { + Some(g) => Some(g), + None => { + if allow_js_when_uncached { + match js_gain_db { + Some(r) if r.is_finite() => Some(r), + _ => Some(pre), + } + } else { + Some(pre) + } + } + } +} + +#[inline] +fn loudness_pre_analysis_db_for_engine(state: &AudioEngine) -> f32 { + f32::from_bits( + state + .loudness_pre_analysis_attenuation_db + .load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0) + .min(0.0) } /// Take (consume) completed manual-stream bytes if they correspond to `url`. @@ -2792,10 +2742,6 @@ async fn fetch_data( const MASTER_HEADROOM: f32 = 0.891_254; const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024; const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900; -const PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA: usize = 192 * 1024; -/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up -/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor. -const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0; fn compute_gain( normalization_engine: u32, @@ -2891,6 +2837,9 @@ fn ramp_sink_volume(sink: Arc, from: f32, to: f32) { // ─── Commands ───────────────────────────────────────────────────────────────── +/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness +/// cache to the track when playing `psysonic-local://` (hot/offline). Optional +/// for HTTP streams (`playback_identity` is used as fallback). #[tauri::command] pub async fn audio_play( url: String, @@ -2903,6 +2852,7 @@ pub async fn audio_play( fallback_db: f32, manual: bool, // true = user-initiated skip → bypass crossfade, start immediately hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha) + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -2979,6 +2929,12 @@ pub async fn audio_play( // a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not // the previous URL still stored until the sink swap completes. *state.current_playback_url.lock().unwrap() = Some(url.clone()); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + *state.current_analysis_track_id.lock().unwrap() = logical_trim.clone(); + let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url); // Extract format hint from URL for better symphonia probing. Strip the // query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`) @@ -3053,6 +3009,45 @@ pub async fn audio_play( len / 1024, local_hint ); + if let Some(ref seed_id) = cache_id_for_tasks { + let path_owned = std::path::PathBuf::from(path); + let app_seed = app.clone(); + let gen_seed = gen; + let gen_arc_seed = state.generation.clone(); + let seed_id = seed_id.clone(); + tokio::spawn(async move { + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + let data = match tokio::fs::read(&path_owned).await { + Ok(d) => d, + Err(_) => return, + }; + if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { + return; + } + if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { + return; + } + match crate::analysis_cache::seed_from_bytes(&app_seed, &seed_id, &data) { + Err(e) => crate::app_eprintln!( + "[analysis] local-file seed failed for {}: {}", + seed_id, + e + ), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app_seed.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: seed_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} + } + }); + } let reader = LocalFileSource { file, len }; PlayInput::SeekableMedia { reader: Box::new(reader), @@ -3112,6 +3107,8 @@ pub async fn audio_play( done.clone(), state.stream_completed_cache.clone(), state.normalization_target_lufs.clone(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let reader = RangedHttpSource { buf, @@ -3150,6 +3147,8 @@ pub async fn audio_play( done.clone(), state.stream_completed_cache.clone(), state.normalization_target_lufs.clone(), + state.loudness_pre_analysis_attenuation_db.clone(), + cache_id_for_tasks.clone(), )); let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::>(); @@ -3184,10 +3183,24 @@ pub async fn audio_play( } let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); - let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db); + let resolved_loudness_gain_db = resolve_loudness_gain_from_cache( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + ); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let startup_loudness_gain_db = if norm_mode == 2 { - loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + loudness_gain_db, + ) } else { resolved_loudness_gain_db }; @@ -3511,6 +3524,7 @@ pub async fn audio_chain_preload( pre_gain_db: f32, fallback_db: f32, hi_res_enabled: bool, + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -3571,6 +3585,11 @@ pub async fn audio_chain_preload( let raw_bytes = Arc::new(data); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Only `gain_linear` is needed — `effective_volume` is intentionally NOT // applied to the Sink here. `audio_chain_preload` runs ~30 s before the // current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl. @@ -3578,10 +3597,19 @@ pub async fn audio_chain_preload( // applied at the gapless transition in `spawn_progress_task`, not here. let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); let norm_mode = state.normalization_engine.load(Ordering::Relaxed); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let chain_loudness_db = if norm_mode == 2 { - loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db) + loudness_gain_db_or_startup( + &app, + &url, + target_lufs, + logical_trim.as_deref(), + pre_analysis_db, + false, + loudness_gain_db, + ) } else { - resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db) + resolve_loudness_gain_from_cache(&app, &url, target_lufs, logical_trim.as_deref()) }; let (gain_linear, _effective_volume) = compute_gain( norm_mode, @@ -3940,6 +3968,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu pub fn audio_stop(state: State<'_, AudioEngine>) { state.generation.fetch_add(1, Ordering::SeqCst); *state.current_playback_url.lock().unwrap() = None; + *state.current_analysis_track_id.lock().unwrap() = None; *state.chained_info.lock().unwrap() = None; *state.stream_completed_cache.lock().unwrap() = None; // Drop RadioLiveState → triggers Drop → task.abort() → TCP released. @@ -4075,22 +4104,45 @@ pub fn audio_update_replay_gain( ) { let norm_mode = state.normalization_engine.load(Ordering::Relaxed); let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed)); + let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state); let url_for_loudness = if norm_mode == 2 { state.current_playback_url.lock().unwrap().clone() } else { None }; + let logical_for_loudness = state + .current_analysis_track_id + .lock() + .ok() + .and_then(|g| (*g).clone()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); // If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db` // so `loudness_ui_current_gain_db` can show a number (otherwise `and_then` // drops the requested gain entirely). let resolved_loudness_gain_db = url_for_loudness .as_deref() - .and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db)) + .and_then(|u| { + resolve_loudness_gain_from_cache( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + ) + }) .or(loudness_gain_db); let effective_loudness_db = if norm_mode == 2 { match url_for_loudness.as_deref() { - Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db), - None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)), + Some(u) => loudness_gain_db_or_startup( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + pre_analysis_db, + true, + loudness_gain_db, + ), + None => loudness_gain_db.or(Some(pre_analysis_db)), } } else { loudness_gain_db @@ -4193,6 +4245,7 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State pub async fn audio_preload( url: String, duration_hint: f64, + analysis_track_id: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { @@ -4221,14 +4274,23 @@ pub async fn audio_preload( response.bytes().await.map_err(|e| e.to_string())?.into() }; let _ = duration_hint; // kept in API for compatibility - if let Some(track_id) = playback_identity(&url) { - if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { - crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); - } else { - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { track_id, is_partial: false }, - ); + let logical_trim = analysis_track_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) { + match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) { + Err(e) => crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e), + Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(_) => {} } } let url_for_emit = url.clone(); @@ -4579,7 +4641,13 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { } #[tauri::command] -pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) { +pub fn audio_set_normalization( + engine: String, + target_lufs: f32, + pre_analysis_attenuation_db: f32, + app: AppHandle, + state: State<'_, AudioEngine>, +) { let mode = match engine.as_str() { "replaygain" => 1, "loudness" => 2, @@ -4590,11 +4658,16 @@ pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state .normalization_target_lufs .store(target.to_bits(), Ordering::Relaxed); + let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0); + state + .loudness_pre_analysis_attenuation_db + .store(pre.to_bits(), Ordering::Relaxed); crate::app_deprintln!( - "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}", + "[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}", engine, normalization_engine_name(mode), - target + target, + pre ); let _ = app.emit( "audio:normalization-state", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4fdef949..5d6983bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1199,8 +1199,30 @@ fn analysis_get_waveform( md5_16kb: String, cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { - let key = analysis_cache::TrackKey { track_id, md5_16kb }; + let key = analysis_cache::TrackKey { + track_id: track_id.clone(), + md5_16kb: md5_16kb.clone(), + }; let row = cache.get_waveform(&key)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}", + track_id, + md5_16kb, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}", + track_id, + md5_16kb + ); + } + } Ok(row.map(|v| WaveformCachePayload { bins: v.bins, bin_count: v.bin_count, @@ -1217,6 +1239,23 @@ fn analysis_get_waveform_for_track( cache: tauri::State<'_, analysis_cache::AnalysisCache>, ) -> Result, String> { let row = cache.get_latest_waveform_for_track(&track_id)?; + match &row { + Some(v) => { + crate::app_deprintln!( + "[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}", + track_id, + v.bins.len(), + v.bin_count, + v.updated_at + ); + } + None => { + crate::app_deprintln!( + "[analysis][waveform] db miss track_id={}", + track_id + ); + } + } Ok(row.map(|v| WaveformCachePayload { bins: v.bins, bin_count: v.bin_count, @@ -1349,18 +1388,19 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path } fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { - analysis_cache::seed_from_bytes(app, track_id, bytes) - .map_err(|e| { - crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); - e - })?; - let _ = app.emit( - "analysis:waveform-updated", - WaveformUpdatedPayload { - track_id: track_id.to_string(), - is_partial: false, - }, - ); + let outcome = analysis_cache::seed_from_bytes(app, track_id, bytes).map_err(|e| { + crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e); + e + })?; + if outcome == analysis_cache::SeedFromBytesOutcome::Upserted { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.to_string(), + is_partial: false, + }, + ); + } let has_loudness = app .try_state::() .and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten()) diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 070a61a3..274b5b62 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -109,6 +109,24 @@ function easeOutCubic(t: number): number { return 1 - Math.pow(1 - x, 3); } +function binsToHeights(src: number[]): Float32Array { + const h = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; +} + +function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > eps) return false; + } + return true; +} + function waveformBarThickness(logicalH: number, norm: number): number { const safeNorm = Math.max(FLAT_WAVE_NORM, norm); return Math.max(1, safeNorm * logicalH); @@ -865,9 +883,6 @@ export default function WaveformSeek({ trackId }: Props) { const seek = usePlayerStore(s => s.seek); const waveformBins = usePlayerStore(s => s.waveformBins); - const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial); - const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec); - const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const seekbarStyle = useAuthStore(s => s.seekbarStyle); @@ -882,18 +897,16 @@ export default function WaveformSeek({ trackId }: Props) { return; } if (waveformBins && waveformBins.length > 0) { - const src = waveformBins; - const h = new Float32Array(BAR_COUNT); - for (let i = 0; i < BAR_COUNT; i++) { - const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); - const v = src[idx]; - h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); - } + const h = binsToHeights(waveformBins); const prev = heightsRef.current; if (!prev || prev.length !== BAR_COUNT) { heightsRef.current = h; return; } + if (heightsNearlyEqual(prev, h, 0.02)) { + heightsRef.current = h; + return; + } const from = new Float32Array(prev); const to = h; const startedAt = performance.now(); @@ -946,7 +959,7 @@ export default function WaveformSeek({ trackId }: Props) { } // No analysis bins yet: render 500 flat bars immediately. heightsRef.current = makeFlatWaveHeights(); - }, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]); + }, [trackId, waveformBins]); // Imperative subscription — no React re-renders from progress changes. // Static styles draw here; animated styles only update refs. @@ -983,9 +996,6 @@ export default function WaveformSeek({ trackId }: Props) { seekbarStyle, trackId, waveformBins, - waveformIsPartial, - waveformKnownUntilSec, - waveformDurationSec, duration, ]); diff --git a/src/locales/de.ts b/src/locales/de.ts index 1e026994..ee5d6c94 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -847,6 +847,12 @@ export const deTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getaggte Dateien)', replayGainFallback: 'Fallback (ohne Tags / Radio)', + normalization: 'Normalisierung', + loudnessTargetLufs: 'Ziel-LUFS', + loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.', + loudnessPreAnalysisAttenuationReset: 'Standard', crossfade: 'Crossfade', crossfadeDesc: 'Überblendung zwischen Tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/en.ts b/src/locales/en.ts index a95ceba7..ae79cfe7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -853,6 +853,12 @@ export const enTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (tagged files)', replayGainFallback: 'Fallback (untagged / radio)', + normalization: 'Normalization', + loudnessTargetLufs: 'Target LUFS', + loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.', + loudnessPreAnalysisAttenuationReset: 'Reset to default', crossfade: 'Crossfade', crossfadeDesc: 'Fade between tracks', crossfadeSecs: '{{n}} s', diff --git a/src/locales/es.ts b/src/locales/es.ts index 3da001dd..848ac685 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -840,6 +840,12 @@ export const esTranslation = { replayGainAlbum: 'Álbum', replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)', replayGainFallback: 'Respaldo (sin etiquetar / radio)', + normalization: 'Normalización', + loudnessTargetLufs: 'LUFS objetivo', + loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.', + loudnessPreAnalysisAttenuationReset: 'Predeterminado', crossfade: 'Crossfade', crossfadeDesc: 'Transición entre pistas', crossfadeSecs: '{{n}} s', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 77f4624d..96b25972 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -835,6 +835,12 @@ export const frTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pré-Gain (fichiers taggés)', replayGainFallback: 'Repli (sans tags / radio)', + normalization: 'Normalisation', + loudnessTargetLufs: 'LUFS cible', + loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Réduit le volume tant que la loudness du morceau n’est pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.', + loudnessPreAnalysisAttenuationReset: 'Valeur par défaut', crossfade: 'Fondu enchaîné', crossfadeDesc: 'Fondu entre les pistes', crossfadeSecs: '{{n}} s', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index d8434943..6f0dc8c5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -834,6 +834,12 @@ export const nbTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (taggede filer)', replayGainFallback: 'Reserveverdi (uten tagger / radio)', + normalization: 'Normalisering', + loudnessTargetLufs: 'Mål-LUFS', + loudnessPreAnalysisAttenuation: 'Demping før måling (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.', + loudnessPreAnalysisAttenuationReset: 'Standard', crossfade: 'Crossfade', crossfadeDesc: 'Tone mellom spor', crossfadeSecs: '{{n}}s', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 1cfb04ea..22796f55 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -834,6 +834,12 @@ export const nlTranslation = { replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getagde bestanden)', replayGainFallback: 'Terugval (zonder tags / radio)', + normalization: 'Normalisatie', + loudnessTargetLufs: 'Doel-LUFS', + loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.', + loudnessPreAnalysisAttenuationReset: 'Standaard', crossfade: 'Overgang', crossfadeDesc: 'Fade tussen nummers', crossfadeSecs: '{{n}} s', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 555b193f..24c4883c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -886,6 +886,12 @@ export const ruTranslation = { replayGainAlbum: 'По альбому', replayGainPreGain: 'Предусиление (файлы с тегами)', replayGainFallback: 'Резерв (без тегов / радио)', + normalization: 'Нормализация', + loudnessTargetLufs: 'Целевой LUFS', + loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)', + loudnessPreAnalysisAttenuationDesc: + 'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.', + loudnessPreAnalysisAttenuationReset: 'По умолчанию', crossfade: 'Кроссфейд', crossfadeDesc: 'Плавный переход между треками', crossfadeSecs: '{{n}} с', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 780c2307..3da16846 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -829,6 +829,12 @@ export const zhTranslation = { replayGainAlbum: '专辑', replayGainPreGain: '预增益(有标签文件)', replayGainFallback: '回退增益(无标签 / 收音机)', + normalization: '响度归一化', + loudnessTargetLufs: '目标 LUFS', + loudnessPreAnalysisAttenuation: '测量前衰减 (dB)', + loudnessPreAnalysisAttenuationDesc: + '在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。', + loudnessPreAnalysisAttenuationReset: '恢复默认', crossfade: '交叉淡入淡出', crossfadeDesc: '曲目间淡入淡出', crossfadeSecs: '{{n}} 秒', diff --git a/src/main.tsx b/src/main.tsx index 4de98ddf..f6c65bbf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,22 @@ try { // Ignore in non-Tauri runtimes. } +// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the +// user's persisted `loggingMode` until then — but waveform/audio may already +// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`). +try { + const raw = localStorage.getItem('psysonic-auth'); + if (raw) { + const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } }; + const mode = parsed.state?.loggingMode; + if (mode === 'off' || mode === 'normal' || mode === 'debug') { + void invoke('set_logging_mode', { mode }); + } + } +} catch { + // Ignore parse / non-Tauri. +} + ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 28f9cd98..3d7f5f51 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -26,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; -import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; +import { + useAuthStore, + DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, + ServerProfile, + MIX_MIN_RATING_FILTER_MAX_STARS, + type SeekbarStyle, + type LyricsSourceId, + type LyricsSourceConfig, + type LoggingMode, + type LoudnessLufsPreset, +} from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; @@ -65,6 +75,29 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; +const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16]; + +function LoudnessLufsButtonGroup(props: { + value: LoudnessLufsPreset; + onSelect: (v: LoudnessLufsPreset) => void; +}) { + return ( +
+ {LOUDNESS_LUFS_BUTTON_ORDER.map(v => ( + + ))} +
+ ); +} + const CONTRIBUTORS = [ { github: 'jiezhuo', @@ -1795,9 +1828,6 @@ export default function Settings() { const deleted = await invoke('analysis_delete_all_waveforms'); usePlayerStore.setState({ waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, }); showToast( t('settings.waveformCacheCleared', { count: deleted }), @@ -2243,12 +2273,7 @@ export default function Settings() {
{/* Normalization */}
-
-
{t('settings.normalization', { defaultValue: 'Normalization' })}
-
- {t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })} -
-
+
{t('settings.normalization', { defaultValue: 'Normalization' })}
+ {auth.normalizationEngine === 'loudness' && ( +
+
+ + {t('settings.loudnessTargetLufs')} + + +
+
+ + {t('settings.loudnessPreAnalysisAttenuation')} + + auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 200 }} + /> + + {auth.loudnessPreAnalysisAttenuationDb} dB + + +
+
+ {t('settings.loudnessPreAnalysisAttenuationDesc')} +
+
+ )} {auth.normalizationEngine !== 'off' && (
- {auth.normalizationEngine === 'loudness' && ( - <> -
- Target LUFS: - - - - -
- - )} {auth.normalizationEngine === 'replaygain' && (
{t('settings.replayGainMode')}: diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 5fbb5f8a..a2593c2f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -22,6 +22,27 @@ export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented export type LoggingMode = 'off' | 'normal' | 'debug'; export type NormalizationEngine = 'off' | 'replaygain' | 'loudness'; +/** Integrated-loudness target presets (Settings + analysis). */ +export type LoudnessLufsPreset = -16 | -14 | -12 | -10; + +const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10]; + +/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */ +export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5; + +function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset { + return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number) + ? (v as LoudnessLufsPreset) + : fallback; +} + +function clampLoudnessPreAnalysisAttenuationDb(v: unknown): number { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB; + const stepped = Math.round(n * 2) / 2; + return Math.max(-24, Math.min(0, stepped)); +} + export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } @@ -51,7 +72,9 @@ interface AuthState { customGenreBlacklist: string[]; replayGainEnabled: boolean; normalizationEngine: NormalizationEngine; - loudnessTargetLufs: -16 | -14 | -12 | -10; + loudnessTargetLufs: LoudnessLufsPreset; + /** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */ + loudnessPreAnalysisAttenuationDb: number; replayGainMode: 'track' | 'album' | 'auto'; replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) @@ -212,7 +235,9 @@ interface AuthState { setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; setNormalizationEngine: (v: NormalizationEngine) => void; - setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void; + setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void; + setLoudnessPreAnalysisAttenuationDb: (v: number) => void; + resetLoudnessPreAnalysisAttenuationDbDefault: () => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; @@ -326,6 +351,7 @@ export const useAuthStore = create()( replayGainEnabled: false, normalizationEngine: 'off', loudnessTargetLufs: -12, + loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, @@ -462,6 +488,13 @@ export const useAuthStore = create()( set({ loudnessTargetLufs: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); }, + setLoudnessPreAnalysisAttenuationDb: (v) => { + set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) }); + }, + resetLoudnessPreAnalysisAttenuationDbDefault: () => { + set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, setReplayGainMode: (v) => { set({ replayGainMode: v }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); @@ -696,6 +729,10 @@ export const useAuthStore = create()( } catch { /* ignore */ } } + const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown }; + const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12); + const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb); + useAuthStore.setState({ mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), @@ -703,6 +740,8 @@ export const useAuthStore = create()( skipStarManualSkipCountsByKey: sanitizeSkipStarCounts( (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, ), + loudnessTargetLufs: targetSan, + loudnessPreAnalysisAttenuationDb: preSan, ...conflictingLegacyState, ...lyricsSourcesMigrated, ...wheelSmoothOneTime, diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e2210483..6f429c9d 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -148,9 +148,6 @@ async function buildInfiniteQueueCandidates( interface PlayerState { currentTrack: Track | null; waveformBins: number[] | null; - waveformIsPartial: boolean; - waveformKnownUntilSec: number; - waveformDurationSec: number; normalizationNowDb: number | null; normalizationTargetLufs: number | null; normalizationEngineLive: 'off' | 'replaygain' | 'loudness'; @@ -264,7 +261,8 @@ interface PlayerState { } type WaveformCachePayload = { - bins: number[]; + /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ + bins: number[] | Uint8Array; binCount: number; isPartial: boolean; knownUntilSec: number; @@ -272,6 +270,27 @@ type WaveformCachePayload = { updatedAt: number; }; +/** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ +function coerceWaveformBins(bins: unknown): number[] | null { + if (bins == null) return null; + if (Array.isArray(bins)) { + return bins.length > 0 ? bins.map(x => Number(x) & 255) : null; + } + if (bins instanceof Uint8Array) { + return bins.length > 0 ? Array.from(bins) : null; + } + if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') { + const len = (bins as { length: number }).length; + if (len === 0) return null; + try { + return Array.from(bins as ArrayLike).map(x => Number(x) & 255); + } catch { + return null; + } + } + return null; +} + type LoudnessCachePayload = { integratedLufs: number; truePeak: number; @@ -613,6 +632,14 @@ function clearLoudnessCacheStateForTrackId(trackId: string) { } } +/** Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit partial hints so Rust uses pre-trim until `analysis:loudness-partial` + `audio_update_replay_gain`. */ +function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null { + if (!trackId) return null; + if (!stableLoudnessGainByTrackId[trackId]) return null; + const v = cachedLoudnessGainByTrackId[trackId]; + return Number.isFinite(v) ? v : null; +} + function resetLoudnessBackfillStateForTrackId(trackId: string) { for (const k of loudnessCacheStateKeysForTrackId(trackId)) { delete analysisBackfillInFlightByTrackId[k]; @@ -651,20 +678,15 @@ async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; try { const row = await invoke('analysis_get_waveform_for_track', { trackId }); - if (!row || !Array.isArray(row.bins) || row.bins.length === 0) { + const bins = row ? coerceWaveformBins(row.bins) : null; + if (!bins || bins.length === 0) { usePlayerStore.setState({ waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, }); return; } usePlayerStore.setState({ - waveformBins: row.bins, - waveformIsPartial: !!row.isPartial, - waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0, - waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0, + waveformBins: bins, }); } catch { // best-effort; seekbar falls back to placeholder waveform @@ -738,6 +760,31 @@ async function refreshLoudnessForTrack( } } +/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */ +const LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS = 40; + +function prefetchLoudnessForEnqueuedTracks( + incoming: Track[], + mergedQueue: Track[], + queueIndex: number, +) { + if (useAuthStore.getState().normalizationEngine !== 'loudness') return; + const ids = new Set(); + const next = mergedQueue[queueIndex + 1]; + if (next?.id) ids.add(next.id); + let n = 0; + for (const t of incoming) { + if (n >= LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS) break; + if (t?.id) { + ids.add(t.id); + n++; + } + } + for (const id of ids) { + void refreshLoudnessForTrack(id, { syncPlayingEngine: false }); + } +} + async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) { try { const res = await invoke<{ path: string; size: number } | null>( @@ -824,10 +871,6 @@ function handleAudioProgress(current_time: number, duration: number) { const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; - // Do not couple `waveformKnownUntilSec` to playhead: for partial streams it - // must track **download/analysis progress** (emitted in `analysis:waveform-partial`). - // Using `displayTime` here broke the seekbar after seeks — bins only cover the - // buffered prefix of the file while `knownUntil` jumped with the seek position. usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) @@ -915,7 +958,11 @@ function handleAudioProgress(current_time: number, duration: number) { gaplessEnabled, }); } - invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); + invoke('audio_preload', { + url: nextUrl, + durationHint: nextTrack.duration, + analysisTrackId: nextTrack.id, + }).catch(() => {}); } // Gapless chain — decode + chain into Sink 30s before track boundary. @@ -942,10 +989,11 @@ function handleAudioProgress(current_time: number, duration: number) { durationHint: nextTrack.duration, replayGainDb, replayGainPeak, - loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, hiResEnabled: authState.enableHiRes, + analysisTrackId: nextTrack.id, }).catch(() => {}); } } @@ -972,8 +1020,6 @@ function handleAudioEnded() { progress: 0, currentTime: 0, buffered: 0, - waveformIsPartial: false, - waveformKnownUntilSec: 0, }); setTimeout(() => { if (repeatMode === 'one' && currentTrack) { @@ -1019,9 +1065,6 @@ function handleAudioTrackSwitched(duration: number) { usePlayerStore.setState({ currentTrack: nextTrack, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), normalizationDbgSource: 'track-switched', normalizationDbgTrackId: nextTrack.id, @@ -1106,48 +1149,6 @@ export function initAudioListeners(): () => void { listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), - listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => { - const current = usePlayerStore.getState().currentTrack; - if (!current || !payload) return; - const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); - if (payloadTrackId && payloadTrackId !== current.id) return; - if (!payload.isPartial || !payload?.bins?.length) { - usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 }); - return; - } - const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0; - const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0); - usePlayerStore.setState((prev) => { - const prevBins = prev.waveformBins; - const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0; - if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) { - return { - waveformBins: payload.bins, - waveformIsPartial: true, - waveformKnownUntilSec: nextKnownUntilSec, - waveformDurationSec: nextDurationSec, - }; - } - const len = Math.max(prevBins.length, payload.bins.length); - const merged = new Array(len); - for (let i = 0; i < len; i++) { - merged[i] = i < prevBins.length ? prevBins[i] : 0; - } - const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len))); - const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len))); - if (nextKnownBars > prevKnownBars) { - for (let i = prevKnownBars; i < nextKnownBars; i++) { - if (i < payload.bins.length) merged[i] = payload.bins[i]; - } - } - return { - waveformBins: merged, - waveformIsPartial: true, - waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec), - waveformDurationSec: nextDurationSec, - }; - }); - }), listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { const current = usePlayerStore.getState().currentTrack; if (!current || !payload) return; @@ -1167,12 +1168,10 @@ export function initAudioListeners(): () => void { if (!payload?.trackId) return; const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); if (!payloadTrackId) return; - const currentId = usePlayerStore.getState().currentTrack?.id; + const currentRaw = usePlayerStore.getState().currentTrack?.id; + const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null; if (currentId && payloadTrackId === currentId) { - if (!payload.isPartial) { - usePlayerStore.setState({ waveformIsPartial: false }); - } - void refreshWaveformForTrack(currentId); + void refreshWaveformForTrack(currentRaw!); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId }); return; @@ -1265,7 +1264,12 @@ export function initAudioListeners(): () => void { invoke('audio_set_normalization', { engine: normCfg.normalizationEngine, targetLufs: normCfg.loudnessTargetLufs, + preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb, }).catch(() => {}); + const bootTrackId = usePlayerStore.getState().currentTrack?.id; + if (bootTrackId) { + void refreshWaveformForTrack(bootTrackId); + } if (normCfg.normalizationEngine === 'loudness') { const currentId = usePlayerStore.getState().currentTrack?.id; if (currentId) { @@ -1281,6 +1285,7 @@ export function initAudioListeners(): () => void { // Keep audio settings in sync whenever auth store changes. let prevNormEngine = normCfg.normalizationEngine; let prevNormTarget = normCfg.loudnessTargetLufs; + let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb; const unsubAuth = useAuthStore.subscribe((state) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, @@ -1289,10 +1294,16 @@ export function initAudioListeners(): () => void { invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); const normChanged = state.normalizationEngine !== prevNormEngine - || state.loudnessTargetLufs !== prevNormTarget; + || state.loudnessTargetLufs !== prevNormTarget + || state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; if (!normChanged) return; + const onlyPreAnalysisChanged = + state.normalizationEngine === prevNormEngine + && state.loudnessTargetLufs === prevNormTarget + && state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; prevNormEngine = state.normalizationEngine; prevNormTarget = state.loudnessTargetLufs; + prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb; usePlayerStore.setState({ normalizationEngineLive: state.normalizationEngine, normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null, @@ -1309,10 +1320,13 @@ export function initAudioListeners(): () => void { invoke('audio_set_normalization', { engine: state.normalizationEngine, targetLufs: state.loudnessTargetLufs, + preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb, }).catch(() => {}); if (state.normalizationEngine === 'loudness') { const currentId = usePlayerStore.getState().currentTrack?.id; - if (currentId) { + if (onlyPreAnalysisChanged) { + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + } else if (currentId) { void refreshLoudnessForTrack(currentId).finally(() => { usePlayerStore.getState().updateReplayGainForCurrentTrack(); }); @@ -1504,9 +1518,6 @@ export const usePlayerStore = create()( (set, get) => ({ currentTrack: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1643,9 +1654,6 @@ export const usePlayerStore = create()( currentTime: 0, currentRadio: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1690,9 +1698,6 @@ export const usePlayerStore = create()( currentRadio: station, currentTrack: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', @@ -1804,9 +1809,6 @@ export const usePlayerStore = create()( currentTrack: track, currentRadio: null, waveformBins: null, - waveformIsPartial: false, - waveformKnownUntilSec: 0, - waveformDurationSec: 0, ...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0), queue: newQueue, queueIndex: idx >= 0 ? idx : 0, @@ -1848,11 +1850,12 @@ export const usePlayerStore = create()( durationHint: track.duration, replayGainDb, replayGainPeak, - loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(track.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, manual, hiResEnabled: authState.enableHiRes, + analysisTrackId: track.id, }) .then(() => { if (playGeneration !== gen) return; @@ -2029,11 +2032,12 @@ export const usePlayerStore = create()( durationHint: trackToPlay.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, - loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: trackToPlay.id, }).then(() => { if (playGeneration === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); @@ -2064,11 +2068,12 @@ export const usePlayerStore = create()( durationHint: currentTrack.duration, replayGainDb: replayGainDbCold, replayGainPeak: replayGainPeakCold, - loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null, + loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id), preGainDb: authStateCold.replayGainPreGainDb, fallbackDb: authStateCold.replayGainFallbackDb, manual: false, hiResEnabled: useAuthStore.getState().enableHiRes, + analysisTrackId: currentTrack.id, }).catch((err: unknown) => { if (playGeneration !== gen) return; setDeferHotCachePrefetch(false); @@ -2358,6 +2363,7 @@ export const usePlayerStore = create()( ...state.queue.slice(firstAutoIdx), ]; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex); return { queue: newQueue }; }); }, @@ -2404,6 +2410,7 @@ export const usePlayerStore = create()( ? state.queueIndex + tracks.length : state.queueIndex; syncQueueToServer(newQueue, state.currentTrack, state.currentTime); + prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex); return { queue: newQueue, queueIndex: newQueueIndex }; }); }, @@ -2495,6 +2502,7 @@ export const usePlayerStore = create()( currentTrack, currentTime: serverTime > 0 ? serverTime : localTime, }); + void refreshWaveformForTrack(currentTrack.id); } } catch (e) { console.error('Failed to initialize queue from server', e);