Phase 21.2: back-pressure to bound the unplayed decoded region

Shared scheduler fill signal (forward water-marks + hard byte cap) pauses
the C# read loop above high-water and, for Opus, stops the demux/decode
feed so WebCodecs queues stay near-empty. Routes through the existing
cancellation discipline; releases the latch on clear/seek.
This commit is contained in:
daniel-c-harvey
2026-06-23 23:16:08 -04:00
parent a2becf45d6
commit 518479e7ae
8 changed files with 436 additions and 8 deletions
@@ -36,6 +36,32 @@ import { AudioContextManager } from './AudioContextManager.js';
*/
const DEFAULT_BACK_RETAIN_SECONDS = 10;
/**
* Forward back-pressure water-marks (Phase 21.2 — the bound on the *unplayed* region).
*
* The single back-pressure signal is the scheduler's decoded forward lookahead: how many
* seconds of decoded audio sit AHEAD of the playhead (OQ7). Production (the C# read loop and,
* 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.
* Comfortably above the playback-start minimum (6 buffers ≈ a second or two), 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).
*
* 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).
*/
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 BYTES_PER_FLOAT_SAMPLE = 4;
interface ScheduledSource {
source: AudioBufferSourceNode;
bufferIndex: number;
@@ -66,6 +92,19 @@ export class PlaybackScheduler {
// 21.2 will drive this from the window policy. Not a hardcoded eviction decision.
private backRetainSeconds: number = DEFAULT_BACK_RETAIN_SECONDS;
// Forward back-pressure water-marks + the OQ3 hard byte ceiling (Phase 21.2). This is the
// single shared window policy (OQ6): both producers read isProductionPaused() and honor it
// in their own way — the C# read loop stops ReadAsync, the Opus feed stops demux/decode.
private forwardHighWaterSeconds: number = DEFAULT_FORWARD_HIGH_WATER_SECONDS;
private forwardLowWaterSeconds: number = DEFAULT_FORWARD_LOW_WATER_SECONDS;
private maxDecodedBytes: number = DEFAULT_MAX_DECODED_BYTES;
// Hysteresis latch for the production pause. Once forward fill crosses the high-water mark we
// stay paused until it drains below the low-water mark, so the two producers do not flap
// on/off around a single threshold (and a paused producer does not resume for one chunk only
// to re-pause immediately). False until first crossing; flips on the band edges.
private productionPaused_: boolean = false;
// Callbacks
public onPlaybackEnded: (() => void) | null = null;
@@ -132,6 +171,68 @@ export class PlaybackScheduler {
this.backRetainSeconds = Math.max(0, seconds);
}
/**
* Configure the forward back-pressure water-marks (seconds of decoded lookahead) and the OQ3
* hard byte ceiling. Provisional config seam — 21.4 tunes the numbers. Low is clamped below
* high so the hysteresis band is always valid; non-positive byte cap disables the OQ3 guard.
*/
setForwardWindow(highWaterSeconds: number, lowWaterSeconds: number, maxDecodedBytes: number): void {
this.forwardHighWaterSeconds = Math.max(0, highWaterSeconds);
this.forwardLowWaterSeconds = Math.max(0, Math.min(lowWaterSeconds, this.forwardHighWaterSeconds));
this.maxDecodedBytes = maxDecodedBytes;
}
/**
* Seconds of decoded audio sitting AHEAD of the current playhead — the forward fill. This is
* the single back-pressure signal (OQ7): the absolute end time of the last decoded buffer
* minus the current playback position. Never negative (clamped at 0 when the playhead has
* caught up to or passed the decoded tail).
*/
getForwardLookaheadSeconds(): number {
const decodedEnd = this.getTotalDuration() + this.playbackOffset;
return Math.max(0, decodedEnd - this.getCurrentPosition());
}
/**
* Estimated bytes of decoded float PCM currently retained (OQ3 input). Web Audio AudioBuffers
* are 32-bit float per sample per channel; frames = duration × sampleRate. Summed across the
* retained buffers only — evicted buffers are already reclaimed, so this tracks the live
* footprint, not the whole track.
*/
getDecodedByteEstimate(): number {
let bytes = 0;
for (const b of this.buffers) {
bytes += b.length * b.numberOfChannels * BYTES_PER_FLOAT_SAMPLE;
}
return bytes;
}
/**
* The single shared production-pause decision (Phase 21.2, OQ6/OQ7). Both producers — the C#
* read loop (21.2a) and the Opus demux/decode feed (21.2b) — call this and stop producing
* while it returns true. Hysteresis: pause when forward lookahead exceeds the high-water mark
* OR the decoded byte estimate exceeds the OQ3 ceiling; resume only once forward lookahead has
* drained below the low-water mark AND the byte estimate is back under the ceiling. The
* byte-ceiling test has no separate low-water band — it is the hard guard rail, so it releases
* as soon as eviction brings the footprint back under the cap.
*/
isProductionPaused(): boolean {
const lookahead = this.getForwardLookaheadSeconds();
const overByteCeiling = this.maxDecodedBytes > 0 && this.getDecodedByteEstimate() > this.maxDecodedBytes;
if (this.productionPaused_) {
// Stay paused until BOTH the time window has drained below low-water AND the byte
// footprint is back under the ceiling.
if (lookahead <= this.forwardLowWaterSeconds && !overByteCeiling) {
this.productionPaused_ = false;
}
} else if (lookahead >= this.forwardHighWaterSeconds || overByteCeiling) {
this.productionPaused_ = true;
}
return this.productionPaused_;
}
/**
* Drop already-played buffers from the front of the array, reclaiming their decoded float
* memory, and advance the time anchor so all position/index bookkeeping stays exact.
@@ -418,6 +519,9 @@ export class PlaybackScheduler {
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
this.playbackOffset = 0;
// Release the back-pressure latch — a fresh stream must start unthrottled so its first
// chunks decode immediately (C2: no throttle-induced first-audio stall).
this.productionPaused_ = false;
}
/**
@@ -432,6 +536,9 @@ export class PlaybackScheduler {
this.nextBufferIndex = 0;
this.nextScheduleTime = 0;
// Note: playbackOffset is NOT reset - it will be set by the caller
// Release the back-pressure latch — the post-seek continuation must refill from the new
// offset without inheriting the pre-seek paused state.
this.productionPaused_ = false;
}
/**