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
@@ -448,6 +448,65 @@ test('OpusStreamDecoder.totalDuration is null when the sidecar carries no positi
assertEqual(decoder.totalDuration, null, 'no positive duration -> null');
});
// --- Phase 21.2b: Opus decode-ahead back-pressure (the stash-while-full half) ------------------
//
// When the shared scheduler is full, push() must NOT demux/decode ahead — it stashes the raw bytes
// and returns nothing, so the WebCodecs decode queue and decodedQueue stay near-empty (OQ7). The
// stash-while-full branch returns BEFORE ensureConfigured(), so it is testable without WebCodecs
// (no AudioDecoder is constructed). The drain-on-resume path needs the real WebCodecs decoder and
// stays browser-verified; here we pin the bound itself and the lifecycle resets.
// Access the private stash for white-box assertions (same idiom the scheduler tests use).
function stashLength(decoder: OpusStreamDecoder): number {
return (decoder as unknown as { pendingBytes: Uint8Array[] }).pendingBytes.length;
}
// The stash-while-full branch returns synchronously at the top of push() (before any real await),
// so the stash is observable immediately without awaiting the returned promise — keeping these
// tests inside the synchronous inline harness (which does not await test bodies).
test('push stashes bytes and decodes nothing while the scheduler is full (no decode-ahead)', () => {
const sidecar = sidecarFrom({
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
points: [{ granule: 312, byteOffset: 4096 }],
});
// Scheduler reports "full" → push must short-circuit before touching WebCodecs.
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
void decoder.push(new Uint8Array([1, 2, 3]));
void decoder.push(new Uint8Array([4, 5]));
assertEqual(stashLength(decoder), 2, 'both chunks stashed in arrival order');
assertEqual(decoder.ready, false, 'decoder not even configured while throttled');
});
test('reinitializeForRangeContinuation drops the pre-seek stash (C6 — no stale feed across reset)', () => {
const sidecar = sidecarFrom({
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
points: [{ granule: 312, byteOffset: 4096 }],
});
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
void decoder.push(new Uint8Array([1, 2, 3])); // stash one chunk while full
assertEqual(stashLength(decoder), 1, 'one chunk stashed pre-seek');
decoder.reinitializeForRangeContinuation(0, 5); // a seek
assertEqual(stashLength(decoder), 0, 'pre-seek stash dropped on range-continuation');
});
test('dispose clears the stash', () => {
const sidecar = sidecarFrom({
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
totalByteLength: 500_000, totalDuration: 100, preSkip: 312,
points: [{ granule: 312, byteOffset: 4096 }],
});
const decoder = new OpusStreamDecoder(stubContextManager, sidecar, () => true);
void decoder.push(new Uint8Array([9]));
assertEqual(stashLength(decoder), 1, 'stashed');
decoder.dispose();
assertEqual(stashLength(decoder), 0, 'stash cleared on dispose');
});
function concat(arrs: Uint8Array[]): Uint8Array {
let len = 0;
for (const a of arrs) len += a.length;