audio: widen forward decode cushion 30/15->60/30s + add [BP-DIAG] back-pressure instrumentation

Byte cap (96MB) unchanged as the hard OOM bound; the wider time window only lets sparse Opus use existing memory headroom to ride out decode jitter. Diag logs pin whether the block is back-pressure or decode throughput.
This commit is contained in:
daniel-c-harvey
2026-06-25 21:52:20 -04:00
parent f0d1463619
commit 61e185a2f7
4 changed files with 126 additions and 12 deletions
@@ -44,23 +44,33 @@ const DEFAULT_BACK_RETAIN_SECONDS = 10;
* for Opus, the demux/decode feed) pauses above the high-water mark and resumes below the
* low-water mark — classic hysteresis so the two producers do not chatter on/off per chunk.
*
* Provisional time-based defaults (OQ1 — 21.4 tunes them):
* - HIGH (30 s): the most decoded lookahead we hold ahead of the playhead before throttling.
* Time-based defaults — the cushion, NOT the memory bound:
* - HIGH (60 s): the most decoded lookahead we hold ahead of the playhead before throttling.
* Comfortably above the playback-start minimum (`AudioPlayer.minBuffersForPlayback = 6`
* buffers, each typically 0.06 1 s depending on format/chunk size; at most a few seconds
* even at the high end), so C2 holds — first audio never waits on a throttle (the high-water
* is reached only well after playback is already running).
* - LOW (15 s): resume producing here. Kept generous so the forward fill never drains to the
* ~500 ms scheduler lookahead under normal network jitter (AC3 — no starvation).
* buffers, each typically 0.06 1 s depending on format/chunk size), so C2 holds — first
* audio never waits on a throttle (the high-water is reached only well after playback runs).
* - LOW (30 s): resume producing here. Kept generous so the forward fill never drains to the
* ~500 ms scheduler lookahead under network/decode jitter (AC3 — no starvation).
*
* Why 60/30 and not the old 30/15: the time window is a CUSHION knob, not the memory guarantee —
* the OQ3 byte ceiling below is the hard OOM bound. The old 30 s was sized for WAV's byte density
* and needlessly starved the cushion for the async WebCodecs Opus path, whose decoded float
* footprint is tiny (48 kHz stereo ≈ 0.37 MB/s, so 60 s ≈ 23 MB — a fraction of the 96 MB cap)
* yet whose per-packet decode jitter (HW-accel-off software decode, main-thread AudioData copies)
* needs a deeper buffer to stay ahead of the playhead. Doubling the window lets Opus use the memory
* headroom the byte cap already permits. The byte cap is UNCHANGED, so high-density formats
* (lossless) still pause at exactly the same footprint as before — the OOM fix does not regress.
*
* OQ3 hard memory ceiling: an absolute byte cap on total decoded float held, independent of the
* time window. This is the guard-rail that makes "1 GB never OOMs" a guarantee rather than a
* tuning hope — if a pathological stream packs an unusual amount of decoded audio into the time
* window, the byte cap still pauses production. Estimated as channels × frames × 4 bytes (f32).
* tuning hope — production pauses on `lookahead >= high OR bytes > cap`, whichever fires first, so
* the footprint can never exceed the cap regardless of the time window. For dense lossless the
* byte cap fires before 60 s (bounding memory exactly as the old 30 s window's byte estimate did);
* for sparse Opus the time window fires first, at ~23 MB. Estimated as channels × frames × 4 (f32).
*/
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 30;
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 15;
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM
const DEFAULT_FORWARD_HIGH_WATER_SECONDS = 60;
const DEFAULT_FORWARD_LOW_WATER_SECONDS = 30;
const DEFAULT_MAX_DECODED_BYTES = 96 * 1024 * 1024; // ~96 MB of decoded float PCM — the HARD OOM bound
const BYTES_PER_FLOAT_SAMPLE = 4;
/**
@@ -295,6 +305,7 @@ export class PlaybackScheduler {
evaluateProductionPause(): boolean {
const lookahead = this.getForwardLookaheadSeconds();
const overByteCeiling = this.maxDecodedBytes > 0 && this.getDecodedByteEstimate() > this.maxDecodedBytes;
const wasPaused = this.productionPaused_;
if (this.productionPaused_) {
// Stay paused until BOTH the time window has drained below low-water AND the byte
@@ -306,6 +317,19 @@ export class PlaybackScheduler {
this.productionPaused_ = true;
}
// [BP-DIAG] Log only the latch TRANSITIONS (not per-call) so a browser run shows exactly when
// production was throttled and the live numbers at that instant — the test for "production
// paused while decoded audio is actually low" (the prime block hypothesis). If a PAUSED line
// ever shows a small lookahead, the lookahead computation is the culprit; if it always shows
// ~high-water, back-pressure is innocent and the symptom is decode throughput. Trivially removable.
if (wasPaused !== this.productionPaused_) {
console.log(
`[BP-DIAG] production ${this.productionPaused_ ? 'PAUSED' : 'RESUMED'} ` +
`lookahead=${lookahead.toFixed(2)}s bytes=${(this.getDecodedByteEstimate() / 1048576).toFixed(1)}MB ` +
`buffers=${this.buffers.length} nextIdx=${this.nextBufferIndex} ` +
`pos=${this.getCurrentPosition().toFixed(2)}s overByteCeiling=${overByteCeiling}`);
}
return this.productionPaused_;
}
@@ -476,6 +500,12 @@ export class PlaybackScheduler {
if (!this.streamComplete && !this.hasMinimumPlaybackLead()) {
return; // still re-accumulating the rebuffer lead — remain parked
}
// [BP-DIAG] Underrun resume — the playhead drained mid-stream and we have now rebuilt the
// lead. Frequent RESUME lines (paired with the PARK lines below) are the "repeatedly hits end
// of buffer" thrash: decode is not staying ahead. Trivially removable.
console.log(
`[BP-DIAG] underrun RESUME lead=${this.getForwardLookaheadSeconds().toFixed(2)}s ` +
`buffers=${this.buffers.length} nextIdx=${this.nextBufferIndex} streamComplete=${this.streamComplete}`);
this.underrun_ = false;
this.isActive_ = true;
this.playbackAnchorTime = this.contextManager.currentTime;
@@ -580,6 +610,12 @@ export class PlaybackScheduler {
this.finishPlayback();
} else {
this.underrun_ = true;
// [BP-DIAG] Mid-stream underrun: the scheduled queue drained and decode has not caught up.
// This is the symptom Daniel reports. The paired RESUME line above shows how long the gap
// lasted and what lead it rebuilt to. Trivially removable.
console.log(
`[BP-DIAG] underrun PARK pos=${this.getCurrentPosition().toFixed(2)}s ` +
`buffers=${this.buffers.length} nextIdx=${this.nextBufferIndex}`);
// Hold the playhead at the decoded tail so getCurrentPosition stays exact during
// the gap. isActive_ goes false so no stale-anchor scheduling occurs; resume
// re-anchors at currentTime when buffers arrive.