feature: OpusFormatDecoder — Ogg-page-aligned segmenting, sidecar parser, accurate index-based seek (Phase 18.4)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user