Files
daniel-c-harvey 518479e7ae 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.
2026-06-23 23:16:08 -04:00

525 lines
25 KiB
TypeScript

/**
* Opus WebCodecs decode-path tests — the browser-independent pieces.
*
* The WebCodecs decode/playback/seek itself can only run in a real browser (verified by Daniel), so
* these tests cover the pure logic that surrounds it and that determines correctness:
* - OggSidecar parse: byte-for-byte round-trip against the C# wire format.
* - resolveOpusByteOffset: the seek transfer function (binary search over the precomputed index).
* - OggDemuxer: Ogg page -> Opus packet extraction (segment-table lacing, packets spanning pages,
* granule tracking, OpusHead/OpusTags setup-packet skipping, continuation reset).
* - extractOpusHead / opusHeadChannelCount: pulling the WebCodecs `description` out of the sidecar.
*
* There is no TS test runner configured in this repo (no package.json, no jest/vitest). This is a
* self-contained, zero-dependency test: a tiny inline assert harness, no `node:` imports beyond Buffer
* (Node global). It is EXCLUDED from the production tsc build (tsconfig `exclude: Interop/**\/*.test.ts`)
* so it never ships in wwwroot/js. To run it (Node 22+ strips TS types natively — no tsc, no deps), the
* `.js` import specifiers must resolve to the COMPILED modules, so run a copy from the compiled output:
*
* # 1. produce the compiled modules (the normal build already does this):
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
* # 2. run this test next to the compiled .js siblings (Node strips the types at load):
* cp DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts DeepDrftPublic/wwwroot/js/audio/
* node DeepDrftPublic/wwwroot/js/audio/OpusStreamDecoder.test.ts
*
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success.
*
* The sidecar bytes built in `makeSidecar` reproduce the C# wire format byte-for-byte
* (DeepDrftContent.Processors.Opus.OpusSidecar.ToBytes / OggOpusSeekIndex.ToBytes):
* [uint32 setupHeaderLength][setup bytes]
* [uint64 totalByteLength][double totalDuration][uint32 count][uint16 preSkip][uint16 reserved]
* count x [uint64 granulePosition][uint64 byteOffset] — all little-endian.
*/
import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js';
import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
let passed = 0;
const failures: string[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
passed++;
} catch (e) {
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
}
}
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
if (actual !== expected) {
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
}
}
function assertArray(actual: ArrayLike<number>, expected: number[], msg?: string): void {
const a = Array.from(actual);
if (a.length !== expected.length || a.some((v, i) => v !== expected[i])) {
throw new Error(`${msg ?? 'assertArray'}: expected [${expected}], got [${a}]`);
}
}
function assertNull(actual: unknown, msg?: string): void {
if (actual !== null) throw new Error(`${msg ?? 'assertNull'}: expected null, got ${String(actual)}`);
}
function assertNotNull<T>(actual: T | null, msg?: string): T {
if (actual === null) throw new Error(`${msg ?? 'assertNotNull'}: got null`);
return actual;
}
interface SidecarSpec {
setupHeader: number[];
totalByteLength: number;
totalDuration: number;
preSkip: number;
points: Array<{ granule: number; byteOffset: number }>;
}
/** Serialize a sidecar blob exactly as the C# OpusSidecar/OggOpusSeekIndex writers do. */
function makeSidecar(spec: SidecarSpec): Uint8Array {
const SEEK_INDEX_HEADER_SIZE = 24;
const SEEK_POINT_SIZE = 16;
const setupLen = spec.setupHeader.length;
const total = 4 + setupLen + SEEK_INDEX_HEADER_SIZE + spec.points.length * SEEK_POINT_SIZE;
const bytes = new Uint8Array(total);
const view = new DataView(bytes.buffer);
view.setUint32(0, setupLen, true);
bytes.set(spec.setupHeader, 4);
let p = 4 + setupLen;
writeUint64(view, p, spec.totalByteLength);
view.setFloat64(p + 8, spec.totalDuration, true);
view.setUint32(p + 16, spec.points.length, true);
view.setUint16(p + 20, spec.preSkip, true);
// bytes 22-23 reserved (zero)
p += SEEK_INDEX_HEADER_SIZE;
for (const pt of spec.points) {
writeUint64(view, p, pt.granule);
writeUint64(view, p + 8, pt.byteOffset);
p += SEEK_POINT_SIZE;
}
return bytes;
}
function writeUint64(view: DataView, offset: number, value: number): void {
view.setUint32(offset, value >>> 0, true);
view.setUint32(offset + 4, Math.floor(value / 0x100000000), true);
}
// --- parseSidecar: byte-for-byte round-trip against the C# layout -----------------------------
test('parseSidecar round-trips the C# binary layout exactly', () => {
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" stand-in
const spec: SidecarSpec = {
setupHeader: setup,
totalByteLength: 1_234_567,
totalDuration: 212.5,
preSkip: 312,
points: [
{ granule: 312, byteOffset: 4096 }, // first point: granule == preSkip -> t=0
{ granule: 312 + 24000, byteOffset: 9000 }, // +0.5 s
{ granule: 312 + 48000, byteOffset: 14000 }, // +1.0 s
],
};
const parsed: OpusSeekData = assertNotNull(parseSidecar(makeSidecar(spec)));
assertEqual(parsed.kind, 'opus-sidecar', 'kind');
assertArray(parsed.setupHeaderBytes, setup, 'setup header bytes');
assertEqual(parsed.totalByteLength, spec.totalByteLength, 'totalByteLength');
assertEqual(parsed.totalDurationSeconds, spec.totalDuration, 'totalDuration');
assertEqual(parsed.preSkip, spec.preSkip, 'preSkip');
assertEqual(parsed.points.length, 3, 'point count');
assertEqual(parsed.points[1].granulePosition, 312 + 24000, 'point[1].granule');
assertEqual(parsed.points[1].byteOffset, 9000, 'point[1].byteOffset');
});
test('parseSidecar honours a borrowed view byteOffset (sidecar not at buffer start)', () => {
const blob = makeSidecar({
setupHeader: [1, 2, 3, 4],
totalByteLength: 100,
totalDuration: 1.0,
preSkip: 0,
points: [{ granule: 0, byteOffset: 8 }],
});
const padded = new Uint8Array(blob.length + 7);
padded.set(blob, 7);
const parsed = assertNotNull(parseSidecar(padded.subarray(7)));
assertArray(parsed.setupHeaderBytes, [1, 2, 3, 4], 'borrowed setup bytes');
assertEqual(parsed.points[0].byteOffset, 8, 'borrowed point offset');
});
test('parseSidecar returns null on a truncated blob', () => {
const blob = makeSidecar({
setupHeader: [0],
totalByteLength: 1,
totalDuration: 0,
preSkip: 0,
points: [{ granule: 0, byteOffset: 0 }],
});
assertNull(parseSidecar(blob.subarray(0, 3)), 'short of length prefix');
assertNull(parseSidecar(blob.subarray(0, blob.length - 4)), 'declared count overruns');
});
test('presentationTimeSeconds applies preSkip and clamps at zero (RFC 7845)', () => {
assertEqual(presentationTimeSeconds(312, 312), 0, 'granule == preSkip');
assertEqual(presentationTimeSeconds(0, 312), 0, 'below preSkip clamps');
assertEqual(presentationTimeSeconds(312 + OPUS_SAMPLE_RATE, 312), 1.0, '+48000 -> 1 s');
});
// --- resolveOpusByteOffset: binary search over the precomputed index (exact, not interpolation) -
function sidecarFrom(spec: SidecarSpec): OpusSeekData {
return assertNotNull(parseSidecar(makeSidecar(spec)), 'sidecar should parse');
}
test('resolveOpusByteOffset returns the page-start of the largest entry with time <= t', () => {
const points = [0, 1, 2, 3].map(i => ({
granule: 1000 + i * (OPUS_SAMPLE_RATE / 2),
byteOffset: 4096 + i * 5000,
}));
const sc = sidecarFrom({
setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points,
});
assertEqual(resolveOpusByteOffset(sc, 0.0).byteOffset, 4096, 't=0 -> first point');
assertEqual(resolveOpusByteOffset(sc, 0.4).byteOffset, 4096, 'just before bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 9096, 'exactly bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.9).byteOffset, 9096, 'within bucket 1');
assertEqual(resolveOpusByteOffset(sc, 1.0).byteOffset, 14096, 'exactly bucket 2');
assertEqual(resolveOpusByteOffset(sc, 99).byteOffset, 19096, 'past end -> last point');
});
test('resolveOpusByteOffset never interpolates between points', () => {
const sc = sidecarFrom({
setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0,
points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }],
});
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 100, 'midpoint snaps to lower page start');
});
test('resolveOpusByteOffset degrades to start of audio with an empty index', () => {
const sc = sidecarFrom({
setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
});
// start of audio == setup header length (server emits [setup pages][audio pages]).
assertEqual(resolveOpusByteOffset(sc, 10).byteOffset, 5, 'empty index degrades to audio start');
});
// --- resolveOpusByteOffset: landingTimeSeconds (AC9 fine re-sync, §3.4a step 4) -----------------
test('resolveOpusByteOffset landingTimeSeconds equals the resolved page time, not the requested time', () => {
// Index: two points at t=0 s and t=0.5 s.
const preSkip = 312;
const sc = sidecarFrom({
setupHeader: [0], totalByteLength: 50_000, totalDuration: 1.5, preSkip,
points: [
{ granule: preSkip, byteOffset: 4096 }, // t=0
{ granule: preSkip + OPUS_SAMPLE_RATE / 2, byteOffset: 9000 }, // t=0.5 s
],
});
// Seeking to 0.3 s lands on the t=0 page; landing should be 0, not 0.3.
const r03: OpusSeekResolution = resolveOpusByteOffset(sc, 0.3);
assertEqual(r03.byteOffset, 4096, 'seek 0.3 -> first page offset');
assertEqual(r03.landingTimeSeconds, 0, 'landing at t=0 (page time, not target)');
// Seeking to exactly 0.5 s lands on the second page; landing == requested time.
const r05: OpusSeekResolution = resolveOpusByteOffset(sc, 0.5);
assertEqual(r05.byteOffset, 9000, 'seek 0.5 -> second page offset');
assertEqual(r05.landingTimeSeconds, 0.5, 'landing == requested when exact page boundary');
});
test('resolveOpusByteOffset empty index returns landingTimeSeconds = 0', () => {
const sc = sidecarFrom({
setupHeader: [0, 1, 2], totalByteLength: 1000, totalDuration: 1.0, preSkip: 0, points: [],
});
const r = resolveOpusByteOffset(sc, 5.0);
assertEqual(r.landingTimeSeconds, 0, 'empty index: landing is stream start (0 s)');
});
// --- Lead-trim frame math (AC9 fine re-sync) ---------------------------------------------------
// The trim frame count is purely arithmetic: (target - landing) * 48000, rounded, clamped to ≥0.
// This is the exact formula in OpusStreamDecoder.reinitializeForRangeContinuation so we test it
// independently of the browser-bound WebCodecs decode.
function leadTrimFrames(landingTimeSeconds: number, targetTimeSeconds: number): number {
return Math.max(0, Math.round((targetTimeSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE));
}
test('lead-trim frame count is (target - landing) * 48000, rounded', () => {
// Page at t=0, seek to 0.3 s: trim 0.3 * 48000 = 14400 frames.
assertEqual(leadTrimFrames(0, 0.3), 14400, 'trim for 0.3 s offset');
// Page at t=0.5 s, seek to 0.7 s: trim 0.2 * 48000 = 9600 frames.
assertEqual(leadTrimFrames(0.5, 0.7), 9600, 'trim for 0.2 s offset');
// Exact page boundary: no trim needed.
assertEqual(leadTrimFrames(0.5, 0.5), 0, 'no trim when target == landing');
// Guard against floating-point rounding producing a tiny negative: clamp to 0.
assertEqual(leadTrimFrames(0.5000001, 0.5), 0, 'negative rounds to zero (guard)');
});
// --- OggDemuxer: page -> packet extraction ----------------------------------------------------
//
// Builds minimal Ogg pages by hand (no codec) so the lacing logic is exercised deterministically.
interface PageSpec {
granule: number; // -1 (0xFFFF...) means "no granule"
continued?: boolean; // header-type bit 0x01
eos?: boolean; // header-type bit 0x04
/** Packet payloads to lace into this page (each split into 255-byte segments per Ogg rules). */
packets?: Uint8Array[];
/** Raw segment lengths + payload, for hand-crafting page-spanning packets. */
rawSegments?: number[];
rawPayload?: Uint8Array;
}
function buildPage(spec: PageSpec): Uint8Array {
let segTable: number[];
let payload: Uint8Array;
if (spec.rawSegments && spec.rawPayload) {
segTable = spec.rawSegments;
payload = spec.rawPayload;
} else {
segTable = [];
const chunks: number[] = [];
for (const pkt of spec.packets ?? []) {
let remaining = pkt.length;
let o = 0;
// Lace: emit 255-byte segments until the final (< 255) segment terminates the packet.
for (;;) {
const seg = Math.min(255, remaining);
segTable.push(seg);
for (let i = 0; i < seg; i++) chunks.push(pkt[o + i]);
o += seg;
remaining -= seg;
if (seg < 255) break; // terminating segment
}
}
payload = new Uint8Array(chunks);
}
const header = new Uint8Array(OGG_HDR + segTable.length + payload.length);
header.set([0x4f, 0x67, 0x67, 0x53], 0); // "OggS"
header[4] = 0; // version
header[5] = (spec.continued ? 0x01 : 0) | (spec.eos ? 0x04 : 0);
// granule (LE uint64)
if (spec.granule < 0) {
for (let i = 0; i < 8; i++) header[6 + i] = 0xff;
} else {
let g = spec.granule;
for (let i = 0; i < 8; i++) { header[6 + i] = g & 0xff; g = Math.floor(g / 256); }
}
header[26] = segTable.length;
header.set(segTable, OGG_HDR);
header.set(payload, OGG_HDR + segTable.length);
return header;
}
const OGG_HDR = 27;
function opusHeadPacket(channels: number, preSkip: number): Uint8Array {
// "OpusHead"(8) version(1) channels(1) preSkip(2 LE) inputRate(4) gain(2) mapping(1) = 19 bytes
const p = new Uint8Array(19);
p.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
p[8] = 1;
p[9] = channels;
p[10] = preSkip & 0xff;
p[11] = (preSkip >> 8) & 0xff;
return p;
}
function opusTagsPacket(): Uint8Array {
const p = new Uint8Array(16);
p.set([0x4f, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73], 0); // "OpusTags"
return p;
}
test('OggDemuxer skips OpusHead/OpusTags and returns audio packets with the page granule', () => {
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 312)] });
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
const audio = buildPage({ granule: 24000, packets: [new Uint8Array([0xaa, 0xbb]), new Uint8Array([0xcc])] });
const d = new OggDemuxer();
const packets = d.push(concat([head, tags, audio]));
assertEqual(packets.length, 2, 'two audio packets, setup skipped');
assertArray(packets[0].data, [0xaa, 0xbb], 'first audio packet bytes');
assertEqual(packets[0].pageGranule, null, 'non-final packet carries no granule');
assertArray(packets[1].data, [0xcc], 'second audio packet bytes');
assertEqual(packets[1].pageGranule, 24000, 'final completing packet carries the page granule');
assertEqual(packets[1].isLastPage, false, 'not EOS');
});
test('OggDemuxer flags the EOS page', () => {
const head = buildPage({ granule: 0, packets: [opusHeadPacket(1, 100)] });
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
const audio = buildPage({ granule: 48000, eos: true, packets: [new Uint8Array([0x01])] });
const d = new OggDemuxer();
const packets = d.push(concat([head, tags, audio]));
assertEqual(packets.length, 1, 'one audio packet');
assertEqual(packets[0].isLastPage, true, 'EOS flagged');
});
test('OggDemuxer reassembles a packet that spans two pages (255 last segment + continuation)', () => {
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 0)] });
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
// First audio page: one 255-byte segment that does NOT terminate (packet continues).
const part1 = new Uint8Array(255).fill(0x11);
const pageA = buildPage({ granule: -1, rawSegments: [255], rawPayload: part1 });
// Second page (continued): a 10-byte terminating segment completes the packet.
const part2 = new Uint8Array(10).fill(0x22);
const pageB = buildPage({ granule: 24000, continued: true, rawSegments: [10], rawPayload: part2 });
const d = new OggDemuxer();
const packets = d.push(concat([head, tags, pageA, pageB]));
assertEqual(packets.length, 1, 'one reassembled packet');
assertEqual(packets[0].data.length, 265, 'packet is 255 + 10 bytes');
assertEqual(packets[0].data[0], 0x11, 'first byte from page A');
assertEqual(packets[0].data[264], 0x22, 'last byte from page B');
assertEqual(packets[0].pageGranule, 24000, 'granule from the completing page');
});
test('OggDemuxer handles bytes split across push() calls (page straddles a network chunk)', () => {
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 0)] });
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
const audio = buildPage({ granule: 960, packets: [new Uint8Array([0x07, 0x08, 0x09])] });
const full = concat([head, tags, audio]);
const d = new OggDemuxer();
const cut = full.length - 2; // split mid-audio-page
const first = d.push(full.subarray(0, cut));
assertEqual(first.length, 0, 'no whole audio packet yet');
const second = d.push(full.subarray(cut));
assertEqual(second.length, 1, 'audio packet completes on the second push');
assertArray(second[0].data, [0x07, 0x08, 0x09], 'reassembled across pushes');
});
test('OggDemuxer.reset(continuation) treats the first page as audio (no setup expected)', () => {
const audio = buildPage({ granule: 96000, packets: [new Uint8Array([0x42])] });
const d = new OggDemuxer();
d.reset(true);
const packets = d.push(audio);
assertEqual(packets.length, 1, 'continuation: first page is audio');
assertArray(packets[0].data, [0x42], 'audio packet bytes');
});
// --- extractOpusHead / opusHeadChannelCount: WebCodecs description from the sidecar -----------
test('extractOpusHead returns the OpusHead packet from the setup pages', () => {
const head = buildPage({ granule: 0, packets: [opusHeadPacket(2, 312)] });
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
const setup = concat([head, tags]);
const opusHead = assertNotNull(extractOpusHead(setup), 'OpusHead extracted');
assertArray(opusHead.subarray(0, 8), [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 'OpusHead magic');
assertEqual(opusHeadChannelCount(opusHead), 2, 'channel count');
});
test('extractOpusHead returns null when no OpusHead page is present', () => {
const tags = buildPage({ granule: 0, packets: [opusTagsPacket()] });
assertNull(extractOpusHead(tags), 'no OpusHead');
});
// --- OpusStreamDecoder.totalDuration: available from the sidecar BEFORE the first push ----------
//
// Defect 1 (dead Opus seekbar): the C# layer locks the UI Duration on the first chunk whose result
// carries a value, and AudioPlayer.processOpusChunk now surfaces `decoder.totalDuration` on that first
// chunk rather than gating it on the (async, possibly-empty-on-chunk-1) decoded buffers. The load-bearing
// guarantee that makes this correct is that `totalDuration` is known from the sidecar IMMEDIATELY — i.e.
// before any push and without WebCodecs. These tests pin that contract; the WebCodecs decode itself stays
// browser-verified. The constructor only stashes the context manager (totalDuration never touches it), so a
// null-shaped stub is safe and no AudioDecoder is constructed.
const stubContextManager = {} as unknown as ConstructorParameters<typeof OpusStreamDecoder>[0];
test('OpusStreamDecoder.totalDuration is the sidecar duration, available before any push', () => {
const sidecar = sidecarFrom({
setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64],
totalByteLength: 500_000, totalDuration: 212.5, preSkip: 312,
points: [{ granule: 312, byteOffset: 4096 }],
});
const decoder = new OpusStreamDecoder(stubContextManager, sidecar);
// No push, no configure — the value the first chunk reports to C# must already be present.
assertEqual(decoder.totalDuration, 212.5, 'totalDuration from sidecar, pre-push');
});
test('OpusStreamDecoder.totalDuration is null when the sidecar carries no positive duration', () => {
const sidecar = sidecarFrom({
setupHeader: [0], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
});
const decoder = new OpusStreamDecoder(stubContextManager, sidecar);
// A zero/absent sidecar duration must report null (not 0) so the chunk result carries no spurious
// value — the WAV-header path, not a bogus Opus duration, then drives the UI.
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;
const out = new Uint8Array(len);
let o = 0;
for (const a of arrs) { out.set(a, o); o += a.length; }
return out;
}
// --- report ----------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
}
console.log(`ALL ${passed} TESTS PASSED`);