From 1e35c5e03922af8f36dbdf331d5db1183ba29c00 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:45:43 -0500 Subject: [PATCH] loudness: correct the gain during the track instead of only guessing at its start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A streamed first play is gained from a prefix; every later play is gained from the whole track. Measured across the 89 cached tracks, a 10s head asks for a median 3.4 dB too much gain and is more than 1 dB hot on 68 of them — that gap is the "first track is so much louder" complaint. The curve never flattens either: even a 60s head is still ~1 dB out with a 6 dB worst case, so no window size fixes it. Live traces then showed something worse. The hard deadline was anchored to the play REQUEST, so on a peer that took a moment to start sending, the 2.5s ran out before a single gating block existed and the track began at unity — no normalization at all, three plays out of four on one run. Releasing early bought nothing in exchange: playback is blocked on the same bytes, so the gate was discarding the measurement without making sound arrive sooner. - The deadline now starts at the first byte, with an absolute 4s ceiling from the request so a slow peer cannot hold audio that is ready to play. - The audio thread's slew drops 2.5 -> 0.25 dB/s. Mid-track correction was switched off in July because the glide was audible; at a tenth the rate it is not, and being 3 dB wrong for a whole track is the larger error. - The correction follows the running measurement from its first publish and may only ratchet DOWN until the track completes. A convergence test lived here and was deleted: an integrated average over 30s cannot move much when one more second is added, so "3 publishes within 0.3 dB" was measuring the inertia of a long average, fired at its floor every time, and declared settled while 1.5 and 2.1 dB from truth. The protection was never the gate — it is the slew rate and the ratchet. - Seeking lands the pending correction immediately. The slew is slow only because a gain change is audible against continuous music, and a seek is already a discontinuity. Observability: SOULSTREAM_LOUDNESS_TRACE=1 reports every commit with the seconds analyzed, which limit fired, the hold, and the bytes received; the debug overlay gains a SLEW line showing applied gain, target, distance and ETA, because one number cannot distinguish "settled" from "in flight". Also adds an #[ignore] corpus harness that sweeps head-window bias in 30s. --- crates/player/src/lib.rs | 86 +++++++- src-tauri/src/diagnostics.rs | 13 ++ src-tauri/src/loudness.rs | 339 ++++++++++++++++++++++++++++- src/components/DebugOverlay.svelte | 77 +++++++ 4 files changed, 502 insertions(+), 13 deletions(-) diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index c90fa25..c4277a9 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -124,6 +124,13 @@ enum Cmd { Stop, } +/// How far the normalization gain may move per 60 ms tick — 0.25 dB/s. +/// +/// The rate IS the feature: a mid-track correction is only acceptable if the +/// correcting is inaudible. See the slew in the tick arm for why this replaced +/// the original 0.15 (≈2.5 dB/s). +const NORM_SLEW_PER_TICK_DB: f32 = 0.015; + #[derive(Clone)] struct TrackSource { path: PathBuf, @@ -234,6 +241,13 @@ pub struct Player { /// f32 bits; the audio thread mirrors its live fade-gain here for /// observability (the debug overlay / crossfade diagnostics). 1.0 = full. fade_report: Arc, + /// f32 bits, dB: the gain the sink is being given RIGHT NOW, and the one + /// the slew is heading for. Mirrored separately because the interesting + /// state during a mid-track correction is the gap between them — a single + /// "current gain" readout cannot show that a correction is in flight, only + /// that it has finished. + norm_now_report: Arc, + norm_target_report: Arc, } impl Player { @@ -250,14 +264,35 @@ impl Player { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let fade_report = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); + let norm_now_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits())); + let norm_target_report = Arc::new(std::sync::atomic::AtomicU32::new(0.0f32.to_bits())); let thread_report = fade_report.clone(); + let thread_norm_now = norm_now_report.clone(); + let thread_norm_target = norm_target_report.clone(); std::thread::Builder::new() .name("audio".into()) - .spawn(move || audio_thread(cmd_rx, event_tx, output_device, thread_report)) + .spawn(move || { + audio_thread( + cmd_rx, + event_tx, + output_device, + thread_report, + thread_norm_now, + thread_norm_target, + ) + }) .expect("spawn audio thread"); - (Player { cmd_tx, fade_report }, event_rx) + ( + Player { + cmd_tx, + fade_report, + norm_now_report, + norm_target_report, + }, + event_rx, + ) } /// The audio thread's current fade-gain (0..1). Observability only. @@ -265,6 +300,16 @@ impl Player { f32::from_bits(self.fade_report.load(Ordering::Relaxed)) } + /// The normalization gain being applied right now, and the one the slew is + /// heading for, both in dB. Equal means settled; different means a + /// correction is in flight. Observability only. + pub fn norm_gain_now_target_db(&self) -> (f32, f32) { + ( + f32::from_bits(self.norm_now_report.load(Ordering::Relaxed)), + f32::from_bits(self.norm_target_report.load(Ordering::Relaxed)), + ) + } + pub fn play_local(&self, path: impl Into, duration_hint: Option) { self.play_local_from(path, duration_hint, None); } @@ -498,6 +543,8 @@ fn audio_thread( event_tx: mpsc::Sender, initial_device: Option, fade_report: Arc, + norm_now_report: Arc, + norm_target_report: Arc, ) { // Open the output device lazily and keep it across tracks. If the device // is momentarily busy (e.g. another instance still releasing it), we @@ -680,6 +727,15 @@ fn audio_thread( if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) { log::debug!("seek failed: {e}"); } + // Land the pending correction rather than carrying it + // across the jump. The slew is slow ONLY because a gain + // change is audible against continuous music — and a seek + // is already a discontinuity, so the step hides inside it. + // Drifting afterwards would be the audible case: the same + // few seconds of new material rising in level for no + // reason the listener can attribute to anything. + norm_db = norm_target_db; + s.set_volume(eff(volume, norm_db, fade_gain)); } } Ok(Cmd::Scratch { secs, rate }) => { @@ -811,11 +867,26 @@ fn audio_thread( } Err(mpsc::RecvTimeoutError::Timeout) => { // Normalization slew: glide toward the analyzer's latest - // target at ~2.5 dB/s (0.15 dB per 60 ms tick) — audible as - // a slow drift at worst, never a jump. + // target, slowly enough that the move is not itself audible. + // + // This ran at ~2.5 dB/s (0.15 dB per 60 ms tick), and at that + // rate a mid-track refinement was heard as a drift — which is + // why mid-track correction was switched off entirely on + // 2026-07-23 rather than slowed down. It is back on because + // the alternative is worse: a streamed first play is gained + // from a 10 s prefix, which reads a median 3.4 dB too quiet + // across the cached corpus, and sometimes from nothing at all. + // Being 3 dB wrong for a whole track is a bigger error than a + // correction nobody can hear. + // + // 0.25 dB/s sits under the threshold at which a gradual level + // change reads as a change rather than as the music: the + // median 3.4 dB correction takes ~14 s and the worst observed + // (~6 dB) about 24 s, both spread thinly enough to be masked + // by the track's own dynamics. let diff = norm_target_db - norm_db; if diff.abs() > 0.001 { - norm_db += diff.clamp(-0.15, 0.15); + norm_db += diff.clamp(-NORM_SLEW_PER_TICK_DB, NORM_SLEW_PER_TICK_DB); if let Some(s) = &sink { s.set_volume(eff(volume, norm_db, fade_gain)); } @@ -824,6 +895,11 @@ fn audio_thread( Err(mpsc::RecvTimeoutError::Disconnected) => break, } + // Mirror the normalization pair after every command and every tick, so + // the debug overlay sees the slew moving rather than only its endpoint. + norm_now_report.store(norm_db.to_bits(), Ordering::Relaxed); + norm_target_report.store(norm_target_db.to_bits(), Ordering::Relaxed); + // In system-default mode, periodically compare endpoint names. CPAL's // existing stream remains attached to the old endpoint when Windows' // default changes, so reopening is required. diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 81559b4..2d799a0 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -82,6 +82,13 @@ pub struct DebugStats { /// Main player's live fade-gain (1.0 = full; <1 only during a preview /// open/close crossfade). Proves a fade never strands the volume. pub main_fade_gain: Option, + /// The normalization gain the sink has RIGHT NOW, and the one the audio + /// thread is slewing toward, both dB. Reported as a pair because the + /// interesting state during a mid-track correction is the gap between + /// them: equal means settled, different means a correction is in flight + /// and the difference is how far it still has to travel. + pub norm_now_db: Option, + pub norm_target_db: 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. @@ -170,6 +177,12 @@ pub fn debug_stats(app: tauri::AppHandle, state: State<'_, DiagState>) -> DebugS normalization_enabled, target_lufs, loudness, + norm_now_db: app + .try_state::() + .map(|s| s.player.norm_gain_now_target_db().0), + norm_target_db: app + .try_state::() + .map(|s| s.player.norm_gain_now_target_db().1), preview_volume: app .try_state::() .map(|s| crate::preview::target_volume(&s)), diff --git a/src-tauri/src/loudness.rs b/src-tauri/src/loudness.rs index 6601ce2..7abc648 100644 --- a/src-tauri/src/loudness.rs +++ b/src-tauri/src/loudness.rs @@ -200,6 +200,19 @@ const PRELIM_WALL_BUDGET: std::time::Duration = std::time::Duration::from_millis /// making it wait, and the gain it starts with is the best cut the evidence /// so far justifies. const PRELIM_HARD_DEADLINE: std::time::Duration = std::time::Duration::from_millis(2500); +/// The most silence a play may ever cost, measured from the REQUEST. +/// +/// Anchoring [`PRELIM_HARD_DEADLINE`] to the first byte fixed tracks starting +/// un-normalized, but uncapped it also moved the cost rather than removing it: +/// a peer that starts slowly and then delivers quickly had the gate holding +/// audio that was ready to play (measured: `held_ms` 3743 and 4098 with 3.4 MB +/// and 1.4 MB already received). Before the first byte, holding is free — +/// playback is blocked on those same bytes. After it, holding is a real wait. +/// +/// So the two clocks run together and whichever expires first wins: the +/// measurement gets its window from the moment data starts flowing, and the +/// listener never waits longer than this whatever happens. +const PRELIM_ABSOLUTE_CEILING: std::time::Duration = std::time::Duration::from_millis(4000); /// A prefix may only ever CUT, never boost. /// /// The head of a track is not evidence about the rest of it, and the error is @@ -228,6 +241,43 @@ fn prelim_gain_db(m: &Measured) -> f64 { gain_db(m).min(PRELIM_MAX_BOOST_DB) } +/// The mid-track correction follows the running measurement from its FIRST +/// publish, and may only ever cut further — never give gain back — until the +/// whole track has been measured. +/// +/// There was a convergence detector here: "the last 3 publishes are within +/// 0.3 dB, after 30 s". It did not work, and the traces showed why. An +/// INTEGRATED average over 30 s cannot move much when one more second is added +/// to it — the test was detecting the arithmetic inertia of a long average, +/// not convergence, so it fired at the 30 s floor every time and declared +/// settled while still 1.5 dB and 2.1 dB from the true value on two measured +/// tracks. A test that is always true is not a test. +/// +/// Following from the start needs no such test, because the protection was +/// never the gate: +/// +/// * the audio thread slews at 0.25 dB/s, so the applied gain physically +/// cannot chase a noisy target — the slew IS a low-pass filter, and an early +/// wild estimate is smoothed rather than obeyed; +/// * the ratchet below blocks the one direction that goes badly wrong. +/// +/// The ratchet matters more than it looks. Capping the TARGET at 0 dB is not +/// enough while following: a quiet passage reads high, asks for a boost, gets +/// clamped to 0 — and slewing from an existing −5 dB cut up to 0 turns the +/// track UP during its quietest part. Monotone-down forbids that, and it +/// matches how the estimate actually moves: measured across 89 cached tracks +/// the head under-reads on 57 of them (median +2.9 dB too much gain), so the +/// running value converges downward in gain almost every time. +/// +/// Released at `Analysis::Complete`, where a real boost becomes earnable. +fn following_target_db(current: Option, m: &Measured) -> f64 { + let wanted = prelim_gain_db(m); // cut-only + match current { + Some(prev) => prev.min(wanted), + None => wanted, + } +} + fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, size_hint: u64) { let final_size = std::fs::metadata(key).map(|m| m.len()).unwrap_or(size_hint); let measured = Measured { @@ -248,6 +298,20 @@ fn store_measurement(app: &tauri::AppHandle, key: &str, lufs: f64, peak: f32, si save_cache(app, &state); } +/// What the preliminary window actually bought, per track. +/// +/// `PRELIM_MIN_SECS` and `PRELIM_WALL_BUDGET` are coupled — the gate commits at +/// whichever arrives first — so the constant that binds in practice cannot be +/// read off either one. This reports it: how much audio was analyzed, which +/// condition fired, and what it cost in wall time. Enabled with +/// `SOULSTREAM_LOUDNESS_TRACE=1`; zero-cost when off. +fn loudness_trace(msg: &str) { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + if *ON.get_or_init(|| std::env::var("SOULSTREAM_LOUDNESS_TRACE").is_ok_and(|v| v == "1")) { + eprintln!("LOUDNESS_TRACE\t{msg}"); + } +} + /// The held start of an unmeasured track, and the best evidence anyone has /// found for it so far. Two things race to open it — the analyzer, once it /// has heard enough, and the startup deadline — so both go through @@ -256,24 +320,55 @@ struct Gate { play: Option>, /// Most recent prefix measurement, or None if nothing has gated yet. heard: Option, + /// Seconds of audio behind `heard`, for the trace. + heard_secs: f64, + /// When the play was REQUESTED, so the trace can report the delay the + /// listener actually experienced rather than analyzer-relative time. + requested_at: std::time::Instant, + /// The transfer behind a streamed track, read at commit time. + /// + /// This is the number that says WHY a commit measured nothing. If the + /// deadline fires with only a few KB received, the analyzer was starved of + /// bytes and the fix is upstream in the transfer. If it fires with + /// megabytes in hand, the bytes were there and something between the file + /// and the first gating block ate the time — a format probe walking a + /// large ID3 tag full of embedded artwork being the obvious suspect on + /// Soulseek MP3s. The two have completely different fixes. + progress: Option>, } impl Gate { /// Start playback at the gain the evidence so far justifies — which for /// a prefix is a cut or nothing. Returns the committed gain if this call /// was the one that opened the gate, and None if it had already opened. - fn release(gate: &Mutex, app: &tauri::AppHandle) -> Option { + fn release(gate: &Mutex, app: &tauri::AppHandle, why: &str) -> Option { // The closure is taken under the lock and called outside it: `play` // reaches into the player, and nothing here should hold a lock that // the audio side might want. - let (play, heard) = { + let (play, heard, secs, waited, got) = { let mut g = gate.lock().expect("loudness gate lock"); match g.play.take() { - Some(play) => (play, g.heard), + Some(play) => ( + play, + g.heard, + g.heard_secs, + g.requested_at.elapsed(), + g.progress.as_ref().map(|p| p.received_done()), + ), None => return None, // someone else already started it } }; let db = heard.as_ref().map(prelim_gain_db).unwrap_or(0.0); + loudness_trace(&format!( + "commit\twhy={why}\tsecs={secs:.1}\theld_ms={}\tgain={db:+.2}\thead_lufs={}\trecv={}", + waited.as_millis(), + heard + .as_ref() + .map(|m| format!("{:.1}", m.lufs)) + .unwrap_or_else(|| "none".into()), + got.map(|(bytes, done)| format!("{}KB done={done}", bytes / 1024)) + .unwrap_or_else(|| "local".into()), + )); play(); app.state::() .player @@ -355,13 +450,37 @@ pub fn on_track_started( // Unmeasured → the analyzer races ahead of playback (download/disk // speed) and RELEASES the held play once the preliminary window is met. + // One request clock, shared by the gate's trace and the deadline's ceiling. + let requested_at = std::time::Instant::now(); let gate = Arc::new(Mutex::new(Gate { play: Some(play), heard: None, + heard_secs: 0.0, + requested_at, + progress: stream.as_ref().map(|(_, p)| p.clone()), })); // The other end of the race: the audible start is never held longer than // the deadline, however little the analyzer has managed to hear. + // + // The clock starts at the FIRST BYTE of a stream, not at the play request. + // Anchoring it to the request measured the wrong thing: on a peer that + // takes a second to start sending, most of the 2.5 s was spent waiting for + // data that had not arrived, and the deadline then fired with nothing + // analyzed and started the track at unity — measured, three plays out of + // four on one run and zero out of three on the next, the only difference + // being how quickly the peers began. + // + // Firing early bought nothing in exchange. Playback is blocked on the same + // bytes — `GrowingFile::open` waits for the first one before the decoder + // can even open the file — so releasing the gate while the transfer is + // still silent does not make sound arrive sooner. It only discards the + // measurement. + // + // The case the deadline genuinely exists for still works: a track opening + // below the −70 LUFS gate (a fade-in, a pre-gap) never produces a first + // measurement, and this now bounds it from the moment data starts flowing. + let deadline_progress = stream.as_ref().map(|(_, p)| p.clone()); { let app = app.clone(); let gate = gate.clone(); @@ -369,9 +488,40 @@ pub fn on_track_started( std::thread::Builder::new() .name("loudness-deadline".into()) .spawn(move || { - std::thread::sleep(PRELIM_HARD_DEADLINE); + // Never past this, whatever the transfer does. + let ceiling = requested_at + PRELIM_ABSOLUTE_CEILING; + + // A local file has every byte already; a stream starts its + // clock when the transfer does. Polled rather than waiting on + // the transfer's condvar, because that primitive is private to + // the player crate and this is a once-per-track thread where + // 25 ms of granularity costs nothing. + if let Some(progress) = deadline_progress { + loop { + let (received, done) = progress.received_done(); + if received > 0 || done { + break; + } + if gen_ctr.load(Ordering::Relaxed) != generation { + return; // a newer track owns the player + } + // The peer has still sent nothing and the ceiling is + // up. Stop waiting and release on it: a gate that + // never opens is a track that never plays. + if std::time::Instant::now() >= ceiling { + break; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + } + + let now = std::time::Instant::now(); + let until = std::cmp::min(now + PRELIM_HARD_DEADLINE, ceiling); + if let Some(wait) = until.checked_duration_since(now) { + std::thread::sleep(wait); + } if gen_ctr.load(Ordering::Relaxed) == generation { - Gate::release(&gate, &app); + Gate::release(&gate, &app, "hard-deadline"); } }) .expect("spawn loudness deadline"); @@ -397,6 +547,8 @@ pub fn on_track_started( let cancelled = &cancelled; let gate = &gate; let mut first_measure: Option = None; + // The mid-track correction's current target, ratcheted down. + let mut follow_db: Option = None; let mut on_update = |lufs: f64, peak: f32, secs: f64| { if cancelled() { return; @@ -410,15 +562,43 @@ pub fn on_track_started( { let mut g = gate.lock().expect("loudness gate lock"); if g.play.is_none() { - return; // started already: NO further volume touches + // The gate is open: this is the refinement path, + // followed from the first publish. See + // `following_target_db` for why there is no + // convergence test guarding it. + drop(g); + let next = following_target_db(follow_db, &m); + if follow_db != Some(next) { + if follow_db.is_none() { + loudness_trace(&format!( + "follow-start\tsecs={secs:.1}\tlufs={lufs:.1}\tgain={next:+.2}" + )); + } + follow_db = Some(next); + app.state::() + .player + .set_norm_gain_db(next as f32, false); + } + return; } g.heard = Some(m); + g.heard_secs = secs; } let first = *first_measure.get_or_insert_with(std::time::Instant::now); if secs < PRELIM_MIN_SECS && first.elapsed() < PRELIM_WALL_BUDGET { return; // keep listening a moment longer } - applied_db = Gate::release(gate, app).or(applied_db); + // Which of the two coupled limits actually ended the wait. + // This is the number the whole question turns on: if it is + // always `min-secs`, the window can grow for free; if it is + // `wall-budget`, growing it needs the budget raised too and + // that is startup latency the listener pays. + let why = if secs >= PRELIM_MIN_SECS { + "min-secs" + } else { + "wall-budget" + }; + applied_db = Gate::release(gate, app, why).or(applied_db); }; let outcome = analyze_loudness(source, cancelled, &mut on_update); @@ -426,7 +606,7 @@ pub fn on_track_started( // failure, dead transfer): start at unity — unless a newer // track superseded this one, then never start it at all. if !cancelled() { - applied_db = Gate::release(gate, app).or(applied_db); + applied_db = Gate::release(gate, app, "analysis-ended").or(applied_db); } // ONLY a measurement that reached the end of the media @@ -441,6 +621,36 @@ pub fn on_track_started( return; }; store_measurement(app, &key, lufs, peak, size); + // The whole track is in, so the cut-only rule is retired: this + // measurement has earned a boost if it needs one. Still slewed, + // never stepped — `false` hands it to the audio thread's + // 0.25 dB/s glide. Guarded by `cancelled()` so a track that has + // already been superseded never touches the new one's volume. + if !cancelled() { + let m = Measured { + lufs, + peak, + size: 0, + at_epoch_ms: 0, + }; + app.state::() + .player + .set_norm_gain_db(gain_db(&m) as f32, false); + } + // The gap between what this play used and what every later + // play will use — i.e. exactly how far a stage-2 correction + // would have to travel, and how long it would take at a given + // dB/s. Negative means this play was LOUDER than it should be. + let final_db = normalization_gain_db(lufs, peak); + loudness_trace(&format!( + "final\tlufs={lufs:.1}\tgain={final_db:+.2}\tprelim={}\tcorrection={}", + applied_db + .map(|d| format!("{d:+.2}")) + .unwrap_or_else(|| "none".into()), + applied_db + .map(|d| format!("{:+.2}", final_db - d)) + .unwrap_or_else(|| "n/a".into()), + )); if !cancelled() { *app.state::() .current @@ -581,6 +791,119 @@ mod tests { assert_eq!(prelim_gain_db(&m(20.0, 1.0)), MIN_GAIN_DB); } + /// How wrong is a head of length N about the whole track? + /// + /// The question behind `PRELIM_MIN_SECS`. A streamed first play is gained + /// from a PREFIX; every later play is gained from the whole track. The gap + /// between those two numbers is exactly the "first track is louder" + /// complaint, and this measures it per candidate window so the constant is + /// chosen from a curve instead of a guess. + /// + /// Manual, and ignored in CI — it decodes an entire corpus. Point it at a + /// directory of real audio: + /// + /// ```text + /// $env:SOULSTREAM_LOUDNESS_CORPUS = "D:\path\to\tracks" + /// cargo test -p app head_window_bias -- --ignored --nocapture + /// ``` + /// + /// Reported per window: how far the prefix gain lands from the true gain, + /// signed so POSITIVE means the first play is LOUDER than it should be. + #[test] + #[ignore] + fn head_window_bias_across_the_corpus() { + const WINDOWS: [f64; 6] = [10.0, 15.0, 20.0, 30.0, 45.0, 60.0]; + + let dir = std::env::var("SOULSTREAM_LOUDNESS_CORPUS") + .expect("set SOULSTREAM_LOUDNESS_CORPUS to a directory of audio files"); + let mut files: Vec = Vec::new(); + let mut stack = vec![PathBuf::from(&dir)]; + while let Some(at) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&at) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!(ext.as_str(), "flac" | "mp3" | "ogg" | "opus" | "wav" | "m4a" | "aiff") + { + files.push(path); + } + } + } + files.sort(); + assert!(!files.is_empty(), "no audio found under {dir}"); + eprintln!("corpus: {} files under {dir}\n", files.len()); + + // errs[i] = prefix gain − true gain, for every track that reached + // window i. Positive means the first play is louder than it should be. + let mut errs: Vec> = vec![Vec::new(); WINDOWS.len()]; + let mut complete = 0usize; + + for path in &files { + let mut heads: Vec> = vec![None; WINDOWS.len()]; + let mut on_update = |lufs: f64, peak: f32, secs: f64| { + for (i, window) in WINDOWS.iter().enumerate() { + if heads[i].is_none() && secs >= *window { + heads[i] = Some((lufs, peak)); + } + } + }; + let done = analyze_loudness( + AnalysisSource::Local(path.clone()), + &|| false, + &mut on_update, + ); + let Analysis::Complete { lufs, peak } = done else { + eprintln!(" skipped (incomplete): {}", path.display()); + continue; + }; + complete += 1; + let truth = gain_db(&m(lufs, peak)); + for (i, head) in heads.iter().enumerate() { + let Some((hl, hp)) = *head else { continue }; + errs[i].push(prelim_gain_db(&m(hl, hp)) - truth); + } + } + + assert!(complete > 0, "no track decoded to completion"); + eprintln!("\n{complete} tracks measured end to end\n"); + eprintln!( + "{:>7} {:>5} {:>8} {:>8} {:>8} {:>8} {:>10}", + "window", "n", "median", "mean", "p90", "worst", ">1dB loud" + ); + for (i, window) in WINDOWS.iter().enumerate() { + let mut e = errs[i].clone(); + if e.is_empty() { + eprintln!("{window:>6}s {:>5} (no track reached this window)", 0); + continue; + } + e.sort_by(|a, b| a.partial_cmp(b).expect("no NaN gains")); + let n = e.len(); + let median = e[n / 2]; + let mean = e.iter().sum::() / n as f64; + let p90 = e[(n * 9 / 10).min(n - 1)]; + let worst = e[n - 1]; + let loud = e.iter().filter(|v| **v > 1.0).count(); + eprintln!( + "{window:>6}s {n:>5} {median:>8.2} {mean:>8.2} {p90:>8.2} {worst:>8.2} {loud:>10}" + ); + } + eprintln!( + "\nPositive = the first (streamed) play is that many dB LOUDER than\n\ + every later play of the same track. Pick PRELIM_MIN_SECS where the\n\ + curve flattens, not where it is smallest." + ); + } + /// Whatever the prefix policy allows, it can never exceed what the same /// numbers would get from a whole-track measurement. #[test] diff --git a/src/components/DebugOverlay.svelte b/src/components/DebugOverlay.svelte index d7c4574..9055dfb 100644 --- a/src/components/DebugOverlay.svelte +++ b/src/components/DebugOverlay.svelte @@ -88,6 +88,24 @@ const dlTotal = $derived(stats?.dlTotal ?? 0); const dlDone = $derived(stats?.dlReceived ?? 0); const loud = $derived(stats?.loudness ?? null); + + // ---- normalization slew readout ---- + // The audio thread mirrors both the applied gain and the one it is heading + // for; a correction in flight is exactly the difference between them. + const fmtDb = (db) => `${db >= 0 ? "+" : ""}${db.toFixed(2)} dB`; + const slewing = $derived( + stats?.normNowDb != null + && stats?.normTargetDb != null + // The audio thread stops slewing below this, so anything smaller is + // settled rather than creeping. + && Math.abs(stats.normTargetDb - stats.normNowDb) > 0.001, + ); + /** Seconds left at the player's 0.25 dB/s slew (0.015 dB per 60ms tick). */ + const slewSecs = $derived( + slewing ? Math.abs(stats.normTargetDb - stats.normNowDb) / 0.25 : 0, + ); + /** Position on a −15…+9 dB scale — the clamp `set_norm_gain_db` enforces. */ + const dbPct = (db) => Math.max(0, Math.min(100, ((db + 15) / 24) * 100)); @@ -135,6 +153,33 @@ {#if stats.normalizationEnabled && loud}
peak {loud.peak.toFixed(2)} · {loud.source}
{/if} + {#if stats.normalizationEnabled && stats.normNowDb != null} + +
+ SLEW + {fmtDb(stats.normNowDb)} + {#if slewing} + → {fmtDb(stats.normTargetDb)} + + {fmtDb(stats.normTargetDb - stats.normNowDb)} to go · + ~{slewSecs.toFixed(0)}s + + {:else} + settled + {/if} +
+
+ + + + +
+ {#if loud?.analyzing} +
analyzing — gain may still ratchet down
+ {/if} + {/if} {#if previewUi.open}
PREVIEW @@ -208,6 +253,38 @@ height: 100%; background: #7fd0ff; } + .num { + font-variant-numeric: tabular-nums; + color: #d7e0ea; + } + .num.hot { + color: #ffc46b; + } + /* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the + distance between them is the correction still to travel. */ + .bar.slew { + position: relative; + height: 7px; + overflow: visible; + margin: 4px 0 2px; + } + .bar.slew i { + position: absolute; + top: -1px; + width: 2px; + height: 9px; + border-radius: 1px; + transition: left 220ms linear; + } + .bar.slew .now { background: #7fd0ff; } + .bar.slew .tgt { background: #ffc46b; } + /* Unity, so a cut and a boost are told apart at a glance. */ + .bar.slew .zero { + background: rgba(255, 255, 255, 0.3); + height: 5px; + top: 1px; + transition: none; + } /* The overlay is passthrough glass (pointer-events: none on the aside) — only its buttons take clicks. */ button {