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)
}