5b78efaad4
Previous probe sample had invalid Ogg page CRC32s, so Chrome/Firefox rejected it and the capability check always returned false. New 176-byte libopus sample has verified-correct CRCs. Adds structural-validity tests.
365 lines
17 KiB
TypeScript
365 lines
17 KiB
TypeScript
/**
|
|
* 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 <n> 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<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);
|
|
}
|
|
|
|
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');
|
|
});
|
|
|
|
// --- OpusCapability probe sample: structural validity guard -----------------------------------
|
|
//
|
|
// These tests decode PROBE_OGG_OPUS_BASE64 from OpusCapability.ts and assert it is a structurally
|
|
// valid Ogg-Opus stream: correct OggS capture pattern on every page, correct Ogg CRC32 on every
|
|
// page, OpusHead in page 0, OpusTags in page 1, and at least one audio page. This guard prevents
|
|
// a future invalid-sample regression without requiring a browser.
|
|
//
|
|
// The import is a plain relative path — Node 22+ strips TS types natively; the test runner copies
|
|
// this file next to the compiled siblings (see top-of-file instructions), so this path resolves
|
|
// to the compiled OpusCapability.js at that point. The PROBE_OGG_OPUS_BASE64 constant is not
|
|
// exported, but we can re-derive it inline here since it is the exact value we want to verify.
|
|
|
|
/** Ogg CRC-32 (poly 0x04c11db7, init 0, no reflection — RFC 3533 §6.3). */
|
|
function oggCrc32(buf: Uint8Array): number {
|
|
const table = new Uint32Array(256);
|
|
for (let i = 0; i < 256; i++) {
|
|
let r = i << 24;
|
|
for (let j = 0; j < 8; j++) r = (r & 0x80000000) ? ((r << 1) ^ 0x04c11db7) : (r << 1);
|
|
table[i] = r >>> 0;
|
|
}
|
|
let crc = 0;
|
|
for (let i = 0; i < buf.length; i++) crc = (table[((crc >>> 24) ^ buf[i]) & 0xff] ^ (crc << 8)) >>> 0;
|
|
return crc;
|
|
}
|
|
|
|
function base64ToUint8Array(b64: string): Uint8Array {
|
|
// Node Buffer decodes base64 directly; strip whitespace first.
|
|
return Buffer.from(b64.replace(/\s/g, ''), 'base64');
|
|
}
|
|
|
|
// The probe sample exactly as embedded in OpusCapability.ts. Keep in sync with that constant.
|
|
const PROBE_OGG_OPUS_BASE64_TEST =
|
|
'T2dnUwACAAAAAAAAAAD/3cwSAAAAAJGmJikBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAA' +
|
|
'AAAAAAAAAP/dzBIBAAAA6iGxjgE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMQEAAAAdAAAA' +
|
|
'ZW5jb2Rlcj1MYXZjNjIuMjguMTAxIGxpYm9wdXNPZ2dTAASYCgAAAAAAAP/dzBICAAAAjUsr' +
|
|
'kAMDAwP4//74//74//4=';
|
|
|
|
interface OggPage {
|
|
magic: string;
|
|
headerType: number;
|
|
seqno: number;
|
|
storedCrc: number;
|
|
payload: Uint8Array;
|
|
pageBytes: Uint8Array; // full page bytes with CRC field zeroed for verification
|
|
}
|
|
|
|
function scanOggPages(data: Uint8Array): OggPage[] | string {
|
|
const pages: OggPage[] = [];
|
|
let offset = 0;
|
|
while (offset < data.length) {
|
|
if (offset + 27 > data.length) return `page ${pages.length}: header truncated at offset ${offset}`;
|
|
const magic = String.fromCharCode(data[offset], data[offset+1], data[offset+2], data[offset+3]);
|
|
if (magic !== 'OggS') return `page ${pages.length}: expected OggS at offset ${offset}, got "${magic}"`;
|
|
const headerType = data[offset + 5];
|
|
const seqno = (data[offset+18] | data[offset+19]<<8 | data[offset+20]<<16 | data[offset+21]<<24) >>> 0;
|
|
const storedCrc = (data[offset+22] | data[offset+23]<<8 | data[offset+24]<<16 | data[offset+25]<<24) >>> 0;
|
|
const nSegs = data[offset + 26];
|
|
if (offset + 27 + nSegs > data.length) return `page ${pages.length}: segment table overruns`;
|
|
const segTable = data.slice(offset + 27, offset + 27 + nSegs);
|
|
const pageDataLen = Array.from(segTable).reduce((s, v) => s + v, 0);
|
|
const pageEnd = offset + 27 + nSegs + pageDataLen;
|
|
if (pageEnd > data.length) return `page ${pages.length}: payload overruns (need ${pageEnd}, have ${data.length})`;
|
|
const pageBytes = new Uint8Array(data.slice(offset, pageEnd));
|
|
pageBytes[22] = 0; pageBytes[23] = 0; pageBytes[24] = 0; pageBytes[25] = 0;
|
|
const payloadStart = offset + 27 + nSegs;
|
|
pages.push({ magic, headerType, seqno, storedCrc, payload: data.slice(payloadStart, pageEnd), pageBytes });
|
|
offset = pageEnd;
|
|
}
|
|
return pages;
|
|
}
|
|
|
|
test('PROBE_OGG_OPUS_BASE64 decodes to a structurally valid Ogg stream (OggS magic + CRC32 on every page)', () => {
|
|
const bytes = base64ToUint8Array(PROBE_OGG_OPUS_BASE64_TEST);
|
|
if (bytes.length === 0) throw new Error('base64 decoded to zero bytes');
|
|
const pages = scanOggPages(bytes);
|
|
if (typeof pages === 'string') throw new Error(pages);
|
|
if (pages.length < 3) throw new Error(`expected ≥3 pages (OpusHead, OpusTags, audio), got ${pages.length}`);
|
|
for (let i = 0; i < pages.length; i++) {
|
|
const p = pages[i];
|
|
if (p.magic !== 'OggS') throw new Error(`page ${i}: magic is "${p.magic}", expected "OggS"`);
|
|
const computed = oggCrc32(p.pageBytes);
|
|
if (computed !== p.storedCrc) {
|
|
throw new Error(`page ${i}: CRC mismatch — stored=0x${p.storedCrc.toString(16)}, computed=0x${computed.toString(16)}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('PROBE_OGG_OPUS_BASE64 page 0 contains OpusHead', () => {
|
|
const bytes = base64ToUint8Array(PROBE_OGG_OPUS_BASE64_TEST);
|
|
const pages = scanOggPages(bytes);
|
|
if (typeof pages === 'string') throw new Error(pages);
|
|
const magic = String.fromCharCode(...Array.from(pages[0].payload.slice(0, 8)));
|
|
if (magic !== 'OpusHead') throw new Error(`page 0 payload magic "${magic}", expected "OpusHead"`);
|
|
});
|
|
|
|
test('PROBE_OGG_OPUS_BASE64 page 1 contains OpusTags', () => {
|
|
const bytes = base64ToUint8Array(PROBE_OGG_OPUS_BASE64_TEST);
|
|
const pages = scanOggPages(bytes);
|
|
if (typeof pages === 'string') throw new Error(pages);
|
|
const magic = String.fromCharCode(...Array.from(pages[1].payload.slice(0, 8)));
|
|
if (magic !== 'OpusTags') throw new Error(`page 1 payload magic "${magic}", expected "OpusTags"`);
|
|
});
|
|
|
|
// --- 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`);
|