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:
1 parent
4f485b6f9a
commit
1e35c5e039
4 files changed
+502
-13
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user