feature: OpusFormatDecoder — Ogg-page-aligned segmenting, sidecar parser, accurate index-based seek (Phase 18.4)

This commit is contained in:
daniel-c-harvey
2026-06-23 08:34:39 -04:00
parent e807ddb91b
commit 261289c1b8
8 changed files with 723 additions and 5 deletions
@@ -0,0 +1,95 @@
/**
* 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<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;
}