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
+35 -3
View File
@@ -14,6 +14,8 @@ import { IFormatDecoder } from './IFormatDecoder.js';
import { WavFormatDecoder } from './WavFormatDecoder.js';
import { Mp3FormatDecoder } from './Mp3FormatDecoder.js';
import { FlacFormatDecoder } from './FlacFormatDecoder.js';
import { OpusFormatDecoder } from './OpusFormatDecoder.js';
import { OpusSeekData, parseSidecar } from './OpusSidecar.js';
export interface AudioResult {
success: boolean;
@@ -62,6 +64,11 @@ export class AudioPlayer {
private onEndCallback: EndCallback | null = null;
private progressInterval: number | null = null;
// Pending Opus sidecar (setup header + seek index), parsed from the one-time sidecar fetch and
// applied to the OpusFormatDecoder when the next Opus stream initializes. Wave 18.5 sets this
// (via setOpusSidecar) before initializeStreaming; this class never fetches it.
private pendingOpusSidecar: OpusSeekData | null = null;
constructor() {
this.contextManager = new AudioContextManager();
this.streamDecoder = new StreamDecoder(this.contextManager);
@@ -103,7 +110,7 @@ export class AudioPlayer {
// Initialize new stream with the format decoder selected from Content-Type.
this.isStreamingMode = true;
const formatDecoder = AudioPlayer.createFormatDecoder(contentType);
const formatDecoder = this.createFormatDecoder(contentType);
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
return { success: true };
} catch (error) {
@@ -112,15 +119,40 @@ export class AudioPlayer {
}
/**
* Select a format decoder from the response Content-Type.
* Inject the Opus sidecar (setup header + seek index) for the next Opus stream. Wave 18.5 calls
* this with the raw sidecar bytes (from its one-time HTTP fetch) BEFORE initializeStreaming; the
* parsed result is applied to the OpusFormatDecoder when the stream initializes. This is the
* injection seam — the player owns no transport, only the parse + hand-off.
*
* @returns success:false with an error if the bytes are not a valid sidecar blob.
*/
private static createFormatDecoder(contentType: string): IFormatDecoder {
setOpusSidecar(sidecarBytes: Uint8Array): AudioResult {
const parsed = parseSidecar(sidecarBytes);
if (!parsed) {
return { success: false, error: 'Invalid Opus sidecar blob' };
}
this.pendingOpusSidecar = parsed;
return { success: true };
}
/**
* Select a format decoder from the response Content-Type. For Opus, applies the pending sidecar
* (if 18.5 has set one) so the decoder has its setup bytes + seek index before stream init.
*/
private createFormatDecoder(contentType: string): IFormatDecoder {
if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) {
return new Mp3FormatDecoder();
}
if (contentType.includes('audio/flac') || contentType.includes('audio/x-flac')) {
return new FlacFormatDecoder();
}
if (contentType.includes('audio/ogg') || contentType.includes('audio/opus')) {
const decoder = new OpusFormatDecoder();
if (this.pendingOpusSidecar) {
decoder.setSidecar(this.pendingOpusSidecar);
}
return decoder;
}
return new WavFormatDecoder(); // default (audio/wav, unknown)
}
@@ -1,3 +1,5 @@
import { OpusSeekData } from './OpusSidecar.js';
/**
* FormatInfo: parsed header data needed to stream and seek an audio file.
* Populated by IFormatDecoder.tryParseHeader; used by StreamDecoder throughout playback.
@@ -36,8 +38,10 @@ export interface FormatInfo {
* MP3 VBR: Xing/VBRI TOC (100-entry Uint8Array, values are file-percentage * 255).
* FLAC: SeekTable (array of {sampleNumber: number, streamOffset: number} — stream_offset
* is bytes from the start of audio frames, i.e. after all metadata blocks).
* Opus: OpusSeekData — the precomputed granule->byte index + OpusHead/OpusTags setup bytes,
* parsed from the sidecar artifact (NOT byteRate math; see OpusFormatDecoder).
*/
seekData?: Mp3VbrSeekData | FlacSeekData | null;
seekData?: Mp3VbrSeekData | FlacSeekData | OpusSeekData | null;
}
export interface Mp3VbrSeekData {
@@ -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;
}
@@ -0,0 +1,261 @@
/**
* OpusFormatDecoder / OpusSidecar tests.
*
* There is no TS test runner configured in this repo (no package.json, no jest/vitest, no other
* *.test.ts). Rather than introduce a heavy harness, this file is a self-contained, zero-dependency
* test: a ~15-line inline assert/test harness, no `node:` imports, no DOM. It exercises the pure
* parser / resolver / alignment logic (none of which touches the DOM or Web Audio).
*
* It is EXCLUDED from the production tsc build (tsconfig `exclude: Interop/**\/*.test.ts`) so it
* never ships in wwwroot/js. To run it (Node 22+ strips TS types natively — no tsc, no deps), the
* test's `.js` import specifiers must resolve to the COMPILED decoder modules, so run a copy from
* the compiled output directory:
*
* # 1. produce the compiled decoder modules (the normal build already does this):
* dotnet build DeepDrftPublic/DeepDrftPublic.csproj
* # 2. run this test next to the compiled .js siblings (Node strips the types at load):
* cp DeepDrftPublic/Interop/audio/OpusFormatDecoder.test.ts DeepDrftPublic/wwwroot/js/audio/
* node DeepDrftPublic/wwwroot/js/audio/OpusFormatDecoder.test.ts
*
* A thrown error / non-zero exit signals failure; "ALL <n> TESTS PASSED" signals success. (The
* copied file lives only in the gitignored wwwroot/js output; the source under Interop is the
* committed artifact.)
*
* The sidecar bytes built in `makeSidecar` reproduce the C# wire format byte-for-byte
* (DeepDrftContent.Processors.Opus.OpusSidecar.ToBytes / OggOpusSeekIndex.ToBytes):
* [uint32 setupHeaderLength][setup bytes]
* [uint64 totalByteLength][double totalDuration][uint32 count][uint16 preSkip][uint16 reserved]
* count x [uint64 granulePosition][uint64 byteOffset] — all little-endian.
* The C# serializer is the source of truth; this verifies the TS parser is its exact counterpart.
*/
import { parseSidecar, presentationTimeSeconds, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
import type { OpusSeekData } from './OpusSidecar.js';
import { OpusFormatDecoder } from './OpusFormatDecoder.js';
import type { FormatInfo } from './IFormatDecoder.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
let passed = 0;
const failures: string[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
passed++;
} catch (e) {
failures.push(`FAIL: ${name}\n ${(e as Error).message}`);
}
}
function assertEqual(actual: unknown, expected: unknown, msg?: string): void {
if (actual !== expected) {
throw new Error(`${msg ?? 'assertEqual'}: expected ${String(expected)}, got ${String(actual)}`);
}
}
function assertArray(actual: ArrayLike<number>, expected: number[], msg?: string): void {
const a = Array.from(actual);
if (a.length !== expected.length || a.some((v, i) => v !== expected[i])) {
throw new Error(`${msg ?? 'assertArray'}: expected [${expected}], got [${a}]`);
}
}
function assertNull(actual: unknown, msg?: string): void {
if (actual !== null) throw new Error(`${msg ?? 'assertNull'}: expected null, got ${String(actual)}`);
}
function assertNotNull<T>(actual: T | null, msg?: string): T {
if (actual === null) throw new Error(`${msg ?? 'assertNotNull'}: got null`);
return actual;
}
interface SidecarSpec {
setupHeader: number[];
totalByteLength: number;
totalDuration: number;
preSkip: number;
points: Array<{ granule: number; byteOffset: number }>;
}
/** Serialize a sidecar blob exactly as the C# OpusSidecar/OggOpusSeekIndex writers do. */
function makeSidecar(spec: SidecarSpec): Uint8Array {
const SEEK_INDEX_HEADER_SIZE = 24;
const SEEK_POINT_SIZE = 16;
const setupLen = spec.setupHeader.length;
const total = 4 + setupLen + SEEK_INDEX_HEADER_SIZE + spec.points.length * SEEK_POINT_SIZE;
const bytes = new Uint8Array(total);
const view = new DataView(bytes.buffer);
view.setUint32(0, setupLen, true);
bytes.set(spec.setupHeader, 4);
let p = 4 + setupLen;
writeUint64(view, p, spec.totalByteLength);
view.setFloat64(p + 8, spec.totalDuration, true);
view.setUint32(p + 16, spec.points.length, true);
view.setUint16(p + 20, spec.preSkip, true);
// bytes 22-23 reserved (zero)
p += SEEK_INDEX_HEADER_SIZE;
for (const pt of spec.points) {
writeUint64(view, p, pt.granule);
writeUint64(view, p + 8, pt.byteOffset);
p += SEEK_POINT_SIZE;
}
return bytes;
}
function writeUint64(view: DataView, offset: number, value: number): void {
view.setUint32(offset, value >>> 0, true);
view.setUint32(offset + 4, Math.floor(value / 0x100000000), true);
}
function formatInfoFor(sidecar: Uint8Array): FormatInfo {
const decoder = new OpusFormatDecoder();
const parsed = assertNotNull(parseSidecar(sidecar), 'sidecar should parse');
decoder.setSidecar(parsed);
return assertNotNull(decoder.tryParseHeader([], 0), 'tryParseHeader should build FormatInfo');
}
// --- parseSidecar: byte-for-byte round-trip against the C# layout -----------------------------
test('parseSidecar round-trips the C# binary layout exactly', () => {
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead" stand-in
const spec: SidecarSpec = {
setupHeader: setup,
totalByteLength: 1_234_567,
totalDuration: 212.5,
preSkip: 312,
points: [
{ granule: 312, byteOffset: 4096 }, // first point: granule == preSkip -> t=0
{ granule: 312 + 24000, byteOffset: 9000 }, // +0.5 s
{ granule: 312 + 48000, byteOffset: 14000 }, // +1.0 s
],
};
const parsed: OpusSeekData = assertNotNull(parseSidecar(makeSidecar(spec)));
assertEqual(parsed.kind, 'opus-sidecar', 'kind');
assertArray(parsed.setupHeaderBytes, setup, 'setup header bytes');
assertEqual(parsed.totalByteLength, spec.totalByteLength, 'totalByteLength');
assertEqual(parsed.totalDurationSeconds, spec.totalDuration, 'totalDuration');
assertEqual(parsed.preSkip, spec.preSkip, 'preSkip');
assertEqual(parsed.points.length, 3, 'point count');
assertEqual(parsed.points[1].granulePosition, 312 + 24000, 'point[1].granule');
assertEqual(parsed.points[1].byteOffset, 9000, 'point[1].byteOffset');
});
test('parseSidecar honours a borrowed view byteOffset (sidecar not at buffer start)', () => {
const blob = makeSidecar({
setupHeader: [1, 2, 3, 4],
totalByteLength: 100,
totalDuration: 1.0,
preSkip: 0,
points: [{ granule: 0, byteOffset: 8 }],
});
const padded = new Uint8Array(blob.length + 7);
padded.set(blob, 7);
const parsed = assertNotNull(parseSidecar(padded.subarray(7)));
assertArray(parsed.setupHeaderBytes, [1, 2, 3, 4], 'borrowed setup bytes');
assertEqual(parsed.points[0].byteOffset, 8, 'borrowed point offset');
});
test('parseSidecar returns null on a truncated blob', () => {
const blob = makeSidecar({
setupHeader: [0],
totalByteLength: 1,
totalDuration: 0,
preSkip: 0,
points: [{ granule: 0, byteOffset: 0 }],
});
assertNull(parseSidecar(blob.subarray(0, 3)), 'short of length prefix');
assertNull(parseSidecar(blob.subarray(0, blob.length - 4)), 'declared count overruns');
});
test('presentationTimeSeconds applies preSkip and clamps at zero (RFC 7845)', () => {
assertEqual(presentationTimeSeconds(312, 312), 0, 'granule == preSkip');
assertEqual(presentationTimeSeconds(0, 312), 0, 'below preSkip clamps');
assertEqual(presentationTimeSeconds(312 + OPUS_SAMPLE_RATE, 312), 1.0, '+48000 -> 1 s');
});
// --- calculateByteOffset: binary search over the precomputed index (exact, not interpolation) -
test('calculateByteOffset returns the page-start of the largest entry with time <= t', () => {
const points = [0, 1, 2, 3].map(i => ({
granule: 1000 + i * (OPUS_SAMPLE_RATE / 2),
byteOffset: 4096 + i * 5000,
}));
const info = formatInfoFor(makeSidecar({
setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points,
}));
const d = new OpusFormatDecoder();
assertEqual(d.calculateByteOffset(info, 0.0), 4096, 't=0 -> first point');
assertEqual(d.calculateByteOffset(info, 0.4), 4096, 'just before bucket 1');
assertEqual(d.calculateByteOffset(info, 0.5), 9096, 'exactly bucket 1');
assertEqual(d.calculateByteOffset(info, 0.9), 9096, 'within bucket 1');
assertEqual(d.calculateByteOffset(info, 1.0), 14096, 'exactly bucket 2');
assertEqual(d.calculateByteOffset(info, 99), 19096, 'past end -> last point');
});
test('calculateByteOffset never interpolates between points', () => {
const info = formatInfoFor(makeSidecar({
setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0,
points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }],
}));
const d = new OpusFormatDecoder();
assertEqual(d.calculateByteOffset(info, 0.5), 100, 'midpoint snaps to lower page start');
});
test('calculateByteOffset degrades to audioDataOffset with an empty index', () => {
const info = formatInfoFor(makeSidecar({
setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
}));
const d = new OpusFormatDecoder();
assertEqual(info.audioDataOffset, 5, 'audioDataOffset == setup header length');
assertEqual(d.calculateByteOffset(info, 10), info.audioDataOffset, 'empty index degrades');
});
// --- getAlignedSegmentSize: Ogg "OggS" page-boundary alignment --------------------------------
function withOggS(len: number, ...pageStarts: number[]): Uint8Array {
const out = new Uint8Array(len).fill(0xaa);
for (const s of pageStarts) { out[s] = 0x4f; out[s + 1] = 0x67; out[s + 2] = 0x67; out[s + 3] = 0x53; }
return out;
}
const stubInfo = { audioDataOffset: 0 } as FormatInfo;
test('getAlignedSegmentSize cuts at the last OggS page start within the window', () => {
const raw = withOggS(64, 4, 40);
assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 40, 'last page start');
});
test('getAlignedSegmentSize waits (returns 0) when no page boundary is found mid-stream', () => {
const raw = withOggS(64);
assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'no boundary');
});
test('getAlignedSegmentSize flushes the whole candidate on stream completion without a boundary', () => {
const raw = withOggS(64);
assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, true, raw), 64, 'flush on complete');
});
test('getAlignedSegmentSize ignores a page start at offset 0 (needs a real cut point)', () => {
const raw = withOggS(64, 0);
assertEqual(new OpusFormatDecoder().getAlignedSegmentSize(stubInfo, 64, 64, false, raw), 0, 'offset 0 skipped');
});
// --- wrapSegment: OpusHead/OpusTags setup-header carry ----------------------------------------
test('wrapSegment prepends the cached setup bytes to a page run', () => {
const setup = [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64]; // "OpusHead"
const info = formatInfoFor(makeSidecar({
setupHeader: setup, totalByteLength: 100, totalDuration: 1, preSkip: 0,
points: [{ granule: 0, byteOffset: setup.length }],
}));
const pageRun = new Uint8Array([0x4f, 0x67, 0x67, 0x53, 0x11, 0x22]); // "OggS" + payload
const wrapped = new OpusFormatDecoder().wrapSegment(info, pageRun);
assertArray(wrapped.subarray(0, setup.length), setup, 'setup header first');
assertArray(wrapped.subarray(setup.length), [0x4f, 0x67, 0x67, 0x53, 0x11, 0x22], 'page run follows');
});
// --- report ----------------------------------------------------------------------------------
if (failures.length > 0) {
console.error(failures.join('\n'));
throw new Error(`${failures.length} test(s) failed, ${passed} passed`);
}
console.log(`ALL ${passed} TESTS PASSED`);
@@ -0,0 +1,169 @@
/**
* OpusFormatDecoder - Ogg-Opus implementation of IFormatDecoder.
*
* Ogg Opus is a containerized, paged format — NOT raw-frame-sliceable the way WAV PCM is. Two
* things make a mid-stream byte slice decodable: (1) it must begin on an Ogg page boundary, and
* (2) the OpusHead/OpusTags setup pages must be prepended (analogous to FLAC's STREAMINFO carry).
* This decoder owns both, plus VBR-safe accurate seeking.
*
* Where the metadata comes from is the genuinely new part. WAV/MP3/FLAC parse everything out of
* the byte stream. Opus is VBR and container-paged, so a byteRate seek would be inaccurate; instead
* the seek transfer function (granule->byte) and the setup bytes are precomputed at transcode time
* (wave 18.1) and delivered as a one-time sidecar fetch (wave 18.5). The injection seam is
* `setSidecar(OpusSeekData)` — call it with the parsed sidecar BEFORE the stream is initialized so
* `tryParseHeader` can build FormatInfo from it. Without a sidecar the decoder cannot stream Opus
* (returns null from tryParseHeader); 18.5 guarantees the fetch precedes stream init.
*
* - getAlignedSegmentSize aligns to Ogg page boundaries by scanning for the "OggS" capture
* pattern (the Ogg analogue of FLAC's frame-sync scan; the interface passes rawData for this).
* - wrapSegment prepends the cached OpusHead/OpusTags setup bytes so any mid-stream page run is
* independently decodable.
* - calculateByteOffset binary-searches the precomputed index for the largest entry with
* presentation-time <= t and returns its exact page-start byte offset — NOT interpolation,
* NOT byteRate math (§3.4a A/C; C5 accurate seek).
*/
import { FormatInfo, IFormatDecoder } from './IFormatDecoder.js';
import { OpusSeekData, OPUS_SAMPLE_RATE, presentationTimeSeconds } from './OpusSidecar.js';
// "OggS" — every Ogg page begins with this 4-byte capture pattern.
const OGG_CAPTURE = [0x4f, 0x67, 0x67, 0x53]; // 'O' 'g' 'g' 'S'
export class OpusFormatDecoder implements IFormatDecoder {
// The parsed sidecar: setup bytes + seek index + preSkip + totals. Injected by wave 18.5 via
// setSidecar before stream init. Held for the stream's lifetime (the format does not change
// across a seek/continuation), mirroring how FlacFormatDecoder retains streamInfoBytes.
private sidecar: OpusSeekData | null = null;
/**
* Inject the parsed sidecar (setup header + seek index) for this stream. Wave 18.5 calls this
* after its one-time sidecar fetch + parseSidecar, before initializeStreaming. This is the seam
* that keeps the HTTP fetch out of the decoder: the decoder is pure and unit-testable against
* synthetic bytes, and 18.5 wires the real transport.
*/
setSidecar(sidecar: OpusSeekData): void {
this.sidecar = sidecar;
}
tryParseHeader(_chunks: Uint8Array[], _totalSize: number): FormatInfo | null {
// Opus metadata is NOT parsed from the stream — it comes from the injected sidecar. Without
// it we cannot stream Opus; return null so StreamDecoder waits, and 18.5's contract (fetch +
// setSidecar before stream init) prevents that null from persisting.
const sidecar = this.sidecar;
if (!sidecar) return null;
// For the initial full-file stream the server emits [setup pages][audio pages], and the
// sidecar's setup bytes are exactly those leading pages — so audio data begins right after
// them. This is the file-absolute offset of the first audio page (== the first index point's
// byteOffset by construction).
const audioDataOffset = sidecar.setupHeaderBytes.length;
return {
// Opus always decodes at 48 kHz regardless of the source rate (RFC 7845).
sampleRate: OPUS_SAMPLE_RATE,
// Channel count is encoded in OpusHead; the decoder reads it from the prepended setup
// bytes at decode time. FormatInfo.channels is display-only here — 2 is the safe nominal.
channels: 2,
bitsPerSample: 16,
byteRate: 0, // VBR + paged; seeking uses the index, never byteRate.
blockAlign: 0, // No fixed alignment; segments align to Ogg page starts via OggS scan.
totalDuration: sidecar.totalDurationSeconds > 0 ? sidecar.totalDurationSeconds : null,
audioDataOffset,
seekData: sidecar
};
}
getAlignedSegmentSize(
info: FormatInfo,
availableBytes: number,
requestedSize: number,
streamComplete: boolean,
rawData?: Uint8Array
): number {
if (availableBytes === 0) return 0;
const candidate = Math.min(requestedSize, availableBytes);
if (!rawData || rawData.length === 0) {
// No scan data — conservative threshold to avoid tiny unusable segments (mirrors FLAC).
if (!streamComplete && availableBytes < 16384) return 0;
return candidate;
}
// Scan backward from the candidate boundary for the start of the last Ogg page. Cutting on a
// page start keeps the next segment Ogg-sync-aligned and the current one a whole page run.
const boundary = OpusFormatDecoder.findLastOggPage(rawData, candidate);
if (boundary <= 0) {
if (streamComplete) return candidate; // flush remaining bytes (stream done)
return 0; // wait for more data — no full page boundary yet
}
return boundary;
}
/**
* Scan backward from `maxBytes` in `rawData` for the start of the last "OggS" capture pattern.
* Returns that byte offset (the page start), or 0 if none is found (caller waits for more data).
* Skips offset 0 itself: a segment that is only "everything up to the very first page" carries
* no page and should wait, matching the FLAC frame-scan's `> 0` discipline.
*/
private static findLastOggPage(rawData: Uint8Array, maxBytes: number): number {
const limit = Math.min(maxBytes, rawData.length);
for (let i = limit - 4; i > 0; i--) {
if (rawData[i] === OGG_CAPTURE[0] &&
rawData[i + 1] === OGG_CAPTURE[1] &&
rawData[i + 2] === OGG_CAPTURE[2] &&
rawData[i + 3] === OGG_CAPTURE[3]) {
return i;
}
}
return 0;
}
wrapSegment(info: FormatInfo, rawBytes: Uint8Array): Uint8Array {
const sidecar = OpusFormatDecoder.opusSeekData(info);
const setupBytes = sidecar?.setupHeaderBytes;
if (!setupBytes || setupBytes.length === 0) {
// Defensive: without setup bytes a mid-stream page run is undecodable. tryParseHeader
// always populates the sidecar on success, so this path should not occur in practice.
return rawBytes;
}
// Prepend OpusHead/OpusTags so the page run is self-contained for decodeAudioData.
const result = new Uint8Array(setupBytes.length + rawBytes.length);
result.set(setupBytes, 0);
result.set(rawBytes, setupBytes.length);
return result;
}
calculateByteOffset(info: FormatInfo, positionSeconds: number): number {
const sidecar = OpusFormatDecoder.opusSeekData(info);
if (!sidecar || sidecar.points.length === 0) {
// No index: degrade to start of audio (seek restarts) — same graceful fallback as FLAC.
return info.audioDataOffset;
}
const points = sidecar.points;
const preSkip = sidecar.preSkip;
// Binary search for the largest entry whose presentation time is <= target. Presentation
// time = max(0, (granule - preSkip) / 48000), matching 18.1's RFC 7845 math exactly.
let lo = 0, hi = points.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const t = presentationTimeSeconds(points[mid].granulePosition, preSkip);
if (t <= positionSeconds) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// byteOffset is already a file-absolute page-start offset in the Opus file — no header math
// to add (unlike FLAC's audio-relative stream_offset). Return it directly.
return points[best].byteOffset;
}
private static opusSeekData(info: FormatInfo): OpusSeekData | null {
return info.seekData?.kind === 'opus-sidecar' ? info.seekData : null;
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* OpusSidecar - parser for the per-track Opus seek/setup sidecar artifact.
*
* The sidecar is built once at transcode time (wave 18.1, C# `OpusSidecar` /
* `OggOpusSeekIndex`) and fetched once on track load (wired by wave 18.5). It carries
* everything the client needs to seek a VBR Opus stream accurately and to decode any
* mid-stream slice:
* - the verbatim OpusHead + OpusTags setup pages (prepended to every post-seek slice),
* - the precomputed granule->byte seek index (the exact time->byte transfer function),
* - the pre_skip and totals needed for presentation-time math and seek clamping.
*
* This module is the byte-for-byte counterpart to the C# serializer. It is pure: it parses
* a blob into an `OpusSeekData` accelerator with no I/O. Wave 18.5 owns the HTTP fetch and
* injects the parsed result into `OpusFormatDecoder.setSidecar`.
*
* Binary layout (all little-endian), matching DeepDrftContent.Processors.Opus:
* [uint32 setupHeaderLength]
* [setupHeaderLength bytes -> OpusHead + OpusTags pages]
* [seek-index blob]:
* header (24 bytes):
* uint64 totalByteLength
* double totalDurationSeconds (pre-skip-corrected)
* uint32 pointCount
* uint16 preSkip
* uint16 reserved
* pointCount x 16-byte points:
* uint64 granulePosition (48 kHz sample count)
* uint64 byteOffset (page-start offset in the Opus file)
*/
/** Opus granule positions are always 48 kHz sample counts, regardless of input rate. */
export const OPUS_SAMPLE_RATE = 48000;
/** Size of the seek-index blob header: totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2). */
const SEEK_INDEX_HEADER_SIZE = 24;
/** Size of one serialized seek point: granulepos(8) + byteOffset(8). */
const SEEK_POINT_SIZE = 16;
/** One (granule, byteOffset) seek-index entry. Both are page-start-accurate. */
export interface OpusSeekPoint {
/** Page end granule position — a 48 kHz sample count. */
granulePosition: number;
/** Byte offset of the page start in the Opus file. */
byteOffset: number;
}
/**
* Parsed sidecar: the `seekData` accelerator the `OpusFormatDecoder` holds for the stream's
* lifetime. Holds the setup bytes (for `wrapSegment` carry) and the index (for `calculateByteOffset`).
*/
export interface OpusSeekData {
kind: 'opus-sidecar';
/** Verbatim OpusHead + OpusTags pages, prepended to every decodable segment. */
setupHeaderBytes: Uint8Array;
/** Ordered (granule, byteOffset) entries, ascending by granule. */
points: OpusSeekPoint[];
/** Pre-skip-corrected total stream duration in seconds. */
totalDurationSeconds: number;
/** Total Opus file byte length, for clamping a seek past the end. */
totalByteLength: number;
/** pre_skip from OpusHead (RFC 7845 §5.1); samples to discard before presentation. */
preSkip: number;
}
/**
* Parse a sidecar blob produced by the C# `OpusSidecar.ToBytes`. Returns null on any structural
* inconsistency (short blob, length prefix overrun, declared point count that does not fit) —
* the format is exact, so a malformed blob is corruption, not a recoverable shape.
*
* Accepts a `Uint8Array`, an `ArrayBuffer`, or a typed-array view; copies nothing it can borrow.
*/
export function parseSidecar(input: Uint8Array | ArrayBuffer | ArrayBufferView): OpusSeekData | null {
const bytes = toUint8Array(input);
// DataView over the same backing buffer; honour the view's byteOffset so a borrowed view parses.
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.byteLength < 4) return null;
const setupLength = view.getUint32(0, true);
const indexStart = 4 + setupLength;
// Need the setup region plus at least the index header.
if (bytes.byteLength < indexStart + SEEK_INDEX_HEADER_SIZE) return null;
// subarray is zero-copy; setup bytes are retained for wrapSegment for the stream's lifetime.
const setupHeaderBytes = bytes.subarray(4, indexStart);
// Seek-index blob header (relative to the DataView, which is bytes-relative).
const totalByteLength = readUint64(view, indexStart);
const totalDurationSeconds = view.getFloat64(indexStart + 8, true);
const pointCount = view.getUint32(indexStart + 16, true);
const preSkip = view.getUint16(indexStart + 20, true);
// bytes 22-23: reserved — ignored on read, for forward-compatibility (matches C#).
const pointsStart = indexStart + SEEK_INDEX_HEADER_SIZE;
const expectedEnd = pointsStart + pointCount * SEEK_POINT_SIZE;
if (bytes.byteLength < expectedEnd) return null;
const points: OpusSeekPoint[] = new Array(pointCount);
let cursor = pointsStart;
for (let i = 0; i < pointCount; i++) {
const granulePosition = readUint64(view, cursor);
const byteOffset = readUint64(view, cursor + 8);
points[i] = { granulePosition, byteOffset };
cursor += SEEK_POINT_SIZE;
}
return {
kind: 'opus-sidecar',
setupHeaderBytes,
points,
totalDurationSeconds,
totalByteLength,
preSkip
};
}
/**
* Pre-skip-corrected presentation time for a granule position: max(0, (granule - preSkip) / 48000).
* Matches the C# `OggOpusSeekIndex.PresentationTimeSeconds` so client and server agree on the
* seek transfer function.
*/
export function presentationTimeSeconds(granulePosition: number, preSkip: number): number {
return Math.max(0, (granulePosition - preSkip) / OPUS_SAMPLE_RATE);
}
function toUint8Array(input: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array {
if (input instanceof Uint8Array) return input;
if (input instanceof ArrayBuffer) return new Uint8Array(input);
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
}
/**
* Read a little-endian uint64 as a JS number. Opus byte offsets and granule positions are exact
* to 2^53 (~8 PB / ~5,700 years of audio at 48 kHz), far beyond any real file — no BigInt needed,
* matching the FLAC seektable's same 2^53 assumption.
*/
function readUint64(view: DataView, offset: number): number {
const lo = view.getUint32(offset, true);
const hi = view.getUint32(offset + 4, true);
return hi * 0x100000000 + lo;
}
+15
View File
@@ -3,6 +3,7 @@
*/
import { AudioPlayer, AudioResult, StreamingResult, AudioState } from './AudioPlayer.js';
import { canDecodeOggOpus } from './OpusCapability.js';
// Player instances by ID
const audioPlayers = new Map<string, AudioPlayer>();
@@ -37,6 +38,20 @@ const DeepDrftAudio = {
return player.initializeStreaming(totalStreamLength, contentType);
},
// Opus injection seam (wave 18.4). Wave 18.5 fetches the per-track sidecar (setup header +
// seek index) over HTTP and hands the raw bytes here BEFORE initializeStreaming on an Opus
// stream. This module never fetches the sidecar — it only parses + stores it on the player.
setOpusSidecar: (playerId: string, sidecarBytes: Uint8Array): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.setOpusSidecar(sidecarBytes);
},
// Capability seam (wave 18.4). Resolves whether this browser can decode Ogg Opus via
// decodeAudioData (Safari < 18.4 cannot). Wave 18.5 / 18.6 consume this to choose lossless
// when unsupported; this module only reports the capability.
canDecodeOggOpus: (): Promise<boolean> => canDecodeOggOpus(),
processStreamingChunk: async (playerId: string, chunk: Uint8Array): Promise<StreamingResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };