5b78efaad4
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.
103 lines
4.6 KiB
TypeScript
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;
|
|
}
|