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)),
}
}
+13
View File
@@ -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).
+90 -21
View File
@@ -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<HashMap<String, PathBuf>>,
/// 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<HashMap<u64, (PathBuf, Instant)>>,
/// 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<HashMap<String, (PathBuf, f32)>>,
/// 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<HashMap<u64, (PathBuf, f32, Instant)>>,
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<PreviewResolveResult> {
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<PreviewR
.leases
.lock()
.expect("preview lease lock")
.insert(lease_id.clone(), path);
.insert(lease_id.clone(), (path, gain));
Some(PreviewResolveResult {
ok: true,
lease: Some(PreviewLeaseDto {
@@ -375,8 +402,29 @@ fn mint_from_recent_clip(state: &PreviewState, track_id: u64) -> Option<PreviewR
})
}
/// Loudness-normalization gain (LINEAR) for a freshly written preview clip:
/// measure it with the same BS.1770 meter the main player uses and bring it
/// to −14 LUFS, so Deezer's hot masters match the user's normalized library.
/// 1.0 (no change) when normalization is off or the clip can't be measured.
fn measure_clip_gain(app: &tauri::AppHandle, path: &std::path::Path) -> 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<u64>,
start_at_secs: Option<f64>,
) -> 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(())
+1
View File
@@ -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}
</div>
{/if}
</div>