fix(audio): align AudioContext to 48kHz up front for Opus streams

Opus resolved its 48kHz context lazily on the first chunk, close()ing and rebuilding the live graph mid-decode. Move the recreate into initializeStreaming so it runs before any bytes flow; the lazy call early-returns. WAV path unchanged.
This commit is contained in:
daniel-c-harvey
2026-06-24 23:26:42 -04:00
parent 8a6acd5f5f
commit 0800167511
3 changed files with 24 additions and 5 deletions
+18 -3
View File
@@ -16,7 +16,7 @@ import { WavFormatDecoder } from './WavFormatDecoder.js';
import { Mp3FormatDecoder } from './Mp3FormatDecoder.js';
import { FlacFormatDecoder } from './FlacFormatDecoder.js';
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
import { OpusSeekData, parseSidecar, resolveOpusByteOffset, OpusSeekResolution } from './OpusSidecar.js';
import { OpusSeekData, parseSidecar, resolveOpusByteOffset, OpusSeekResolution, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
export interface AudioResult {
success: boolean;
@@ -118,7 +118,7 @@ export class AudioPlayer {
// ==================== Streaming ====================
initializeStreaming(totalStreamLength: number, contentType: string): AudioResult {
async initializeStreaming(totalStreamLength: number, contentType: string): Promise<AudioResult> {
try {
// Full cleanup before starting new stream
this.stopProgressTracking();
@@ -137,6 +137,19 @@ export class AudioPlayer {
// selects Opus when the sidecar parsed, so the null branch is defensive.
if (this.isOpusContentType(contentType) && this.pendingOpusSidecar) {
this.activeOpusSidecar = this.pendingOpusSidecar;
// Align the AudioContext to 48 kHz NOW, before any Opus bytes flow — the format is
// already resolved (C# resolves Opus + injects the sidecar before this call), so the
// target rate is known up front. Done here, the decoder's own lazy
// recreateWithSampleRate(48000) in ensureConfigured hits its sampleRate-equal early
// return and is a no-op; the live graph is never close()'d and rebuilt mid-decode (the
// teardown that double-decoded the stream and OOM'd the tab with HW accel off). The
// recreate seam itself stays — it is the WAV path's mechanism for non-44.1 sources and
// remains the defensive backstop here.
if (this.contextManager.sampleRate !== OPUS_SAMPLE_RATE) {
await this.contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE);
}
// Pass the shared back-pressure signal (21.2b): the Opus decoder stops demuxing/
// decoding new packets while the scheduler is full, so the WebCodecs decode queue
// and decodedQueue do not balloon behind a throttled socket (OQ7). Same signal the
@@ -148,7 +161,9 @@ export class AudioPlayer {
return { success: true };
}
// Non-Opus (or Opus-without-sidecar): the existing StreamDecoder path, unchanged.
// Non-Opus (or Opus-without-sidecar): the existing StreamDecoder path, unchanged. The
// context sample rate is untouched here, so the WAV/lossless path is byte-for-byte
// unaffected by the Opus up-front alignment above.
const formatDecoder = this.createFormatDecoder(contentType);
this.streamDecoder.initialize(totalStreamLength, formatDecoder);
return { success: true };
@@ -123,7 +123,11 @@ export class OpusStreamDecoder implements IStreamingDecoder {
// Copy the OpusHead into a standalone buffer — the sidecar subarray is a view we keep.
this.opusHeadDescription = opusHead.slice();
// Opus decodes at 48 kHz; align the context so no resample is needed.
// Opus decodes at 48 kHz; align the context so no resample is needed. AudioPlayer.initializeStreaming
// already aligned it to 48 kHz up front (the format is resolved before any bytes flow), so in the
// common path this is an early-return no-op — the live graph is NOT close()'d and rebuilt mid-decode.
// Kept as the defensive backstop for any path that reaches a configured decoder on a non-48 kHz
// context (the same recreate seam the WAV path uses for non-44.1 sources).
if (this.contextManager.sampleRate !== OPUS_SAMPLE_RATE) {
await this.contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE);
}
+1 -1
View File
@@ -32,7 +32,7 @@ const DeepDrftAudio = {
}
},
initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => {
initializeStreaming: async (playerId: string, totalStreamLength: number, contentType: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.initializeStreaming(totalStreamLength, contentType);