/** * 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: 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. */ const PROBE_OGG_OPUS_BASE64 = 'T2dnUwACAAAAAAAAAACRYwAAAAAAANieBHsBE09wdXNIZWFkAQEAAIC7AAAAAABPZ2dTAAAAAAAA' + 'AAAAAJFjAAABAAAAUkOcUAEMT3B1c1RhZ3MAAAAAAAAAAE9nZ1MABABAAQAAAAAAkWMAAAIAAABU' + '/9D/A2P4//////////////////////////////////////////////////////////////////8='; let cachedSupport: Promise | 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 { if (cachedSupport === null) { cachedSupport = probe(context); } return cachedSupport; } async function probe(context?: BaseAudioContext): Promise { 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 { return new Promise((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).then === 'function') { (result as Promise).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; }