/** * OpusFormatDecoder / OpusSidecar tests. * * There is no TS test runner configured in this repo (no package.json, no jest/vitest, no other * *.test.ts). Rather than introduce a heavy harness, this file is a self-contained, zero-dependency * test: a ~15-line inline assert/test harness, no `node:` imports, no DOM. It exercises the pure * parser / resolver / alignment logic (none of which touches the DOM or Web Audio). * * 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 * test's `.js` import specifiers must resolve to the COMPILED decoder modules, so run a copy from * the compiled output directory: * * # 1. produce the compiled decoder 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/OpusFormatDecoder.test.ts DeepDrftPublic/wwwroot/js/audio/ * node DeepDrftPublic/wwwroot/js/audio/OpusFormatDecoder.test.ts * * A thrown error / non-zero exit signals failure; "ALL TESTS PASSED" signals success. (The * copied file lives only in the gitignored wwwroot/js output; the source under Interop is the * committed artifact.) * * 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. * The C# serializer is the source of truth; this verifies the TS parser is its exact counterpart. */ import { parseSidecar, presentationTimeSeconds, OPUS_SAMPLE_RATE } from './OpusSidecar.js'; import type { OpusSeekData } from './OpusSidecar.js'; import { OpusFormatDecoder } from './OpusFormatDecoder.js'; import type { FormatInfo } from './IFormatDecoder.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); } function formatInfoFor(sidecar: Uint8Array): FormatInfo { const decoder = new OpusFormatDecoder(); const parsed = assertNotNull(parseSidecar(sidecar), 'sidecar should parse'); decoder.setSidecar(parsed); return assertNotNull(decoder.tryParseHeader([], 0), 'tryParseHeader should build FormatInfo'); } // --- 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'); }); // --- calculateByteOffset: binary search over the precomputed index (exact, not interpolation) - test('calculateByteOffset 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 info = formatInfoFor(makeSidecar({ setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points, })); const d = new OpusFormatDecoder(); assertEqual(d.calculateByteOffset(info, 0.0), 4096, 't=0 -> first point'); assertEqual(d.calculateByteOffset(info, 0.4), 4096, 'just before bucket 1'); assertEqual(d.calculateByteOffset(info, 0.5), 9096, 'exactly bucket 1'); assertEqual(d.calculateByteOffset(info, 0.9), 9096, 'within bucket 1'); assertEqual(d.calculateByteOffset(info, 1.0), 14096, 'exactly bucket 2'); assertEqual(d.calculateByteOffset(info, 99), 19096, 'past end -> last point'); }); test('calculateByteOffset never interpolates between points', () => { const info = formatInfoFor(makeSidecar({ setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0, points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }], })); const d = new OpusFormatDecoder(); assertEqual(d.calculateByteOffset(info, 0.5), 100, 'midpoint snaps to lower page start'); }); test('calculateByteOffset degrades to audioDataOffset with an empty index', () => { const info = formatInfoFor(makeSidecar({ setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [], })); const d = new OpusFormatDecoder(); assertEqual(info.audioDataOffset, 5, 'audioDataOffset == setup header length'); assertEqual(d.calculateByteOffset(info, 10), info.audioDataOffset, 'empty index degrades'); }); // --- getAlignedSegmentSize: Ogg "OggS" page-boundary alignment -------------------------------- function withOggS(len: number, ...pageStarts: number[]): Uint8Array { const out = new Uint8Array(len).fill(0xaa); for (const s of pageStarts) { out[s] = 0x4f; out[s + 1] = 0x67; out[s + 2] = 0x67; out[s + 3] = 0x53; } return out; } const stubInfo = { audioDataOffset: 0 } as FormatInfo; test('getAlignedSegmentSize cuts at the last OggS page start within the window', () => { const raw = withOggS(64, 4, 40); assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 40, 'last page start'); }); test('getAlignedSegmentSize waits (returns 0) when no page boundary is found mid-stream', () => { const raw = withOggS(64); assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'no boundary'); }); test('getAlignedSegmentSize flushes the whole candidate on stream completion without a boundary', () => { const raw = withOggS(64); assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, true, raw), 64, 'flush on complete'); }); test('getAlignedSegmentSize ignores a page start at offset 0 (needs a real cut point)', () => { const raw = withOggS(64, 0); assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'offset 0 skipped'); }); // --- wrapSegment: OpusHead/OpusTags setup-header carry ---------------------------------------- test('wrapSegment prepends the cached setup bytes to a page run', () => { const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" const info = formatInfoFor(makeSidecar({ setupHeader: setup, totalByteLength: 100, totalDuration: 1, preSkip: 0, points: [{ granule: 0, byteOffset: setup.length }], })); const pageRun = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x11, 0x22]); // "OggS" + payload const wrapped = new OpusFormatDecoder().wrapSegment(info, pageRun); assertArray(wrapped.subarray(0, setup.length), setup, 'setup header first'); assertArray(wrapped.subarray(setup.length), [0x4f, 0x67, 0x67, 0x53, 0x11, 0x22], 'page run follows'); }); // --- 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`);