142 lines
6.2 KiB
TypeScript
142 lines
6.2 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|