feat: normalize preview clips to -14 LUFS to match the library

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.
This commit is contained in:
KeelrLeek committed 2026-07-23 17:12:04 -05:00
1 parent abc959ca2f
commit 28aba3a3b3
4 files changed
+109 -21

No files matched your search

+5
View File
@@ -81,6 +81,8 @@ pub struct DebugStats {
pub loudness: Option<crate::loudness::CurrentTrack>,
/// The preview voices' resting volume (mirrors the app volume; 0 = muted).
pub preview_volume: Option<f32>,
/// The active preview clip's loudness-normalization gain in dB.
pub preview_norm_db: Option<f32>,
}
#[tauri::command]
@@ -168,6 +170,9 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS
preview_volume: app
.try_state::<crate::preview::PreviewState>()
.map(|s| crate::preview::target_volume(&s)),
preview_norm_db: app
.try_state::<crate::preview::PreviewState>()
.map(|s| crate::preview::active_norm_db(&s)),
}
}