From a910162cfec686925b5ba756f147c16c69a4896d Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:25:26 +0300 Subject: [PATCH] fix(analysis): gate partial LUFS, seed RAM paths, clarify logs, dedupe IPC (#320) - Emit streaming partial loudness only when the loudness normalization engine is active - Seed waveform/loudness from in-memory full buffers (preloaded, stream cache, gapless reuse) so offline hot cache does not rely on HTTP backfill - Add explicit ranged/local/preload logs before full-file Symphonia + EBU analysis - Coalesce concurrent waveform DB reads and skip duplicate audio_set_normalization within 450ms (e.g. StrictMode double init) --- src-tauri/src/analysis_cache.rs | 17 ++-- src-tauri/src/audio.rs | 146 +++++++++++++++++++++++++++----- src/store/playerStore.ts | 70 ++++++++++----- 3 files changed, 186 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index 429d796a..8d1d2a2c 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -356,12 +356,16 @@ pub fn seed_from_bytes( return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit); } } + let mib = bytes.len() as f64 / (1024.0 * 1024.0); crate::app_deprintln!( - "[analysis][waveform] build start track_id={} bytes={} md5_16kb={}", + "[analysis] full-track analysis start track_id={} input_mib={:.2} md5_16kb={}", track_id, - bytes.len(), + mib, key.md5_16kb ); + crate::app_deprintln!( + "[analysis] full-track analysis work: Symphonia decodes the entire buffer twice (frame timeline, then PCM peak bins), then EBU R128 integrated loudness + true-peak when that succeeds — CPU-bound; large lossless files often take minutes" + ); let build = (|| -> Result<(bool, usize), String> { cache.touch_track_status(&key, "queued")?; @@ -406,20 +410,21 @@ pub fn seed_from_bytes( match &build { Ok((used_pcm_decode, bins_len)) => { crate::app_deprintln!( - "[analysis][waveform] build done track_id={} elapsed_ms={} path={} bins_len={}", + "[analysis] full-track analysis done track_id={} elapsed_ms={} decode_path={} bins_len={} ebu_loudness_cached={}", track_id, elapsed_ms, if *used_pcm_decode { "pcm_ebur128" } else { - "byte_envelope" + "byte_envelope_no_ebu" }, - bins_len + bins_len, + *used_pcm_decode ); } Err(e) => { crate::app_deprintln!( - "[analysis][waveform] build failed track_id={} elapsed_ms={} err={}", + "[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}", track_id, elapsed_ms, e diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index cc1793e4..affe75ae 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1051,6 +1051,7 @@ async fn track_download_task( mut prod: HeapProducer, done: Arc, promote_cache_slot: Arc>>, + normalization_engine: Arc, normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, @@ -1152,13 +1153,15 @@ async fn track_download_task( if !capture_over_limit && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - let pre_db = f32::from_bits( - loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed), - ) - .clamp(-24.0, 0.0); - emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db); last_partial_loudness_emit = Instant::now(); + if normalization_engine.load(Ordering::Relaxed) == 2 { + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + let pre_db = f32::from_bits( + loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed), + ) + .clamp(-24.0, 0.0); + emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db); + } } offset += pushed; downloaded += pushed as u64; @@ -1167,6 +1170,11 @@ 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)", + 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) => { @@ -1207,6 +1215,7 @@ async fn ranged_download_task( downloaded_to: Arc, done: Arc, promote_cache_slot: Arc>>, + normalization_engine: Arc, normalization_target_lufs: Arc, loudness_pre_analysis_attenuation_db: Arc, cache_track_id: Option, @@ -1296,23 +1305,25 @@ async fn ranged_download_task( && total_size > 0 && last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { - let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); - let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed)) - .clamp(-24.0, 0.0); - if let Some(provisional_db) = - provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db) - { - let _ = app.emit( - "analysis:loudness-partial", - PartialLoudnessPayload { - track_id: playback_identity(&url), - gain_db: provisional_db, - target_lufs, - is_partial: true, - }, - ); - } last_partial_loudness_emit = Instant::now(); + if normalization_engine.load(Ordering::Relaxed) == 2 { + let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); + let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed)) + .clamp(-24.0, 0.0); + if let Some(provisional_db) = + provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db) + { + let _ = app.emit( + "analysis:loudness-partial", + PartialLoudnessPayload { + track_id: playback_identity(&url), + gain_db: provisional_db, + target_lufs, + is_partial: true, + }, + ); + } + } } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { @@ -1344,7 +1355,22 @@ 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)", + tid, + total_size as f64 / (1024.0 * 1024.0), + total_size + ); + } + let t_clone = Instant::now(); let data = buf.lock().unwrap().clone(); + if total_size > 32 * 1024 * 1024 { + crate::app_deprintln!( + "[stream] ranged: buffer cloned in_ms={}", + t_clone.elapsed().as_millis() + ); + } 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), @@ -2735,6 +2761,56 @@ async fn fetch_data( Ok(Some(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 / +/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP. +fn spawn_analysis_seed_from_in_memory_bytes( + app: &AppHandle, + cache_track_id: Option<&str>, + gen: u64, + gen_arc: &Arc, + bytes: &[u8], +) { + let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else { + return; + }; + if bytes.is_empty() || bytes.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { + return; + } + let track_id = track_id.to_string(); + let bytes = bytes.to_vec(); + let app = app.clone(); + let gen_arc = gen_arc.clone(); + crate::app_deprintln!( + "[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}", + track_id, + bytes.len() as f64 / (1024.0 * 1024.0) + ); + 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!( + "[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(_) => {} + } + }); +} + /// -1 dB headroom applied at full scale to prevent inter-sample clipping. /// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler /// can produce inter-sample peaks slightly above ±1.0 → audible distortion. @@ -2980,6 +3056,13 @@ pub async fn audio_play( // b) Server does not → legacy non-seekable AudioStreamReader fallback // 4) Preloaded/streamed-cache hit → in-memory bytes via fetch_data let play_input = if let Some(d) = reuse_chained_bytes { + spawn_analysis_seed_from_in_memory_bytes( + &app, + cache_id_for_tasks.as_deref(), + gen, + &state.generation, + &d, + ); PlayInput::Bytes(d) } else { let stream_cache_hit = { @@ -3029,6 +3112,11 @@ pub async fn audio_play( if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES { return; } + crate::app_deprintln!( + "[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — invoking full-track analysis (async)", + 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!( "[analysis] local-file seed failed for {}: {}", @@ -3106,6 +3194,7 @@ pub async fn audio_play( downloaded_to.clone(), done.clone(), state.stream_completed_cache.clone(), + state.normalization_engine.clone(), state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), cache_id_for_tasks.clone(), @@ -3146,6 +3235,7 @@ pub async fn audio_play( prod, done.clone(), state.stream_completed_cache.clone(), + state.normalization_engine.clone(), state.normalization_target_lufs.clone(), state.loudness_pre_analysis_attenuation_db.clone(), cache_id_for_tasks.clone(), @@ -3174,6 +3264,13 @@ pub async fn audio_play( Some(d) => d, None => return Ok(()), // superseded while downloading }; + spawn_analysis_seed_from_in_memory_bytes( + &app, + cache_id_for_tasks.as_deref(), + gen, + &state.generation, + &data, + ); PlayInput::Bytes(data) } }; @@ -4279,6 +4376,11 @@ pub async fn audio_preload( .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) { + crate::app_deprintln!( + "[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis", + 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) => { diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 13c1dbba..2f7b65e4 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -608,6 +608,28 @@ 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>(); + +/** 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; + +function invokeAudioSetNormalizationDeduped(payload: { + engine: string; + targetLufs: number; + preAnalysisAttenuationDb: number; +}) { + const key = `${payload.engine}|${payload.targetLufs}|${payload.preAnalysisAttenuationDb}`; + const now = Date.now(); + if (key === lastNormAudioInvokeKey && now - lastNormAudioInvokeAtMs < 450) { + return; + } + lastNormAudioInvokeKey = key; + lastNormAudioInvokeAtMs = now; + void invoke('audio_set_normalization', payload).catch(() => {}); +} + function isReplayGainActive() { const a = useAuthStore.getState(); return a.normalizationEngine === 'replaygain' && a.replayGainEnabled; @@ -676,23 +698,33 @@ async function reseedLoudnessForTrackId(trackId: string) { async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; - try { - const row = await invoke('analysis_get_waveform_for_track', { trackId }); - // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). - if (usePlayerStore.getState().currentTrack?.id !== trackId) return; - const bins = row ? coerceWaveformBins(row.bins) : null; - if (!bins || bins.length === 0) { - usePlayerStore.setState({ - waveformBins: null, - }); - return; - } - usePlayerStore.setState({ - waveformBins: bins, + 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); }); - } catch { - // best-effort; seekbar falls back to placeholder waveform + waveformRefreshInflight.set(trackId, job); } + 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. */ @@ -1300,11 +1332,11 @@ export function initAudioListeners(): () => void { targetLufs: normCfg.loudnessTargetLufs, currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null, }); - invoke('audio_set_normalization', { + invokeAudioSetNormalizationDeduped({ engine: normCfg.normalizationEngine, targetLufs: normCfg.loudnessTargetLufs, preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb, - }).catch(() => {}); + }); const bootTrackId = usePlayerStore.getState().currentTrack?.id; if (bootTrackId) { void refreshWaveformForTrack(bootTrackId); @@ -1356,11 +1388,11 @@ export function initAudioListeners(): () => void { targetLufs: state.loudnessTargetLufs, currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null, }); - invoke('audio_set_normalization', { + invokeAudioSetNormalizationDeduped({ engine: state.normalizationEngine, targetLufs: state.loudnessTargetLufs, preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb, - }).catch(() => {}); + }); if (state.normalizationEngine === 'loudness') { const currentId = usePlayerStore.getState().currentTrack?.id; if (onlyPreAnalysisChanged) {