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:
1 parent
abc959ca2f
commit
28aba3a3b3
4 files changed
+109
-21
No files matched your search
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user