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