loudness: correct the gain during the track instead of only guessing at its start

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.
This commit is contained in:
KeelrLeek committed 2026-07-29 18:45:43 -05:00
1 parent 4f485b6f9a
commit 1e35c5e039
4 files changed
+502 -13

No files matched your search

+13
View File
@@ -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<f32>,
/// 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<f32>,
pub norm_target_db: Option<f32>,
/// 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.
@@ -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::<crate::playback::PlayerState>()
.map(|s| s.player.norm_gain_now_target_db().0),
norm_target_db: app
.try_state::<crate::playback::PlayerState>()
.map(|s| s.player.norm_gain_now_target_db().1),
preview_volume: app
.try_state::<crate::preview::PreviewState>()
.map(|s| crate::preview::target_volume(&s)),
+331 -8
View File
@@ -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<f64>, 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<bool> = 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<Box<dyn FnOnce() + Send>>,
/// Most recent prefix measurement, or None if nothing has gated yet.
heard: Option<Measured>,
/// 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<Arc<StreamProgress>>,
}
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<Gate>, app: &tauri::AppHandle) -> Option<f64> {
fn release(gate: &Mutex<Gate>, app: &tauri::AppHandle, why: &str) -> Option<f64> {
// 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::<PlayerState>()
.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<std::time::Instant> = None;
// The mid-track correction's current target, ratcheted down.
let mut follow_db: Option<f64> = 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::<PlayerState>()
.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::<PlayerState>()
.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::<LoudnessState>()
.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<PathBuf> = 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<f64>> = vec![Vec::new(); WINDOWS.len()];
let mut complete = 0usize;
for path in &files {
let mut heads: Vec<Option<(f64, f32)>> = 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::<f64>() / 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]