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.