/** * 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 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 } from './OpusSidecar.js'; import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.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, 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(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), 4096, 't=0 -> first point'); assertEqual(resolveOpusByteOffset(sc, 0.4), 4096, 'just before bucket 1'); assertEqual(resolveOpusByteOffset(sc, 0.5), 9096, 'exactly bucket 1'); assertEqual(resolveOpusByteOffset(sc, 0.9), 9096, 'within bucket 1'); assertEqual(resolveOpusByteOffset(sc, 1.0), 14096, 'exactly bucket 2'); assertEqual(resolveOpusByteOffset(sc, 99), 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), 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), 5, 'empty index degrades to audio start'); }); // --- 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'); }); 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`);