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

+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 {