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

+81 -5
View File
@@ -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<std::sync::atomic::AtomicU32>,
/// 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<std::sync::atomic::AtomicU32>,
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
}
impl Player {
@@ -250,14 +264,35 @@ impl Player {
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
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<PathBuf>, duration_hint: Option<f64>) {
self.play_local_from(path, duration_hint, None);
}
@@ -498,6 +543,8 @@ fn audio_thread(
event_tx: mpsc::Sender<PlayerEvent>,
initial_device: Option<String>,
fade_report: Arc<std::sync::atomic::AtomicU32>,
norm_now_report: Arc<std::sync::atomic::AtomicU32>,
norm_target_report: Arc<std::sync::atomic::AtomicU32>,
) {
// 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.
+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]
+77
View File
@@ -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));
</script>
<svelte:window onkeydown={onKeydown} />
@@ -135,6 +153,33 @@
{#if stats.normalizationEnabled && loud}
<div class="dim">peak {loud.peak.toFixed(2)} · {loud.source}</div>
{/if}
{#if stats.normalizationEnabled && stats.normNowDb != null}
<!-- The pair, not a single number: a mid-track correction is
only visible as the GAP between what the sink has and
where the slew is heading. Equal = settled. -->
<div>
<b>SLEW</b>
<span class="num">{fmtDb(stats.normNowDb)}</span>
{#if slewing}
→ <span class="num hot">{fmtDb(stats.normTargetDb)}</span>
<span class="dim">
{fmtDb(stats.normTargetDb - stats.normNowDb)} to go ·
~{slewSecs.toFixed(0)}s
</span>
{:else}
<span class="dim">settled</span>
{/if}
</div>
<div class="bar slew">
<!-- −15…+9 dB is the clamp the player enforces. -->
<i class="now" style="left:{dbPct(stats.normNowDb)}%"></i>
<i class="tgt" style="left:{dbPct(stats.normTargetDb)}%"></i>
<i class="zero" style="left:{dbPct(0)}%"></i>
</div>
{#if loud?.analyzing}
<div class="dim">analyzing — gain may still ratchet down</div>
{/if}
{/if}
{#if previewUi.open}
<div>
<b>PREVIEW</b>
@@ -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 {