diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index affe75ae..b993c40d 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -46,6 +46,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 ──────────────────────────────────────────────── @@ -1313,15 +1368,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, + }, + ); + } } } } @@ -1420,6 +1478,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(), @@ -3324,8 +3392,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 +3888,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 { @@ -4273,8 +4340,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, @@ -4771,8 +4839,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/store/playerStore.ts b/src/store/playerStore.ts index 2f7b65e4..805014c2 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -611,6 +611,12 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) { /** Coalesce concurrent `analysis_get_waveform_for_track` for one id (StrictMode double mount, init + queue restore). */ const waveformRefreshInflight = new Map>(); +/** 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 = ''; let lastNormAudioInvokeAtMs = 0; @@ -630,6 +636,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; @@ -731,9 +773,19 @@ async function refreshWaveformForTrack(trackId: string) { 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 { @@ -1227,6 +1279,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, @@ -2620,14 +2678,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, + }); }, }), {