Files
deepdrft/DeepDrftPublic/Interop/audio/OpusCapability.ts
T
daniel-c-harvey 5b78efaad4 fix: replace hand-assembled Opus probe blob with real ffmpeg/libopus output
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.
2026-06-23 16:57:49 -04:00

103 lines
4.6 KiB
TypeScript

/**
* OpusCapability - runtime detection of Ogg-Opus decode support.
*
* The bespoke graph decodes segments via `AudioContext.decodeAudioData`. Ogg-Opus support there
* is long-standing in Chrome and Firefox but arrived in Safari only at 18.4 (macOS 15.4 / iOS 18.4,
* March 2025); older Safari decodes Opus only in a CAF container, not Ogg. iOS Safari is a primary
* music-listening surface, so a browser that cannot decode Ogg Opus must fall back to the lossless
* WAV path (§3.4 / OQ2).
*
* This module is the detection *seam* only — it answers "can this browser decode Ogg Opus?". The
* player (waves 18.5 / 18.6) consumes the answer to choose the delivery format; this module never
* touches the player or the stream request.
*
* Detection is a genuine probe: a tiny in-memory Ogg-Opus blob is handed to `decodeAudioData`. A
* UA/version gate was rejected because Safari's Opus story is version-specific and UA strings lie;
* a real decode attempt is authoritative. The result is cached after the first probe (capability
* does not change within a session).
*/
/**
* 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 =
'T2dnUwACAAAAAAAAAAD/3cwSAAAAAJGmJikBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAA' +
'AAAAAAAAAP/dzBIBAAAA6iGxjgE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMQEAAAAdAAAA' +
'ZW5jb2Rlcj1MYXZjNjIuMjguMTAxIGxpYm9wdXNPZ2dTAASYCgAAAAAAAP/dzBICAAAAjUsr' +
'kAMDAwP4//74//74//4=';
let cachedSupport: Promise<boolean> | null = null;
/**
* Resolve whether this browser can decode Ogg Opus via `decodeAudioData`. Cached after the first
* call. Never rejects — a probe failure resolves to `false` (treat as unsupported, fall back to
* lossless). Pass an existing `AudioContext`/`OfflineAudioContext` to avoid allocating one; if none
* is given, a short-lived `OfflineAudioContext` is created and torn down.
*/
export function canDecodeOggOpus(context?: BaseAudioContext): Promise<boolean> {
if (cachedSupport === null) {
cachedSupport = probe(context);
}
return cachedSupport;
}
async function probe(context?: BaseAudioContext): Promise<boolean> {
let ctx = context;
let ownsContext = false;
try {
if (!ctx) {
const OfflineCtor =
(globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext ??
(globalThis as { webkitOfflineAudioContext?: typeof OfflineAudioContext }).webkitOfflineAudioContext;
if (!OfflineCtor) return false;
// 1 channel, 1 frame, 48 kHz — the smallest legal context; we never render it.
ctx = new OfflineCtor(1, 1, OPUS_PROBE_SAMPLE_RATE);
ownsContext = true;
}
const buffer = base64ToArrayBuffer(PROBE_OGG_OPUS_BASE64);
// decodeAudioData detaches the buffer; the probe blob is single-use, so that is fine.
await decode(ctx, buffer);
return true;
} catch {
// DOMException (unsupported / corrupt) or any allocation failure -> unsupported.
return false;
} finally {
// OfflineAudioContext has no close() in all engines; guard it.
if (ownsContext && ctx && 'close' in ctx && typeof (ctx as AudioContext).close === 'function') {
try { await (ctx as AudioContext).close(); } catch { /* best-effort teardown */ }
}
}
}
const OPUS_PROBE_SAMPLE_RATE = 48000;
/** Promisify decodeAudioData; older Safari only supports the callback form. */
function decode(ctx: BaseAudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> {
return new Promise<AudioBuffer>((resolve, reject) => {
const result = ctx.decodeAudioData(buffer, resolve, reject);
// Modern engines return a Promise; bridge it so a rejection isn't dropped.
if (result && typeof (result as Promise<AudioBuffer>).then === 'function') {
(result as Promise<AudioBuffer>).then(resolve, reject);
}
});
}
function base64ToArrayBuffer(b64: string): ArrayBuffer {
const binary = atob(b64);
const buffer = new ArrayBuffer(binary.length);
const out = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return buffer;
}