From 28aba3a3b315865419308c3c26447523263c96ea Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:12:04 -0500 Subject: [PATCH] feat: normalize preview clips to -14 LUFS to match the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deezer preview clips are cut from the raw master (not pre-normalized), so they played several dB hotter than the user's normalized -14 LUFS files — measured across one artist the previews ranged -0.1 to -8.8 dB from target, an 8.7 dB spread, so a flat offset would have been wrong either way. Each clip is now measured with the same BS.1770 meter the main player uses (preview.rs measure_clip_gain -> loudness::normalization_gain_db) right after it is written in preview_resolve -- off the async worker, and fully off the playback path on a prefetched neighbour. The linear gain is stored in the lease and the reuse cache, and rides UNDER the app-volume mirror per voice (effective = target_vol * clip_gain), so it composes with the slider, mute and the crossfade (each clip crosses in at its own matched level). Gated on the volume_normalization setting; reset on dispose. Debug overlay PREVIEW row now shows the active clip's norm dB. Verified live: per-clip gains -0.1..-8.8 dB applied; mute still zeroes the preview; 39 app + 12 player tests pass. --- src-tauri/src/diagnostics.rs | 5 ++ src-tauri/src/loudness.rs | 13 ++++ src-tauri/src/preview.rs | 111 +++++++++++++++++++++++------ src/components/DebugOverlay.svelte | 1 + 4 files changed, 109 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index eb3c3ed..6a950c4 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -81,6 +81,8 @@ pub struct DebugStats { pub loudness: Option, /// The preview voices' resting volume (mirrors the app volume; 0 = muted). pub preview_volume: Option, + /// The active preview clip's loudness-normalization gain in dB. + pub preview_norm_db: Option, } #[tauri::command] @@ -168,6 +170,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS preview_volume: app .try_state::() .map(|s| crate::preview::target_volume(&s)), + preview_norm_db: app + .try_state::() + .map(|s| crate::preview::active_norm_db(&s)), } } diff --git a/src-tauri/src/loudness.rs b/src-tauri/src/loudness.rs index 79ac57a..4a87d10 100644 --- a/src-tauri/src/loudness.rs +++ b/src-tauri/src/loudness.rs @@ -129,6 +129,19 @@ fn gain_db(m: &Measured) -> f64 { g } +/// The same −14 LUFS normalization gain (dB) the main player uses, exposed +/// for the preview backend so Deezer clips are brought to the SAME loudness +/// as the user's own normalized tracks instead of playing at their raw +/// (much louder) master level. +pub fn normalization_gain_db(lufs: f64, peak: f32) -> f64 { + gain_db(&Measured { + lufs, + peak, + size: 0, + at_epoch_ms: 0, + }) +} + /// Toggle from settings: applies to the CURRENT track immediately (gain to /// unity when turning off; the cached gain back on when turning on is left /// to the next track — the analyzer generation for the current one is gone). diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs index 1b5d789..1f11ab3 100644 --- a/src-tauri/src/preview.rs +++ b/src-tauri/src/preview.rs @@ -82,12 +82,18 @@ pub struct PreviewState { /// load command would fade the outgoing into the incoming clip's decode /// latency (the mock-derived "ramp on audible, not on play()" trap). audible_marks: Arc<[AtomicU64; 2]>, - /// opaque lease id → clip file. Entries die with the session (dispose) - /// or the sweep; the map is small by construction (active + prefetch). - leases: Mutex>, - /// track id → (clip file, fetched at): the CLIP_REUSE_TTL byte cache. - /// Several leases may point at one file; dispose clears both maps. - recent_clips: Mutex>, + /// f32 bits per voice; the loudness-normalization gain (LINEAR) of the + /// clip currently on that voice. The voice's effective volume is + /// `target_vol * clip_gain` — the same "gain layered under the user + /// volume" the main player uses, so Deezer clips match the user's + /// −14 LUFS library instead of playing at raw master level. + clip_gains: Arc<[AtomicU32; 2]>, + /// opaque lease id → (clip file, normalization gain LINEAR). Entries die + /// with the session (dispose) or the sweep; map is small (active + pre). + leases: Mutex>, + /// track id → (clip file, gain LINEAR, fetched at): the CLIP_REUSE_TTL + /// byte cache. Several leases may point at one file; dispose clears both. + recent_clips: Mutex>, clip_dir: PathBuf, lease_counter: AtomicU64, } @@ -99,6 +105,16 @@ impl PreviewState { fn target(&self) -> f32 { f32::from_bits(self.target_vol.load(Ordering::Relaxed)) } + fn clip_gain(&self, idx: usize) -> f32 { + f32::from_bits(self.clip_gains[idx].load(Ordering::Relaxed)) + } + fn set_clip_gain(&self, idx: usize, g: f32) { + self.clip_gains[idx].store(g.to_bits(), Ordering::Relaxed); + } + /// The voice's steady volume: app-volume mirror × the clip's loudness gain. + fn effective_target(&self, idx: usize) -> f32 { + self.target() * self.clip_gain(idx) + } fn set_vol(&self, idx: usize, v: f32) { self.voices[idx].set_volume(v); self.shadow_vols[idx].store(v.to_bits(), Ordering::Relaxed); @@ -118,6 +134,13 @@ pub fn target_volume(state: &PreviewState) -> f32 { state.target() } +/// The active clip's loudness-normalization gain in dB (0 = untouched), +/// for the debug overlay — proves preview normalization is working, the way +/// the main player's NORM row does. +pub fn active_norm_db(state: &PreviewState) -> f32 { + 20.0 * state.clip_gain(state.active_idx()).max(1e-6).log10() +} + /// How long a crossfade waits for the incoming voice to become audible /// before ramping anyway. Local-file decode start is tens of ms; hitting /// this means the clip is broken (its error event fails the item) and the @@ -220,6 +243,10 @@ pub fn init(app: &tauri::AppHandle) { ]), fade_gen: Arc::new(AtomicU64::new(0)), audible_marks: audible_marks.clone(), + clip_gains: Arc::new([ + AtomicU32::new(1.0f32.to_bits()), + AtomicU32::new(1.0f32.to_bits()), + ]), leases: Mutex::new(HashMap::new()), recent_clips: Mutex::new(HashMap::new()), clip_dir, @@ -345,14 +372,14 @@ fn fail(failure: &'static str, retryable: bool) -> PreviewResolveResult { /// pick (CLIP_REUSE_TTL). safe_for_ms gets the short fallback: a re-resolve /// after it lapses lands right back here and is equally cheap. fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option { - let (path, byte_length) = { + let (path, gain, byte_length) = { let clips = state.recent_clips.lock().expect("preview clip lock"); - let (path, fetched_at) = clips.get(&track_id)?; + let (path, gain, fetched_at) = clips.get(&track_id)?; if fetched_at.elapsed() > CLIP_REUSE_TTL { return None; } let len = std::fs::metadata(path).ok()?.len() as usize; - (path.clone(), len) + (path.clone(), *gain, len) }; let nonce = state.lease_counter.fetch_add(1, Ordering::Relaxed); let lease_id = format!("pv-{track_id}-{nonce}"); @@ -360,7 +387,7 @@ fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option Option f32 { + if !crate::config::volume_normalization(&crate::config::load(app)) { + return 1.0; + } + match player::analyze_loudness( + player::AnalysisSource::Local(path.to_path_buf()), + &|| false, + &mut |_, _, _| {}, + ) { + Some((lufs, peak)) => { + 10f64.powf(crate::loudness::normalization_gain_db(lufs, peak) / 20.0) as f32 + } + None => 1.0, + } +} + #[tauri::command] pub async fn preview_resolve( + app: tauri::AppHandle, library: State<'_, LibraryState>, state: State<'_, PreviewState>, track_id: u64, @@ -439,16 +487,25 @@ pub async fn preview_resolve( if std::fs::write(&path, audio).is_err() { return Ok(fail("temporarily_failed", true)); } + // Measure loudness off the async worker (a 30 s clip decodes in tens of + // ms). On a prefetched neighbour this is fully off the playback path. + let gain = { + let app2 = app.clone(); + let path2 = path.clone(); + tauri::async_runtime::spawn_blocking(move || measure_clip_gain(&app2, &path2)) + .await + .unwrap_or(1.0) + }; state .leases .lock() .expect("preview lease lock") - .insert(lease_id.clone(), path.clone()); + .insert(lease_id.clone(), (path.clone(), gain)); state .recent_clips .lock() .expect("preview clip lock") - .insert(track_id, (path, Instant::now())); + .insert(track_id, (path, gain, Instant::now())); let safe_for_ms = compute_safe_for_ms(parse_exp_ms(&preview_url), media.date_ms, now_epoch_ms()); @@ -488,7 +545,7 @@ pub fn preview_load( crossfade_ms: Option, start_at_secs: Option, ) -> Result<(), String> { - let path = state + let (path, clip_gain) = state .leases .lock() .expect("preview lease lock") @@ -498,13 +555,13 @@ pub fn preview_load( let gen = state.bump_fades(); let xfade = crossfade_ms.unwrap_or(0); let cur = state.active_idx(); - let target = state.target(); if xfade == 0 || start_paused { // Hard swap (open, replay, muted, parked): idle voice must be silent // — it may be a fade tail a bumped generation just orphaned. state.voices[1 - cur].stop(); - state.set_vol(cur, target); + state.set_clip_gain(cur, clip_gain); + state.set_vol(cur, state.effective_target(cur)); state.voices[cur].play_local_from(&path, Some(30.0), start_at_secs); if start_paused { // Ack receipt matters less than the latch being set before any @@ -518,7 +575,12 @@ pub fn preview_load( let next = 1 - cur; let out_from = state.shadow(cur); + state.set_clip_gain(next, clip_gain); state.set_vol(next, 0.0); + // The incoming voice ramps to ITS clip's normalized level (app volume × + // this clip's gain) — so a loud clip crossing into a quiet one lands at + // the right, matched loudness, not the outgoing clip's. + let in_target = state.effective_target(next); // Snapshot the incoming voice's audible mark BEFORE play so the fade // thread can tell "sounding now" from any earlier life of this voice. let mark = state.audible_marks[next].load(Ordering::Relaxed); @@ -531,7 +593,7 @@ pub fn preview_load( state.fade_gen.clone(), state.voices.clone(), state.shadow_vols.clone(), - Some((next, target)), + Some((next, in_target)), Some((cur, out_from)), xfade, Some((state.audible_marks.clone(), next, mark)), @@ -549,24 +611,28 @@ pub fn preview_control( match action.as_str() { "pause" => { // Pause silences EVERYTHING: orphan fades, kill any dying tail, - // and park the active voice back at target so resume is full. + // and park the active voice back at its normalized level so a + // resume is at full (matched) loudness. state.bump_fades(); state.voices[1 - cur].stop(); state.voices[cur].pause(); - state.set_vol(cur, state.target()); + state.set_vol(cur, state.effective_target(cur)); } "resume" => state.voices[cur].resume(), "stop" => { state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); - state.set_vol(cur, state.target()); + state.set_vol(cur, state.effective_target(cur)); } "seek" => state.voices[cur].seek(value.unwrap_or(0.0)), "volume" => { + // App-volume mirror. The clip's normalization gain rides UNDER + // it (effective = app volume × clip gain), so the level tracks + // the slider while staying loudness-matched. let v = value.unwrap_or(DEFAULT_TARGET_VOL as f64) as f32; state.target_vol.store(v.to_bits(), Ordering::Relaxed); - state.set_vol(cur, v); + state.set_vol(cur, state.effective_target(cur)); } // The crossfade's out-half when the incoming clip is not ready yet: // ramp the active voice to silence and stop it. The voice STAYS @@ -598,6 +664,9 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { state.bump_fades(); state.voices[0].stop(); state.voices[1].stop(); + // Reset clip gains with the session — the next session measures afresh. + state.set_clip_gain(0, 1.0); + state.set_clip_gain(1, 1.0); state.set_vol(0, state.target()); state.set_vol(1, state.target()); state @@ -606,7 +675,7 @@ pub fn preview_dispose(state: State<'_, PreviewState>) -> Result<(), String> { .expect("preview clip lock") .clear(); let mut leases = state.leases.lock().expect("preview lease lock"); - for (_, path) in leases.drain() { + for (_, (path, _gain)) in leases.drain() { let _ = std::fs::remove_file(path); } Ok(()) diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index 98cb037..6c44b6a 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -125,6 +125,7 @@ {previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} · idx {previewUi.snapshot?.activeManifestIndex ?? "—"} {#if stats.previewVolume != null}· vol {stats.previewVolume.toFixed(2)}{/if} + {#if stats.previewNormDb != null}· norm {stats.previewNormDb >= 0 ? "+" : ""}{stats.previewNormDb.toFixed(1)} dB{/if} {/if}