From 08001675114f92efce403d867d7a6e6c1a533aad Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 24 Jun 2026 23:26:42 -0400 Subject: [PATCH] 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. --- DeepDrftPublic/Interop/audio/AudioPlayer.ts | 21 ++++++++++++++++--- .../Interop/audio/OpusStreamDecoder.ts | 6 +++++- DeepDrftPublic/Interop/audio/index.ts | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 16f476f..85b375e 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -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 { 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 }; diff --git a/DeepDrftPublic/Interop/audio/OpusStreamDecoder.ts b/DeepDrftPublic/Interop/audio/OpusStreamDecoder.ts index fa05d93..acb3b71 100644 --- a/DeepDrftPublic/Interop/audio/OpusStreamDecoder.ts +++ b/DeepDrftPublic/Interop/audio/OpusStreamDecoder.ts @@ -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); } diff --git a/DeepDrftPublic/Interop/audio/index.ts b/DeepDrftPublic/Interop/audio/index.ts index a1a0c91..5529758 100644 --- a/DeepDrftPublic/Interop/audio/index.ts +++ b/DeepDrftPublic/Interop/audio/index.ts @@ -32,7 +32,7 @@ const DeepDrftAudio = { } }, - initializeStreaming: (playerId: string, totalStreamLength: number, contentType: string): AudioResult => { + initializeStreaming: async (playerId: string, totalStreamLength: number, contentType: string): Promise => { const player = audioPlayers.get(playerId); if (!player) return { success: false, error: 'Player not found' }; return player.initializeStreaming(totalStreamLength, contentType);