Files
daniel-c-harvey 5a75da1769 fix: AC9 seek fine re-sync + deterministic decoder drain (WebCodecs Opus)
Seek now trims the lead-in so playback lands at the requested time, not the page start; decoder drain polls decodeQueueSize (bounded) instead of a single timeout. Minor cleanups.
2026-06-23 20:57:05 -04:00

64 lines
3.4 KiB
TypeScript

/**
* IStreamingDecoder - the stateful streaming-decode seam, parallel to IFormatDecoder.
*
* Why a second seam. `IFormatDecoder` (WAV/MP3/FLAC) is a *wrap-and-decode-each-segment* strategy:
* `StreamDecoder` cuts the stream into independently-decodable segments, `wrapSegment` makes each a
* standalone file, and `decodeAudioData` decodes each in isolation. That model is correct for raw PCM
* (WAV) and independently-decodable frames (FLAC), but it is fundamentally wrong for Opus: Opus has
* pre-skip (encoder delay) and inter-frame state (MDCT overlap-add, SILK/CELT continuity), so decoding
* page-runs independently re-applies the pre-skip and starts from cold codec state at every boundary —
* audible glitching and a broken timeline.
*
* A WebCodecs `AudioDecoder` is the right tool: one stateful decoder fed packets sequentially, decoding
* continuously with correct pre-skip-once handling and full inter-frame continuity. But it does NOT fit
* `IFormatDecoder` — it is async/callback-driven and owns its own buffering. So Opus gets this seam
* instead. `AudioPlayer` dispatches by content-type: WAV/MP3/FLAC keep the `StreamDecoder` path
* byte-for-byte; Opus routes here. Both feed the SAME `PlaybackScheduler` — the change is the decode
* stage only, never the schedule/playback stage.
*
* The seam is intentionally minimal and mirrors the lifecycle `StreamDecoder` already exposes so
* `AudioPlayer` can treat the two uniformly: initialize -> push chunks -> mark complete, plus a
* range-continuation reinit for seek-beyond-buffer.
*/
export interface IStreamingDecoder {
/**
* Decoded buffers ready to schedule, drained by AudioPlayer after each push/flush. Each entry is a
* standard AudioBuffer at the AudioContext's sample rate, ready for PlaybackScheduler.addBuffer.
*/
readonly hasFatalError: boolean;
/** True once the decoder has enough to begin playback (header/config established). */
readonly ready: boolean;
/** Total stream duration in seconds if known up front (Opus knows it from the sidecar), else null. */
readonly totalDuration: number | null;
/**
* Push raw stream bytes. Returns decoded AudioBuffers that became ready (possibly empty — WebCodecs
* decode is async, so a push may return nothing and a later push returns several).
*/
push(chunk: Uint8Array): Promise<AudioBuffer[]>;
/**
* Signal end-of-stream. Flushes the decoder and returns any residual decoded buffers (including the
* end-trimmed final buffer).
*/
complete(): Promise<AudioBuffer[]>;
/**
* Reinitialize for a Range-continuation after seek-beyond-buffer. The 206 body begins on an Ogg page
* boundary and carries no setup pages — the decoder reuses the cached config and resets demux/codec
* state so inter-frame continuity restarts cleanly from the new offset.
*
* @param landingTimeSeconds The actual presentation time of the resolved seek page (t_page ≤ target).
* @param targetTimeSeconds The user-requested seek position. The decoder trims the leading
* `(target - landing) * sampleRate` frames so playback lands at target
* (AC9 fine re-sync, §3.4a step 4).
*/
reinitializeForRangeContinuation(landingTimeSeconds: number, targetTimeSeconds: number): void;
/** Tear down the underlying WebCodecs decoder and release resources. */
dispose(): void;
}