diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index 8d1d2a2c..582d5ba3 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -14,9 +14,18 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use tauri::Manager; -pub const WAVEFORM_ALGO_VERSION: i64 = 3; +pub const WAVEFORM_ALGO_VERSION: i64 = 4; pub const LOUDNESS_ALGO_VERSION: i64 = 1; +/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin). +pub fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { + if bin_count <= 0 { + return false; + } + let n = bin_count as usize; + bins.len() == n.saturating_mul(2) +} + #[derive(Debug, Clone)] pub struct TrackKey { pub track_id: String, @@ -200,8 +209,9 @@ impl AnalysisCache { 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#" + let row = 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 @@ -211,20 +221,21 @@ impl AnalysisCache { 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()) + 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())?; + Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count))) } pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result, String> { @@ -258,8 +269,10 @@ impl AnalysisCache { ) .optional() .map_err(|e| e.to_string())?; - if row.is_some() { - return Ok(row); + if let Some(e) = row { + if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { + return Ok(Some(e)); + } } } Ok(None) @@ -315,7 +328,7 @@ pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_ recommended_gain_db.clamp(-24.0, 24.0) } -/// Result of [`seed_from_bytes`]: callers use it to avoid redundant UI events. +/// Result of [`seed_from_bytes_execute`] / CPU seed queue: 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). @@ -326,7 +339,9 @@ pub enum SeedFromBytesOutcome { SkippedNoAnalysisCache, } -pub fn seed_from_bytes( +/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the +/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs. +pub fn seed_from_bytes_execute( app: &tauri::AppHandle, track_id: &str, bytes: &[u8], @@ -454,8 +469,8 @@ 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 mut peak_half = vec![0u8; bin_count]; + for (i, slot) in peak_half.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; @@ -467,6 +482,8 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { } *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; } + let mut out = peak_half.clone(); + out.extend_from_slice(&peak_half); out } @@ -618,6 +635,8 @@ fn decode_scan_pcm( }; let mut bin_max = vec![0.0f32; bin_count]; + let mut bin_sum = vec![0.0f32; bin_count]; + let mut bin_n = vec![0u32; bin_count]; let mut ebu: Option = None; let mut ebu_channels: u32 = 0; let mut sample_peak_abs = 0.0_f64; @@ -693,6 +712,8 @@ fn decode_scan_pcm( 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); + bin_sum[bin] += mag; + bin_n[bin] = bin_n[bin].saturating_add(1); } for c in 0..n_ch { let v = (slice[base + c] as f64).abs(); @@ -721,7 +742,17 @@ fn decode_scan_pcm( } } - let bins = normalize_peak_bins(&bin_max); + let mut bin_mean = vec![0.0f32; bin_count]; + for i in 0..bin_count { + if bin_n[i] > 0 { + bin_mean[i] = bin_sum[i] / (bin_n[i] as f32); + } + } + let peak_u8 = normalize_peak_bins(&bin_max); + let mean_u8 = normalize_peak_bins(&bin_mean); + let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2)); + bins.extend_from_slice(&peak_u8); + bins.extend_from_slice(&mean_u8); let loudness = if let Some(target_lufs) = loudness_target_lufs { if !fed_any_frames { diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index affe75ae..3427bc0b 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -31,13 +31,6 @@ struct PartialLoudnessPayload { 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 { @@ -46,6 +39,61 @@ struct NormalizationStatePayload { target_lufs: f32, } +/// Last `audio:normalization-state` emit, kept so we can suppress duplicate +/// payloads. The frontend already debounces this event, but on Windows +/// (WebView2) the IPC pipe is the bottleneck — every echo we skip here is +/// renderer-thread time we don't pay. +static LAST_NORM_STATE_EMIT: OnceLock>> = OnceLock::new(); + +fn norm_state_lock() -> &'static Mutex> { + LAST_NORM_STATE_EMIT.get_or_init(|| Mutex::new(None)) +} + +fn norm_state_changed(prev: &NormalizationStatePayload, next: &NormalizationStatePayload) -> bool { + if prev.engine != next.engine { return true; } + if (prev.target_lufs - next.target_lufs).abs() >= 0.02 { return true; } + match (prev.current_gain_db, next.current_gain_db) { + (None, None) => false, + (Some(a), Some(b)) => (a - b).abs() >= 0.05, + _ => true, // None ↔ Some transition is significant + } +} + +fn maybe_emit_normalization_state(app: &AppHandle, payload: NormalizationStatePayload) { + let mut guard = norm_state_lock().lock().unwrap(); + let should_emit = match guard.as_ref() { + Some(prev) => norm_state_changed(prev, &payload), + None => true, + }; + if !should_emit { return; } + *guard = Some(payload.clone()); + drop(guard); + let _ = app.emit("audio:normalization-state", payload); +} + +/// Last `analysis:loudness-partial` gain emitted per track-identity, used to +/// suppress emits whose gain hasn't moved meaningfully (≥ 0.1 dB). The partial +/// heuristic in `emit_partial_loudness_from_bytes` and the ranged-progress curve +/// both produce values that drift by hundredths of a dB even on identical input, +/// so the time-based throttle alone is not enough to keep the loop quiet. +static LAST_PARTIAL_LOUDNESS_EMIT: OnceLock>> = OnceLock::new(); +const PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB: f32 = 0.1; + +fn partial_loudness_should_emit(track_key: &str, gain_db: f32) -> bool { + let mut guard = LAST_PARTIAL_LOUDNESS_EMIT + .get_or_init(|| Mutex::new(std::collections::HashMap::new())) + .lock() + .unwrap(); + let prev = guard.get(track_key).copied(); + if let Some(p) = prev { + if (p - gain_db).abs() < PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB { + return false; + } + } + guard.insert(track_key.to_string(), gain_db); + true +} + // ─── 10-Band Graphic Equalizer ──────────────────────────────────────────────── @@ -473,6 +521,10 @@ const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024; const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024; /// Max bytes kept in memory to promote a completed streamed track for fast replay/seek recovery. const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024; +/// Hot/offline `psysonic-local://` files are read from disk for waveform/LUFS seeding — not the +/// same heap pressure as retaining a full HTTP capture. FLAC/DSD tracks often exceed 64 MiB; +/// using the stream-promote cap here skipped analysis entirely (empty seekbar). +const LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES: usize = 512 * 1024 * 1024; /// Consecutive body-stream failures tolerated for track streaming before abort. const TRACK_STREAM_MAX_RECONNECTS: u32 = 3; /// Seconds at stall threshold while paused before hard-disconnect. @@ -1171,22 +1223,15 @@ async fn track_download_task( if !capture_over_limit && !capture.is_empty() { if let Some(track_id) = cache_track_id { crate::app_deprintln!( - "[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — invoking full-track analysis (blocks until seed_from_bytes returns)", + "[stream] legacy stream: capture complete track_id={} capture_mib={:.2} — full-track analysis (cpu-seed queue)", track_id, capture.len() as f64 / (1024.0 * 1024.0) ); - 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(_) => {} + let high = analysis_seed_high_priority_for_track(&app, &track_id); + if let Err(e) = + crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await + { + crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e); } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { @@ -1219,7 +1264,25 @@ async fn ranged_download_task( normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, + // When `Some`, ranged playback seeds on completion — defer HTTP backfill for that + // track; `None` for large files where ranged skips seed (needs backfill). + loudness_seed_hold: Option>>>, ) { + let _ranged_loudness_hold_clear = match (loudness_seed_hold.as_ref(), cache_track_id.as_ref()) { + (Some(slot), Some(tid)) => { + let t = tid.clone(); + { + let mut g = slot.lock().unwrap(); + *g = Some((t.clone(), gen)); + } + Some(RangedLoudnessSeedHoldClear { + slot: Arc::clone(slot), + tid: t, + gen, + }) + } + _ => None, + }; let total_size = buf.lock().unwrap().len(); let mut downloaded: usize = 0; let mut reconnects: u32 = 0; @@ -1313,15 +1376,18 @@ async fn ranged_download_task( 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, - }, - ); + let track_key = playback_identity(&url).unwrap_or_else(|| url.clone()); + if partial_loudness_should_emit(&track_key, provisional_db) { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, + target_lufs, + is_partial: true, + }, + ); + } } } } @@ -1357,7 +1423,7 @@ async fn ranged_download_task( if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES { if let Some(ref tid) = cache_track_id { crate::app_deprintln!( - "[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (blocks this download task until complete)", + "[stream] ranged: HTTP buffer full track_id={} size_mib={:.2} — cloning {} bytes then full-track analysis (cpu-seed queue; this task awaits completion)", tid, total_size as f64 / (1024.0 * 1024.0), total_size @@ -1372,18 +1438,9 @@ async fn ranged_download_task( ); } 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(_) => {} + let high = analysis_seed_high_priority_for_track(&app, &track_id); + if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e); } } *promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data }); @@ -1420,6 +1477,16 @@ fn emit_partial_loudness_from_bytes( pre_floor.max(heuristic_floor) }; let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0); + let track_key = playback_identity(url).unwrap_or_else(|| url.to_string()); + if !partial_loudness_should_emit(&track_key, gain_db as f32) { + crate::app_deprintln!( + "[normalization] partial-loudness skip reason=delta-below-threshold gain_db={:.2} threshold_db={:.2} track_id={:?}", + gain_db, + PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB, + playback_identity(url) + ); + return; + } crate::app_deprintln!( "[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}", bytes.len(), @@ -2217,6 +2284,12 @@ pub struct AudioEngine { /// 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>>, + /// While a `RangedHttpSource` download task is filling the buffer for this + /// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the + /// same id — otherwise a parallel full GET + Symphonia competes with playback + /// decode (ALSA underruns). The ranged task clears this on exit; `gen` avoids a + /// late drop clearing a newer play of the same track. + pub(crate) ranged_loudness_seed_hold: Arc>>, } pub struct AudioCurrent { @@ -2491,11 +2564,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { radio_state: Mutex::new(None), current_playback_url: Arc::new(Mutex::new(None)), current_analysis_track_id: Arc::new(Mutex::new(None)), + ranged_loudness_seed_hold: Arc::new(Mutex::new(None)), }; (engine, thread) } +/// Clears [`AudioEngine::ranged_loudness_seed_hold`] only if it still matches this play. +struct RangedLoudnessSeedHoldClear { + slot: Arc>>, + tid: String, + gen: u64, +} + +impl Drop for RangedLoudnessSeedHoldClear { + fn drop(&mut self) { + if let Ok(mut g) = self.slot.lock() { + if matches!(&*g, Some((t, gen)) if t == &self.tid && *gen == self.gen) { + *g = None; + } + } + } +} + +/// `analysis_enqueue_seed_from_url` should bail while this track's ranged HTTP buffer +/// is still filling — playback will seed on completion with the same bytes. +pub(crate) fn ranged_loudness_backfill_should_defer(engine: &AudioEngine, track_id: &str) -> bool { + let tid = track_id.trim(); + if tid.is_empty() { + return false; + } + let Ok(g) = engine.ranged_loudness_seed_hold.lock() else { + return false; + }; + matches!(&*g, Some((t, _)) if t.as_str() == tid) +} + +/// Subsonic id pinned for the playing source (`audio_play`). Used to prioritize +/// HTTP loudness backfill for the track the user is listening to. +pub(crate) fn analysis_track_id_is_current_playback(engine: &AudioEngine, track_id: &str) -> bool { + let needle = track_id.trim(); + if needle.is_empty() { + return false; + } + let Ok(guard) = engine.current_analysis_track_id.lock() else { + return false; + }; + let Some(cur) = guard.as_deref().map(str::trim).filter(|s| !s.is_empty()) else { + return false; + }; + cur == needle +} + +fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool { + app.try_state::() + .is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id)) +} + fn audio_http_client(state: &AudioEngine) -> reqwest::Client { state .http_client @@ -2564,30 +2689,71 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool { } } +#[derive(Clone, Copy)] +struct ResolveLoudnessCacheOpts { + /// When false, skip `get_latest_waveform_for_track` — `audio_update_replay_gain` runs + /// on every partial-LUFS tick; loudness gain does not depend on waveform, and the extra + /// SQLite read was pure overhead on the IPC path. + touch_waveform: bool, + /// When false, omit `cache-miss` / `cache-invalid` debug lines (still log hits and errors). + log_soft_misses: bool, +} + +impl Default for ResolveLoudnessCacheOpts { + fn default() -> Self { + Self { + touch_waveform: true, + log_soft_misses: true, + } + } +} + fn resolve_loudness_gain_from_cache( app: &AppHandle, url: &str, target_lufs: f32, logical_track_id: Option<&str>, +) -> Option { + resolve_loudness_gain_from_cache_impl( + app, + url, + target_lufs, + logical_track_id, + ResolveLoudnessCacheOpts::default(), + ) +} + +fn resolve_loudness_gain_from_cache_impl( + app: &AppHandle, + url: &str, + target_lufs: f32, + logical_track_id: Option<&str>, + opts: ResolveLoudnessCacheOpts, ) -> 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() - ); + if opts.log_soft_misses { + 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 - ); + if opts.log_soft_misses { + 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); + if opts.touch_waveform { + // Bind / preload: verify waveform context exists alongside loudness lookup. + 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( @@ -2606,18 +2772,22 @@ fn resolve_loudness_gain_from_cache( Some(recommended) } Ok(Some(row)) => { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}", - track_id, - row.integrated_lufs - ); + if opts.log_soft_misses { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}", + track_id, + row.integrated_lufs + ); + } None } Ok(None) => { - crate::app_deprintln!( - "[normalization] resolve_loudness_gain source=cache-miss track_id={}", - track_id - ); + if opts.log_soft_misses { + crate::app_deprintln!( + "[normalization] resolve_loudness_gain source=cache-miss track_id={}", + track_id + ); + } None } Err(e) => { @@ -2631,6 +2801,30 @@ fn resolve_loudness_gain_from_cache( } } +/// LUFS gain after a single `resolve_loudness_gain_from_cache` result (`None` = miss). +/// Keeps `audio_update_replay_gain` / `audio_play` from resolving twice on the same URL. +fn loudness_gain_db_after_resolve( + resolved_from_cache: Option, + 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 resolved_from_cache { + 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) + } + } + } +} + /// 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. @@ -2643,20 +2837,19 @@ fn loudness_gain_db_or_startup( 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) - } - } - } + let resolved = resolve_loudness_gain_from_cache_impl( + app, + url, + target_lufs, + logical_track_id, + ResolveLoudnessCacheOpts::default(), + ); + loudness_gain_db_after_resolve( + resolved, + pre_analysis_attenuation_db, + allow_js_when_uncached, + js_gain_db, + ) } #[inline] @@ -2763,7 +2956,7 @@ async fn fetch_data( /// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`, /// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local` -/// disk-read seed path never runs. Spawn the same `seed_from_bytes` work so waveform / +/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform / /// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP. fn spawn_analysis_seed_from_in_memory_bytes( app: &AppHandle, @@ -2787,26 +2980,17 @@ fn spawn_analysis_seed_from_in_memory_bytes( track_id, bytes.len() as f64 / (1024.0 * 1024.0) ); + let high = analysis_seed_high_priority_for_track(&app, &track_id); tokio::spawn(async move { if gen_arc.load(Ordering::SeqCst) != gen { return; } - match crate::analysis_cache::seed_from_bytes(&app, &track_id, &bytes) { - Err(e) => crate::app_eprintln!( + if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await { + crate::app_eprintln!( "[analysis] in-memory play path 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(_) => {} + ); } }); } @@ -3109,30 +3293,29 @@ pub async fn audio_play( if gen_arc_seed.load(Ordering::SeqCst) != gen_seed { return; } - if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { + if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES { + crate::app_deprintln!( + "[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)", + seed_id, + data.len(), + LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024) + ); return; } crate::app_deprintln!( - "[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — invoking full-track analysis (async)", + "[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)", seed_id, data.len() as f64 / (1024.0 * 1024.0) ); - match crate::analysis_cache::seed_from_bytes(&app_seed, &seed_id, &data) { - Err(e) => crate::app_eprintln!( + let high = analysis_seed_high_priority_for_track(&app_seed, &seed_id); + if let Err(e) = + crate::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await + { + 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(_) => {} + ); } }); } @@ -3182,6 +3365,8 @@ pub async fn audio_play( let buf = Arc::new(Mutex::new(vec![0u8; total_usize])); let downloaded_to = Arc::new(AtomicUsize::new(0)); let done = Arc::new(AtomicBool::new(false)); + let loudness_hold_for_defer = (total_usize <= TRACK_STREAM_PROMOTE_MAX_BYTES) + .then_some(state.ranged_loudness_seed_hold.clone()); tokio::spawn(ranged_download_task( gen, state.generation.clone(), @@ -3198,6 +3383,7 @@ pub async fn audio_play( state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), cache_id_for_tasks.clone(), + loudness_hold_for_defer, )); let reader = RangedHttpSource { buf, @@ -3280,26 +3466,19 @@ 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( + let cache_loudness = resolve_loudness_gain_from_cache( &app, &url, target_lufs, logical_trim.as_deref(), ); + let resolved_loudness_gain_db = cache_loudness; 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, - ) + loudness_gain_db_after_resolve(cache_loudness, pre_analysis_db, false, loudness_gain_db) } else { - resolved_loudness_gain_db + cache_loudness }; let (gain_linear, effective_volume) = compute_gain( norm_mode, @@ -3324,8 +3503,8 @@ pub async fn audio_play( volume, effective_volume ); - let _ = app.emit( - "audio:normalization-state", + maybe_emit_normalization_state( + &app, NormalizationStatePayload { engine: normalization_engine_name(norm_mode).to_string(), current_gain_db, @@ -3820,20 +3999,19 @@ fn spawn_progress_task( gapless_switch_at: Arc, current_playback_url: Arc>>, ) { + // Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on + // Linux; mirrors the quantum policy used for stream open/reopen plus a small + // scheduler/mixer cushion so the UI doesn't run ahead. Other platforms have + // their own latency reporting paths and don't need the compensation here. + #[cfg(target_os = "linux")] 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 - } + let rate = sample_rate_hz.max(1.0); + let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 }; + (frames / rate) + 0.012 + } + #[cfg(not(target_os = "linux"))] + fn estimated_output_latency_secs(_sample_rate_hz: f64) -> f64 { + 0.0 } tokio::spawn(async move { @@ -4217,24 +4395,23 @@ pub fn audio_update_replay_gain( // 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 cache_loudness = url_for_loudness.as_deref().and_then(|u| { + resolve_loudness_gain_from_cache_impl( + &app, + u, + target_lufs, + logical_for_loudness.as_deref(), + ResolveLoudnessCacheOpts { + touch_waveform: false, + log_soft_misses: false, + }, + ) + }); + let resolved_loudness_gain_db = cache_loudness.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(), + Some(_u) => loudness_gain_db_after_resolve( + cache_loudness, pre_analysis_db, true, loudness_gain_db, @@ -4273,8 +4450,9 @@ pub fn audio_update_replay_gain( if let Some(sink) = &cur.sink { ramp_sink_volume(Arc::clone(sink), prev_effective, effective); } - let _ = app.emit( - "audio:normalization-state", + drop(cur); + maybe_emit_normalization_state( + &app, NormalizationStatePayload { engine: normalization_engine_name(norm_mode).to_string(), current_gain_db, @@ -4381,18 +4559,9 @@ pub async fn audio_preload( track_id, data.len() as f64 / (1024.0 * 1024.0) ); - 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 high = analysis_track_id_is_current_playback(&state, &track_id); + if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await { + crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e); } } let url_for_emit = url.clone(); @@ -4771,8 +4940,8 @@ pub fn audio_set_normalization( target, pre ); - let _ = app.emit( - "audio:normalization-state", + maybe_emit_normalization_state( + &app, NormalizationStatePayload { engine: normalization_engine_name(mode).to_string(), // At mode-switch time the effective track gain may not be recalculated yet. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92b74a1d..2693f3e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ pub(crate) mod logging; #[cfg(target_os = "windows")] mod taskbar_win; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -60,10 +60,425 @@ 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())) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AnalysisBackfillEnqueueKind { + /// New job at the tail of the queue. + NewBack, + /// New job for the currently playing track (head). + NewFront, + /// Same track was already waiting; moved to head with the latest URL. + ReorderedFront, + /// Low-priority duplicate while the track is already queued or running. + DuplicateSkipped, + /// High-priority request but that track is already being downloaded+seeded. + RunningSkipped, +} + +#[derive(Default)] +struct AnalysisBackfillQueueState { + deque: VecDeque<(String, String)>, + /// Set while this `track_id` is inside `analysis_backfill_download_and_seed` (not in deque). + in_progress: Option, +} + +impl AnalysisBackfillQueueState { + fn is_reserved(&self, tid: &str) -> bool { + self.in_progress.as_deref() == Some(tid) + || self.deque.iter().any(|(t, _)| t.as_str() == tid) + } + + fn try_pop_next(&mut self) -> Option<(String, String)> { + let (tid, url) = self.deque.pop_front()?; + self.in_progress = Some(tid.clone()); + Some((tid, url)) + } + + fn finish_job(&mut self, tid: &str) { + if self.in_progress.as_deref() == Some(tid) { + self.in_progress = None; + } + } + + fn enqueue( + &mut self, + tid: String, + url: String, + high_priority: bool, + ) -> AnalysisBackfillEnqueueKind { + let tref = tid.as_str(); + if self.is_reserved(tref) { + if !high_priority { + return AnalysisBackfillEnqueueKind::DuplicateSkipped; + } + if self.in_progress.as_deref() == Some(tref) { + return AnalysisBackfillEnqueueKind::RunningSkipped; + } + self.deque.retain(|(t, _)| t != &tid); + self.deque.push_front((tid, url)); + return AnalysisBackfillEnqueueKind::ReorderedFront; + } + if high_priority { + self.deque.push_front((tid, url)); + AnalysisBackfillEnqueueKind::NewFront + } else { + self.deque.push_back((tid, url)); + AnalysisBackfillEnqueueKind::NewBack + } + } +} + +struct AnalysisBackfillShared { + state: Mutex, + wake_tx: tokio::sync::mpsc::UnboundedSender<()>, +} + +impl AnalysisBackfillShared { + fn ping_worker(&self) { + let _ = self.wake_tx.send(()); + } +} + +static ANALYSIS_BACKFILL: OnceLock> = OnceLock::new(); + +/// Lazily spawns the single backfill worker (first caller supplies `AppHandle`). +fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc { + ANALYSIS_BACKFILL + .get_or_init(|| { + let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel(); + let shared = Arc::new(AnalysisBackfillShared { + state: Mutex::new(AnalysisBackfillQueueState::default()), + wake_tx, + }); + let app = app.clone(); + let sh = shared.clone(); + tauri::async_runtime::spawn(analysis_backfill_worker_loop(app, sh, wake_rx)); + shared + }) + .clone() +} + +async fn analysis_backfill_download_and_seed( + app: &tauri::AppHandle, + track_id: &str, + url: &str, +) -> Result { + 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()); + } + enqueue_analysis_seed(app, track_id, &bytes).await +} + +async fn analysis_backfill_worker_loop( + app: tauri::AppHandle, + shared: Arc, + mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>, +) { + loop { + if wake_rx.recv().await.is_none() { + break; + } + while let Some((track_id, url)) = { + let mut st = shared + .state + .lock() + .unwrap_or_else(|e| e.into_inner()); + st.try_pop_next() + } { + crate::app_deprintln!("[analysis] backfill worker: start track_id={}", track_id); + let result = analysis_backfill_download_and_seed(&app, &track_id, &url).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), + } + let mut st = shared + .state + .lock() + .unwrap_or_else(|e| e.into_inner()); + st.finish_job(&track_id); + } + } +} + +fn analysis_backfill_is_current_track(app: &tauri::AppHandle, track_id: &str) -> bool { + app.try_state::() + .is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id)) +} + +// ─── Full-track waveform + loudness: single CPU worker (mirrors HTTP backfill queue) ─ +// One `spawn_blocking` decode at a time; current playback is high-priority (front + reorder). +// Same `track_id` queued again merges waiters onto one job; while decode runs, same-id +// submitters attach to `running` followers so they all get the same outcome. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AnalysisCpuSeedEnqueueKind { + NewBack, + NewFront, + ReorderedFront, + RunningFollower, + MergedQueued, +} + +struct AnalysisCpuSeedJob { + track_id: String, + bytes: Vec, + waiters: Vec>>, +} + +struct AnalysisCpuSeedQueueState { + deque: VecDeque, + /// Decode in progress — same-id callers wait here for the same outcome. + running: Option<( + String, + Arc>>>>, + )>, +} + +impl AnalysisCpuSeedQueueState { + fn enqueue( + &mut self, + track_id: String, + bytes: Vec, + high_priority: bool, + ) -> ( + AnalysisCpuSeedEnqueueKind, + tokio::sync::oneshot::Receiver>, + ) { + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let tid = track_id.as_str(); + + if let Some((rtid, followers)) = &self.running { + if rtid == tid { + followers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(done_tx); + return (AnalysisCpuSeedEnqueueKind::RunningFollower, done_rx); + } + } + + if let Some(pos) = self.deque.iter().position(|j| j.track_id == track_id) { + let mut job = self.deque.remove(pos).unwrap(); + job.bytes = bytes; + job.waiters.push(done_tx); + let kind = if high_priority { + self.deque.push_front(job); + AnalysisCpuSeedEnqueueKind::ReorderedFront + } else { + self.deque.push_back(job); + AnalysisCpuSeedEnqueueKind::MergedQueued + }; + return (kind, done_rx); + } + + let job = AnalysisCpuSeedJob { + track_id: track_id.clone(), + bytes, + waiters: vec![done_tx], + }; + let kind = if high_priority { + self.deque.push_front(job); + AnalysisCpuSeedEnqueueKind::NewFront + } else { + self.deque.push_back(job); + AnalysisCpuSeedEnqueueKind::NewBack + }; + (kind, done_rx) + } +} + +struct AnalysisCpuSeedShared { + state: Mutex, + wake_tx: tokio::sync::mpsc::UnboundedSender<()>, +} + +impl Default for AnalysisCpuSeedQueueState { + fn default() -> Self { + Self { + deque: VecDeque::new(), + running: None, + } + } +} + +impl AnalysisCpuSeedShared { + fn ping_worker(&self) { + let _ = self.wake_tx.send(()); + } +} + +static ANALYSIS_CPU_SEED: OnceLock> = OnceLock::new(); + +fn analysis_cpu_seed_shared(app: &tauri::AppHandle) -> Arc { + ANALYSIS_CPU_SEED + .get_or_init(|| { + let (wake_tx, wake_rx) = tokio::sync::mpsc::unbounded_channel(); + let shared = Arc::new(AnalysisCpuSeedShared { + state: Mutex::new(AnalysisCpuSeedQueueState::default()), + wake_tx, + }); + let app = app.clone(); + let sh = shared.clone(); + tauri::async_runtime::spawn(analysis_cpu_seed_worker_loop(app, sh, wake_rx)); + shared + }) + .clone() +} + +/// HTTP backfill + CPU seed queue sizes (debug log only — `app_deprintln!`). +fn emit_analysis_queue_snapshot_line() { + let http = if let Some(arc) = ANALYSIS_BACKFILL.get() { + let st = arc.state.lock().unwrap_or_else(|e| e.into_inner()); + format!( + "http_backfill={{queued:{} download_active:{:?}}}", + st.deque.len(), + st.in_progress.as_deref() + ) + } else { + "http_backfill={{not_started}}".to_string() + }; + + let cpu = if let Some(arc) = ANALYSIS_CPU_SEED.get() { + let st = arc.state.lock().unwrap_or_else(|e| e.into_inner()); + let queued_jobs = st.deque.len(); + let pending_in_queued_jobs: usize = st.deque.iter().map(|j| j.waiters.len()).sum(); + let (decoding_tid, decoding_extra_waiters) = match &st.running { + Some((tid, fl)) => ( + Some(tid.as_str()), + fl.lock().map(|g| g.len()).unwrap_or(0), + ), + None => (None, 0usize), + }; + format!( + "cpu_seed={{queued_jobs:{} pending_channels_in_queue:{} decoding_tid:{:?} extra_waiters_same_id:{}}}", + queued_jobs, + pending_in_queued_jobs, + decoding_tid, + decoding_extra_waiters + ) + } else { + "cpu_seed={{not_started}}".to_string() + }; + + crate::app_deprintln!( + "[analysis] queue_snapshot interval_s=60 note=queues_in_memory_cleared_on_app_restart | {http} | {cpu}" + ); +} + +async fn analysis_queue_snapshot_loop() { + emit_analysis_queue_snapshot_line(); + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + emit_analysis_queue_snapshot_line(); + } +} + +async fn analysis_cpu_seed_worker_loop( + app: tauri::AppHandle, + shared: Arc, + mut wake_rx: tokio::sync::mpsc::UnboundedReceiver<()>, +) { + loop { + if wake_rx.recv().await.is_none() { + break; + } + loop { + let (job, followers) = { + let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + let Some(j) = st.deque.pop_front() else { + break; + }; + let fl = Arc::new(Mutex::new(Vec::new())); + st.running = Some((j.track_id.clone(), fl.clone())); + (j, fl) + }; + let tid_log = job.track_id.clone(); + let app2 = app.clone(); + let tid = job.track_id.clone(); + let bytes = job.bytes; + let outcome = tokio::task::spawn_blocking(move || { + analysis_cache::seed_from_bytes_execute(&app2, &tid, &bytes) + }) + .await + .unwrap_or_else(|e| Err(format!("cpu-seed spawn_blocking: {e}"))); + + let mut extra = followers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain(..) + .collect::>(); + for tx in job.waiters { + let _ = tx.send(outcome.clone()); + } + for tx in extra.drain(..) { + let _ = tx.send(outcome.clone()); + } + + { + let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + st.running = None; + } + let ok = outcome.as_ref().map(|o| *o == analysis_cache::SeedFromBytesOutcome::Upserted).unwrap_or(false); + crate::app_deprintln!( + "[analysis] cpu-seed worker: done track_id={} upserted={}", + tid_log, + ok + ); + } + } +} + +/// Submit full-buffer analysis; serializes with other producers. `high_priority` mirrors +/// HTTP backfill head insertion for the currently playing track. +/// +/// Emits `analysis:waveform-updated` once here when the DB row is ready (Upserted or cache hit), +/// so `audio` and other callers do not duplicate IPC. +pub(crate) async fn submit_analysis_cpu_seed( + app: tauri::AppHandle, + track_id: String, + bytes: Vec, + high_priority: bool, +) -> Result { + let shared = analysis_cpu_seed_shared(&app); + let rx = { + let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); + let (kind, rx) = st.enqueue(track_id.clone(), bytes, high_priority); + crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} high_priority={high_priority}"); + drop(st); + shared.ping_worker(); + rx + }; + let outcome = match rx.await { + Ok(res) => res?, + Err(_) => return Err("cpu-seed: result channel dropped".to_string()), + }; + if matches!( + outcome, + analysis_cache::SeedFromBytesOutcome::Upserted + | analysis_cache::SeedFromBytesOutcome::SkippedWaveformCacheHit + ) { + let _ = app.emit( + "analysis:waveform-updated", + WaveformUpdatedPayload { + track_id: track_id.clone(), + is_partial: false, + }, + ); + } + Ok(outcome) } /// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed. @@ -1314,6 +1729,15 @@ fn analysis_enqueue_seed_from_url( if track_id.trim().is_empty() || url.trim().is_empty() { return Ok(()); } + if let Some(engine) = app.try_state::() { + if crate::audio::ranged_loudness_backfill_should_defer(&engine, &track_id) { + crate::app_deprintln!( + "[analysis] backfill skip track_id={} reason=ranged_playback_will_seed", + track_id + ); + return Ok(()); + } + } if let Some(cache) = app.try_state::() { if cache.get_latest_loudness_for_track(&track_id)?.is_some() { crate::app_deprintln!( @@ -1323,48 +1747,34 @@ fn analysis_enqueue_seed_from_url( return Ok(()); } } - { - let mut inflight = analysis_backfill_inflight() + let tid_log = track_id.clone(); + let high_priority = analysis_backfill_is_current_track(&app, &track_id); + let shared = analysis_backfill_shared(&app); + let kind = { + let mut st = shared + .state .lock() .map_err(|_| "analysis backfill lock poisoned".to_string())?; - if inflight.contains(&track_id) { - return Ok(()); + st.enqueue(track_id, url, high_priority) + }; + match kind { + AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => { + shared.ping_worker(); + crate::app_deprintln!( + "[analysis] backfill enqueued: track_id={} position={}", + tid_log, + if high_priority { "front" } else { "back" } + ); } - inflight.insert(track_id.clone()); + AnalysisBackfillEnqueueKind::ReorderedFront => { + shared.ping_worker(); + crate::app_deprintln!( + "[analysis] backfill bumped to front (current track) track_id={}", + tid_log + ); + } + AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {} } - 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(()) } @@ -1388,26 +1798,25 @@ 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| { +async fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result { + let high = analysis_backfill_is_current_track(app, track_id); + let outcome = submit_analysis_cpu_seed( + app.clone(), + track_id.to_string(), + bytes.to_vec(), + high, + ) + .await + .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={}", + "[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}", track_id, bytes.len(), has_loudness @@ -1427,7 +1836,7 @@ async fn enqueue_analysis_seed_from_file( if bytes.is_empty() { return; } - let _ = enqueue_analysis_seed(app, track_id, &bytes); + let _ = enqueue_analysis_seed(app, track_id, &bytes).await; } /// Downloads a single track to the app's offline cache directory. @@ -2019,12 +2428,32 @@ async fn download_track_hot_cache( .await .map(|m| m.len()) .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] download disk_hit track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + // Disk hit: still seed analysis, but do not block the command (full-file read); the + // prefetch worker runs invokes sequentially. + let app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); return Ok(HotCacheDownloadResult { path: path_str, size, }); } + crate::app_deprintln!( + "[hot-cache] download http_start track_id={} server_id={}", + track_id, + server_id + ); + let client = reqwest::Client::builder() .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(120)) @@ -2046,12 +2475,23 @@ 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 app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] download http_done track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); Ok(HotCacheDownloadResult { path: path_str, size, @@ -2084,12 +2524,30 @@ async fn promote_stream_cache_to_hot_cache( .await .map(|m| m.len()) .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] promote disk_hit track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); + let app_seed = app.clone(); + let tid = track_id.clone(); + let fp = file_path.clone(); + tokio::spawn(async move { + enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).await; + }); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } let bytes = match audio::take_stream_completed_for_url(&state, &url) { Some(b) => b, - None => return Ok(None), + None => { + crate::app_deprintln!( + "[hot-cache] promote skip track_id={} reason=no_completed_stream_for_url", + track_id + ); + return Ok(None); + } }; let part_path = file_path.with_extension(format!("{suffix}.part")); @@ -2101,12 +2559,18 @@ async fn promote_stream_cache_to_hot_cache( .await .map_err(|e| e.to_string())?; - let _ = enqueue_analysis_seed(&app, &track_id, &bytes); + let _ = enqueue_analysis_seed(&app, &track_id, &bytes).await; let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); + crate::app_deprintln!( + "[hot-cache] promote from_stream track_id={} server_id={} bytes={}", + track_id, + server_id, + size + ); Ok(Some(HotCacheDownloadResult { path: path_str, size })) } @@ -2147,11 +2611,20 @@ async fn delete_hot_cache_track( app: tauri::AppHandle, ) -> Result<(), String> { let file_path = std::path::PathBuf::from(&local_path); - if file_path.exists() { + let existed = file_path.exists(); + if existed { tokio::fs::remove_file(&file_path) .await .map_err(|e| e.to_string())?; } + crate::app_deprintln!( + "[hot-cache] delete file existed={} path_suffix={}", + existed, + file_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("?") + ); let boundary = resolve_hot_cache_root(custom_dir, &app)?; @@ -2181,11 +2654,17 @@ async fn delete_hot_cache_track( #[tauri::command] async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> Result<(), String> { let dir = resolve_hot_cache_root(custom_dir, &app)?; - if dir.exists() { + let existed = dir.exists(); + if existed { tokio::fs::remove_dir_all(&dir) .await .map_err(|e| e.to_string())?; } + crate::app_deprintln!( + "[hot-cache] purge root_existed={} dir={}", + existed, + dir.display() + ); Ok(()) } @@ -3874,6 +4353,9 @@ pub fn run() { app.manage(cache); } + // Periodic analysis queue sizes (debug logging mode only). + tauri::async_runtime::spawn(analysis_queue_snapshot_loop()); + // ── 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 diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index e3d9636a..f1df3143 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -7,6 +7,11 @@ function fmt(s: number): string { } const BAR_COUNT = 500; +/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */ +const WAVE_BIN_COUNT = 500; +/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */ +const WAVE_MIX_MEAN = 0.7; +const WAVE_MIX_MAX = 0.3; const SEG_COUNT = 60; const FLAT_WAVE_NORM = 0.06; const WAVE_MORPH_MS = 1000; @@ -111,8 +116,27 @@ function easeOutCubic(t: number): number { function binsToHeights(src: number[]): Float32Array { const h = new Float32Array(BAR_COUNT); + const n = src.length; + if (n === WAVE_BIN_COUNT * 2) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const maxNorm = Number(src[idx]) / 255; + const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255; + const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm; + h[i] = Math.max(0.08, Math.min(1, v)); + } + return h; + } + if (n === WAVE_BIN_COUNT) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; + } for (let i = 0; i < BAR_COUNT; i++) { - const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); + const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n)); const v = src[idx]; h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); } diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index 6e89fba8..927bba64 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -10,6 +10,15 @@ import { getDeferHotCachePrefetch, } from './utils/hotCacheGate'; +/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / lucky-mix. */ +function hotCacheFrontendDebug(payload: Record): void { + if (useAuthStore.getState().loggingMode !== 'debug') return; + void invoke('frontend_debug_log', { + scope: 'hot-cache', + message: JSON.stringify(payload), + }).catch(() => {}); +} + /** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */ const PREFETCH_AHEAD = 5; @@ -89,11 +98,21 @@ function scheduleEvictAfterPreviousGrace(): void { function enqueueJobs(jobs: PrefetchJob[]) { const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`)); + let merged = 0; for (const j of jobs) { const k = `${j.serverId}:${j.trackId}`; if (seen.has(k)) continue; seen.add(k); pendingQueue.push(j); + merged++; + } + if (merged > 0) { + hotCacheFrontendDebug({ + event: 'prefetch-queue-jobs', + added: merged, + pendingTotal: pendingQueue.length, + trackIds: jobs.map(j => j.trackId), + }); } void runWorker(); } @@ -105,6 +124,11 @@ async function runWorker() { while (pendingQueue.length > 0) { const auth = useAuthStore.getState(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) { + hotCacheFrontendDebug({ + event: 'prefetch-worker-stop', + reason: 'auth-disabled-or-logged-out', + clearedPending: pendingQueue.length, + }); pendingQueue.length = 0; break; } @@ -117,11 +141,24 @@ async function runWorker() { if (!job) break; const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024; - if (maxBytes <= 0) continue; + if (maxBytes <= 0) { + hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'max-mb-zero' }); + continue; + } const offline = useOfflineStore.getState(); - if (offline.isDownloaded(job.trackId, job.serverId)) continue; - if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue; + if (offline.isDownloaded(job.trackId, job.serverId)) { + hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'offline-library' }); + continue; + } + if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) { + hotCacheFrontendDebug({ + event: 'prefetch-skip-job', + trackId: job.trackId, + reason: 'already-in-hot-index', + }); + continue; + } const player = usePlayerStore.getState(); const { queue, queueIndex } = player; @@ -130,19 +167,46 @@ async function runWorker() { .slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD) .map(t => t.id), ); - if (!wantIds.has(job.trackId)) continue; + if (!wantIds.has(job.trackId)) { + hotCacheFrontendDebug({ + event: 'prefetch-skip-job', + trackId: job.trackId, + reason: 'not-in-upcoming-window', + queueIndex, + window: PREFETCH_AHEAD, + }); + continue; + } const track = queue.find(t => t.id === job.trackId); - if (!track) continue; + if (!track) { + hotCacheFrontendDebug({ + event: 'prefetch-skip-job', + trackId: job.trackId, + reason: 'track-not-in-queue', + }); + continue; + } const hotEntries = useHotCacheStore.getState().entries; const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries); const est = estimateTrackHotCacheBytes(track); const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId; - if (!isImmediateNext && occupied + est > maxBytes) continue; + if (!isImmediateNext && occupied + est > maxBytes) { + hotCacheFrontendDebug({ + event: 'prefetch-skip-job', + trackId: job.trackId, + reason: 'budget-protected-window-plus-estimate', + occupied, + estimateBytes: est, + maxBytes, + }); + continue; + } const url = buildStreamUrl(job.trackId); try { const customDir = auth.hotCacheDownloadDir || null; + hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId }); const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', { trackId: job.trackId, serverId: job.serverId, @@ -150,7 +214,8 @@ async function runWorker() { suffix: job.suffix, customDir, }); - useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size); + useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size, 'prefetch'); + hotCacheFrontendDebug({ event: 'prefetch-stored', trackId: job.trackId, sizeBytes: res.size }); const fresh = usePlayerStore.getState(); const authAfter = useAuthStore.getState(); const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024; @@ -161,8 +226,8 @@ async function runWorker() { authAfter.activeServerId ?? '', authAfter.hotCacheDownloadDir || null, ); - } catch { - /* network / HTTP — skip */ + } catch (e: unknown) { + hotCacheFrontendDebug({ event: 'prefetch-download-failed', trackId: job.trackId, error: String(e) }); } } } finally { @@ -199,30 +264,55 @@ async function replanNow() { if (maxBytes <= 0) return; const { queue, queueIndex, currentRadio } = usePlayerStore.getState(); - if (currentRadio) return; + if (currentRadio) { + hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' }); + return; + } const offline = useOfflineStore.getState(); - const hot = useHotCacheStore.getState(); - await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir); + await useHotCacheStore.getState().evictToFit(queue, queueIndex, maxBytes, serverId, customDir); + + // Must read entries after eviction: the pre-evict snapshot still lists removed keys and would + // skip prefetch for upcoming tracks that no longer have on-disk rows. + const hotEntries = useHotCacheStore.getState().entries; const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); const immediateNextId = queue[queueIndex + 1]?.id; - let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hot.entries); + let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hotEntries); const jobs: PrefetchJob[] = []; + const skipped: { trackId: string; reason: string }[] = []; for (const t of targets) { - if (offline.isDownloaded(t.id, serverId)) continue; - if (hot.entries[entryKey(serverId, t.id)]) continue; + if (offline.isDownloaded(t.id, serverId)) { + skipped.push({ trackId: t.id, reason: 'offline-library' }); + continue; + } + if (hotEntries[entryKey(serverId, t.id)]) { + skipped.push({ trackId: t.id, reason: 'already-in-hot-index' }); + continue; + } const isImmediateNext = t.id === immediateNextId; if (isImmediateNext) { jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' }); continue; } const est = estimateTrackHotCacheBytes(t); - if (projectedOccupied + est > maxBytes) break; + if (projectedOccupied + est > maxBytes) { + skipped.push({ trackId: t.id, reason: 'budget-cap-rest-deferred' }); + break; + } projectedOccupied += est; jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' }); } + hotCacheFrontendDebug({ + event: 'replan', + queueIndex, + aheadCount: targets.length, + scheduledIds: jobs.map(j => j.trackId), + skipped, + projectedOccupiedBytes: projectedOccupied, + maxBytes, + }); enqueueJobs(jobs); } @@ -266,6 +356,7 @@ export function initHotCachePrefetch(): () => void { } if (!state.hotCacheEnabled || !state.isLoggedIn) { + hotCacheFrontendDebug({ event: 'prefetch-auth-off', clearedPending: pendingQueue.length }); pendingQueue.length = 0; clearHotCachePreviousGrace(); return; @@ -286,6 +377,13 @@ export function initHotCachePrefetch(): () => void { if (budgetSettingsChanged) { if (prev && state.hotCacheMaxMb < prev.hotCacheMaxMb) { + hotCacheFrontendDebug({ + event: 'prefetch-pending-cleared', + reason: 'hot-cache-max-mb-decreased', + prevMb: prev.hotCacheMaxMb, + nextMb: state.hotCacheMaxMb, + droppedJobs: pendingQueue.length, + }); pendingQueue.length = 0; } void replanNow(); diff --git a/src/store/hotCacheStore.ts b/src/store/hotCacheStore.ts index 960a549b..cfc9ff37 100644 --- a/src/store/hotCacheStore.ts +++ b/src/store/hotCacheStore.ts @@ -20,7 +20,13 @@ interface HotCacheState { /** Persisted map `${serverId}:${trackId}` → file meta */ entries: Record; getLocalUrl: (trackId: string, serverId: string) => string | null; - setEntry: (trackId: string, serverId: string, localPath: string, sizeBytes: number) => void; + setEntry: ( + trackId: string, + serverId: string, + localPath: string, + sizeBytes: number, + debugSource?: string, + ) => void; /** Bump LRU when the user actually plays this track (if it is in the hot cache). */ touchPlayed: (trackId: string, serverId: string) => void; removeEntry: (trackId: string, serverId: string) => void; @@ -51,6 +57,29 @@ function lruStamp(meta: HotCacheEntry | undefined): number { return meta.lastPlayedAt ?? meta.cachedAt ?? 0; } +function evictionReasonForTier(tier: number): string { + const labels: Record = { + 0: 'inactive-server', + 1: 'not-in-queue', + 2: 'ahead-of-protected-window', + 3: 'behind-current-in-queue', + }; + return labels[tier] ?? `tier-${tier}`; +} + +/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. Dynamic `authStore` import avoids a static cycle (auth → player → hot-cache). */ +function hotCacheFrontendDebug(payload: Record): void { + void import('./authStore') + .then(({ useAuthStore }) => { + if (useAuthStore.getState().loggingMode !== 'debug') return; + return invoke('frontend_debug_log', { + scope: 'hot-cache', + message: JSON.stringify(payload), + }); + }) + .catch(() => {}); +} + export const useHotCacheStore = create()( persist( (set, get) => ({ @@ -62,7 +91,7 @@ export const useHotCacheStore = create()( return `psysonic-local://${e.localPath}`; }, - setEntry: (trackId, serverId, localPath, sizeBytes) => { + setEntry: (trackId, serverId, localPath, sizeBytes, debugSource) => { const now = Date.now(); set(s => ({ entries: { @@ -75,6 +104,13 @@ export const useHotCacheStore = create()( }, }, })); + hotCacheFrontendDebug({ + event: 'index-add', + trackId, + serverId, + sizeBytes, + source: debugSource ?? 'unknown', + }); }, touchPlayed: (trackId, serverId) => { @@ -97,6 +133,12 @@ export const useHotCacheStore = create()( delete next[entryKey(serverId, trackId)]; return { entries: next }; }); + hotCacheFrontendDebug({ + event: 'index-remove', + trackId, + serverId, + reason: 'explicit-removeEntry', + }); emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' }); }, @@ -157,8 +199,31 @@ export const useHotCacheStore = create()( return a.lru - b.lru; }); - for (const { key } of cands) { + if (cands.length === 0) { + hotCacheFrontendDebug({ + event: 'evict-no-candidates', + sumBytes: sum, + maxBytes, + queueIndex, + entryKeys: keys.length, + reason: 'all-protected-or-grace-or-parse-fail', + }); + return; + } + + hotCacheFrontendDebug({ + event: 'evict-start', + sumBytes: sum, + maxBytes, + queueIndex, + protectLo, + protectHi, + candidateCount: cands.length, + }); + + for (const cand of cands) { if (sum <= maxBytes) break; + const { key, tier } = cand; const meta = entries[key]; if (!meta) continue; const parsed = parseKey(key); @@ -166,7 +231,24 @@ export const useHotCacheStore = create()( await invoke('delete_hot_cache_track', { localPath: meta.localPath, customDir: hotCacheCustomDir || null, - }).catch(() => {}); + }).catch((e: unknown) => { + hotCacheFrontendDebug({ + event: 'evict-disk-delete-failed', + trackId: parsed.trackId, + serverId: parsed.serverId, + error: String(e), + }); + }); + hotCacheFrontendDebug({ + event: 'evict-remove', + trackId: parsed.trackId, + serverId: parsed.serverId, + reason: `budget:${evictionReasonForTier(tier)}`, + tier, + bytes: meta.sizeBytes, + sumBytesAfter: sum - (meta.sizeBytes || 0), + maxBytes, + }); sum -= meta.sizeBytes || 0; delete entries[key]; emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' }); @@ -176,6 +258,10 @@ export const useHotCacheStore = create()( }, clearAllDisk: async (customDir: string | null) => { + hotCacheFrontendDebug({ + event: 'purge-all', + customDir: customDir && customDir.length > 0 ? '(custom)' : 'default', + }); await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {}); set({ entries: {} }); emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' }); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 2f7b65e4..18244128 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic'; import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; +import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; @@ -270,25 +271,34 @@ type WaveformCachePayload = { updatedAt: number; }; +/** v4: `500` peak + `500` mean-abs = `1000` bytes. Legacy single curve: `500` (treated as mean=max). */ +function waveformBlobLenOk(len: number): boolean { + return len === 500 || len === 1000; +} + /** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ function coerceWaveformBins(bins: unknown): number[] | null { if (bins == null) return null; + let raw: number[] | null = 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') { + if (bins.length === 0) return null; + raw = bins.map(x => Number(x) & 255); + } else if (bins instanceof Uint8Array) { + if (bins.length === 0) return null; + raw = Array.from(bins); + } else 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); + raw = Array.from(bins as ArrayLike).map(x => Number(x) & 255); } catch { return null; } + } else { + return null; } - return null; + if (!waveformBlobLenOk(raw.length)) return null; + return raw; } type LoudnessCachePayload = { @@ -608,8 +618,22 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) { useHotCacheStore.getState().touchPlayed(trackId, serverId); } -/** Coalesce concurrent `analysis_get_waveform_for_track` for one id (StrictMode double mount, init + queue restore). */ -const waveformRefreshInflight = new Map>(); +/** Last-write-wins generation per track: avoids applying a stale empty waveform read when + * `analysis:waveform-updated` bumps gen after SQLite commit while an older `analysis_get_waveform_for_track` + * is still in flight. Gen is bumped only on explicit invalidation (waveform-updated, analysis storage), + * not on every `refreshWaveformForTrack` call — otherwise bursts (Lucky Mix, queue) cancel each other. */ +const waveformRefreshGenByTrackId: Record = {}; + +function bumpWaveformRefreshGen(trackId: string) { + if (!trackId) return; + waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1; +} + +/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The + * analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in + * parallel for every full-track analysis completion; without coalescing, gapless + * preload + current-track completion can stack two SQLite reads + two state writes. */ +const loudnessRefreshInflight = new Map>(); /** Skip redundant `audio_set_normalization` IPC when the same payload is sent twice within a short window (e.g. StrictMode). */ let lastNormAudioInvokeKey = ''; @@ -630,6 +654,42 @@ function invokeAudioSetNormalizationDeduped(payload: { void invoke('audio_set_normalization', payload).catch(() => {}); } +/** + * Skip redundant `audio_update_replay_gain` IPC when the same payload was sent + * recently. updateReplayGainForCurrentTrack runs from the analysis:loudness-partial + * listener (~every 900 ms while LUFS is on); without dedupe each tick triggers a + * full IPC roundtrip + backend audio:normalization-state echo + frontend setState, + * which saturates the WebView2 renderer thread on Windows after a few minutes. + */ +let lastRgInvokeKey = ''; +let lastRgInvokeAtMs = 0; + +function invokeAudioUpdateReplayGainDeduped(payload: { + volume: number; + replayGainDb: number | null; + replayGainPeak: number | null; + loudnessGainDb: number | null; + preGainDb: number; + fallbackDb: number; +}) { + const fmt = (v: number | null) => (v == null || !Number.isFinite(v) ? 'null' : v.toFixed(3)); + const key = [ + payload.volume.toFixed(4), + fmt(payload.replayGainDb), + fmt(payload.replayGainPeak), + fmt(payload.loudnessGainDb), + payload.preGainDb.toFixed(2), + payload.fallbackDb.toFixed(2), + ].join('|'); + const now = Date.now(); + if (key === lastRgInvokeKey && now - lastRgInvokeAtMs < 250) { + return; + } + lastRgInvokeKey = key; + lastRgInvokeAtMs = now; + invoke('audio_update_replay_gain', payload).catch(console.error); +} + function isReplayGainActive() { const a = useAuthStore.getState(); return a.normalizationEngine === 'replaygain' && a.replayGainEnabled; @@ -698,42 +758,44 @@ async function reseedLoudnessForTrackId(trackId: string) { async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; - let job = waveformRefreshInflight.get(trackId); - if (!job) { - const p = (async () => { - 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 - } - })(); - job = p.finally(() => { - waveformRefreshInflight.delete(trackId); + const gen = waveformRefreshGenByTrackId[trackId] ?? 0; + try { + const row = await invoke('analysis_get_waveform_for_track', { trackId }); + if ((waveformRefreshGenByTrackId[trackId] ?? 0) !== gen) return; + // 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, }); - waveformRefreshInflight.set(trackId, job); + } catch { + // best-effort; seekbar falls back to placeholder waveform } - await job; } /** 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 }, -) { +): Promise { if (!trackId) return; const syncEngine = opts?.syncPlayingEngine !== false; + const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}`; + const existing = loudnessRefreshInflight.get(inflightKey); + if (existing) return existing; + const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })() + .finally(() => { loudnessRefreshInflight.delete(inflightKey); }); + loudnessRefreshInflight.set(inflightKey, job); + return job; +} + +async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise { emitNormalizationDebug('refresh:start', { trackId }); usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId }); try { @@ -753,7 +815,11 @@ async function refreshLoudnessForTrack( analysisBackfillInFlightByTrackId[trackId] = true; analysisBackfillAttemptsByTrackId[trackId] = attempts + 1; const url = buildStreamUrl(trackId); - emitNormalizationDebug('backfill:enqueue', { trackId, url, attempt: attempts + 1 }); + emitNormalizationDebug('backfill:enqueue', { + trackId, + url: redactSubsonicUrlForLog(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) })) @@ -832,7 +898,7 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string, }, ); if (!res || !res.path) return; - useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0); + useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0, 'stream-promote'); } catch { // best-effort promotion; normal hot-cache prefetch remains fallback } @@ -1227,6 +1293,12 @@ export function initAudioListeners(): () => void { if (payloadTrackId && payloadTrackId !== current.id) return; if (!Number.isFinite(payload.gainDb)) return; if (stableLoudnessGainByTrackId[current.id]) return; + // Skip when the cached gain is already within ~0.05 dB of the new payload — + // float jitter from the partial-loudness heuristic would otherwise re-trigger + // updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo + // every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed. + const existing = cachedLoudnessGainByTrackId[current.id]; + if (Number.isFinite(existing) && Math.abs(existing - payload.gainDb) < 0.05) return; cachedLoudnessGainByTrackId[current.id] = payload.gainDb; emitNormalizationDebug('partial-loudness:apply', { trackId: current.id, @@ -1242,6 +1314,7 @@ export function initAudioListeners(): () => void { const currentRaw = usePlayerStore.getState().currentTrack?.id; const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null; if (currentId && payloadTrackId === currentId) { + bumpWaveformRefreshGen(currentRaw!); void refreshWaveformForTrack(currentRaw!); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId }); @@ -1410,6 +1483,7 @@ export function initAudioListeners(): () => void { const currentId = usePlayerStore.getState().currentTrack?.id; if (!currentId) return; if (detail.trackId && detail.trackId !== currentId) return; + bumpWaveformRefreshGen(currentId); void refreshWaveformForTrack(currentId); void refreshLoudnessForTrack(currentId); }); @@ -2620,14 +2694,14 @@ export const usePlayerStore = create()( 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); + invokeAudioUpdateReplayGainDeduped({ + volume, + replayGainDb, + replayGainPeak, + loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null, + preGainDb: authState.replayGainPreGainDb, + fallbackDb: authState.replayGainFallbackDb, + }); }, }), { diff --git a/src/utils/redactSubsonicUrl.ts b/src/utils/redactSubsonicUrl.ts new file mode 100644 index 00000000..d3f78424 --- /dev/null +++ b/src/utils/redactSubsonicUrl.ts @@ -0,0 +1,17 @@ +/** + * Masks Subsonic wire-auth query params so debug logs are safe to copy. + * (`t` salt, `s` token hash, `p` password when present.) + */ +export function redactSubsonicUrlForLog(url: string): string { + if (!url || !url.includes('stream.view')) return url; + try { + const u = new URL(url); + // Placeholder must stay URL-safe (no `<>` — URLSearchParams percent-encodes them). + for (const k of ['t', 's', 'p'] as const) { + if (u.searchParams.has(k)) u.searchParams.set(k, 'REDACTED'); + } + return u.toString(); + } catch { + return url.replace(/([?&])(t|s|p)=([^&]*)/gi, (_m, sep: string, key: string) => `${sep}${key}=REDACTED`); + } +}