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
@@ -39,6 +39,17 @@ const MAX_PACKET_FRAMES = 5760;
export class OpusStreamDecoder implements IStreamingDecoder {
private readonly contextManager: AudioContextManager;
private readonly sidecar: OpusSeekData;
// Phase 21.2b back-pressure hook: returns true when the shared scheduler is full (forward fill
// over high-water). While full, push() stashes raw bytes WITHOUT demuxing/decoding so the
// WebCodecs decode queue and decodedQueue stay near-empty behind a throttled socket (OQ7).
// Null = no back-pressure (e.g. unit tests), in which case the decoder feeds eagerly as before.
private readonly isSchedulerFull: (() => boolean) | null;
// Raw bytes received while the scheduler was full, held undemuxed until it drains. The C# read
// loop also pauses above high-water, so this stash is bounded to at most the in-flight chunks
// between the loop reading the productionPaused flag and actually stopping — a handful of KB,
// not a decode-ahead. Drained (demuxed + decoded) on the next push once below high-water.
private pendingBytes: Uint8Array[] = [];
private demuxer = new OggDemuxer();
private decoder: AudioDecoder | null = null;
@@ -66,9 +77,13 @@ export class OpusStreamDecoder implements IStreamingDecoder {
private emittedFrames = 0;
private readonly totalFrames: number;
constructor(contextManager: AudioContextManager, sidecar: OpusSeekData) {
constructor(
contextManager: AudioContextManager,
sidecar: OpusSeekData,
isSchedulerFull: (() => boolean) | null = null) {
this.contextManager = contextManager;
this.sidecar = sidecar;
this.isSchedulerFull = isSchedulerFull;
this.totalFrames = sidecar.totalDurationSeconds > 0
? Math.round(sidecar.totalDurationSeconds * OPUS_SAMPLE_RATE)
: Number.POSITIVE_INFINITY;
@@ -136,17 +151,59 @@ export class OpusStreamDecoder implements IStreamingDecoder {
async push(chunk: Uint8Array): Promise<AudioBuffer[]> {
if (this.fatalError) return [];
// 21.2b back-pressure: while the scheduler is full, do NOT demux/decode ahead. Stash the
// raw bytes in arrival order and return nothing — the WebCodecs decode queue and
// decodedQueue stay near-empty (OQ7). The bytes are demuxed/decoded on a later push once
// the scheduler has drained below low-water, in exactly the order received (Ogg demux is
// order-sensitive). configure() is deferred too — no need to spin up the decoder while
// throttled. The C# loop also stops reading above high-water, so the stash stays small.
if (this.isSchedulerFull?.()) {
this.pendingBytes.push(chunk);
return [];
}
if (!(await this.ensureConfigured())) return [];
const packets = this.demuxer.push(chunk);
this.decodePackets(packets);
// Drained below high-water: replay any stashed bytes first (preserving stream order), then
// the new chunk, through the demuxer as one contiguous feed.
const out: AudioBuffer[] = [];
if (this.pendingBytes.length > 0) {
const stashed = this.pendingBytes;
this.pendingBytes = [];
for (const bytes of stashed) {
this.decodePackets(this.demuxer.push(bytes));
}
}
this.decodePackets(this.demuxer.push(chunk));
// Wait until the WebCodecs decoder has processed the queued packets before draining.
await this.yieldToDecoder();
return this.drainDecoded();
out.push(...this.drainDecoded());
return out;
}
async complete(): Promise<AudioBuffer[]> {
if (this.fatalError || !this.decoder || this.decoder.state !== 'configured') {
if (this.fatalError) {
return this.drainDecoded();
}
// End-of-stream may arrive while still throttled with bytes stashed (e.g. a short track
// that finished sending before the scheduler drained). Configure if needed and replay the
// stash so the tail is decoded before flush — otherwise the final seconds would be lost.
if (this.pendingBytes.length > 0) {
if (await this.ensureConfigured()) {
const stashed = this.pendingBytes;
this.pendingBytes = [];
for (const bytes of stashed) {
this.decodePackets(this.demuxer.push(bytes));
}
} else {
this.pendingBytes = [];
}
}
if (!this.decoder || this.decoder.state !== 'configured') {
return this.drainDecoded();
}
try {
@@ -184,6 +241,10 @@ export class OpusStreamDecoder implements IStreamingDecoder {
// continuation mode (treat the first page's packets as audio — no setup pages in a 206 body).
this.demuxer.reset(true);
this.decodedQueue = [];
// Drop any bytes stashed by back-pressure: they belong to the PRE-seek stream position and
// must never be replayed against the post-seek (range-continuation) demux state (C6 — no
// stale feed racing the reset).
this.pendingBytes = [];
this.emittedFrames = 0; // post-seek buffers are positioned by the scheduler's playbackOffset
// Arm the lead trim: skip enough decoded frames to land at targetTimeSeconds, not at
// landingTimeSeconds (the page start). Clamp to ≥0 to guard against floating-point rounding.
@@ -199,6 +260,7 @@ export class OpusStreamDecoder implements IStreamingDecoder {
try { d.close(); } catch { /* already closed */ }
}
this.decodedQueue = [];
this.pendingBytes = [];
if (this.decoder && this.decoder.state !== 'closed') {
try { this.decoder.close(); } catch { /* already closed */ }
}