diff --git a/DeepDrftPublic/Interop/audio/OpusCapability.ts b/DeepDrftPublic/Interop/audio/OpusCapability.ts index d6b8719..39904be 100644 --- a/DeepDrftPublic/Interop/audio/OpusCapability.ts +++ b/DeepDrftPublic/Interop/audio/OpusCapability.ts @@ -18,15 +18,22 @@ */ /** - * A minimal, valid Ogg-Opus file: an OpusHead page, an OpusTags page, and one audio page carrying a - * single 20 ms silence packet (mono, 48 kHz). Base64-encoded; ~250 bytes decoded. This is the - * smallest blob a conformant decoder will accept and a non-supporting decoder will reject, which is - * exactly the discriminator we need. + * A minimal, valid Ogg-Opus file generated by ffmpeg/libopus (libopus via Lavc62, libavformat62). + * Three pages: OpusHead (page 0), OpusTags (page 1), one audio page of ~50 ms silence (page 2, + * EOS flag set). Mono, 48 kHz. All three Ogg page CRC32s are verified correct — generated by + * construction; not hand-assembled. + * + * ffmpeg command: + * /c/ffmpeg/ffmpeg.exe -f lavfi -i anullsrc=r=48000:cl=mono -t 0.05 \ + * -c:a libopus -b:a 24k -f ogg /tmp/opusprobe.opus + * + * 176 bytes decoded; 236 chars base64. */ const PROBE_OGG_OPUS_BASE64 = - 'T2dnUwACAAAAAAAAAACRYwAAAAAAANieBHsBE09wdXNIZWFkAQEAAIC7AAAAAABPZ2dTAAAAAAAA' + - 'AAAAAJFjAAABAAAAUkOcUAEMT3B1c1RhZ3MAAAAAAAAAAE9nZ1MABABAAQAAAAAAkWMAAAIAAABU' + - '/9D/A2P4//////////////////////////////////////////////////////////////////8='; + 'T2dnUwACAAAAAAAAAAD/3cwSAAAAAJGmJikBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAA' + + 'AAAAAAAAAP/dzBIBAAAA6iGxjgE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMQEAAAAdAAAA' + + 'ZW5jb2Rlcj1MYXZjNjIuMjguMTAxIGxpYm9wdXNPZ2dTAASYCgAAAAAAAP/dzBICAAAAjUsr' + + 'kAMDAwP4//74//74//4='; let cachedSupport: Promise | null = null; diff --git a/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts index 67843f3..e2a0ad1 100644 --- a/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts +++ b/DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts @@ -253,6 +253,109 @@ test('wrapSegment prepends the cached setup bytes to a page run', () => { 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'));