diff --git a/DeepDrftPublic/Interop/audio/AudioPlayer.ts b/DeepDrftPublic/Interop/audio/AudioPlayer.ts index 354389a..06ce602 100644 --- a/DeepDrftPublic/Interop/audio/AudioPlayer.ts +++ b/DeepDrftPublic/Interop/audio/AudioPlayer.ts @@ -229,14 +229,21 @@ export class AudioPlayer { const decoder = this.opusDecoder!; const buffers = await decoder.push(chunk); + // Duration is known up front from the sidecar — surface it as soon as the decoder reports it, + // NOT gated on the first decoded buffers. The C# layer locks Duration on the first chunk whose + // result carries a value (the `Duration == null` guard), and WebCodecs decode is async, so the + // earliest chunks can return zero buffers; gating duration on buffers means C# captures the + // initial 0 and never overwrites it — the WAV header path sets duration on chunk 1 because its + // header parses synchronously, which is the asymmetry this closes. Set once so a seek (which + // reinitialises the decoder) cannot overwrite it. + if (this.duration === 0 && decoder.totalDuration) { + this.duration = decoder.totalDuration; + } + if (buffers.length > 0) { for (const buffer of buffers) { this.scheduler.addBuffer(buffer); } - // Duration is known up front from the sidecar; set once (a seek must not overwrite it). - if (this.duration === 0 && decoder.totalDuration) { - this.duration = decoder.totalDuration; - } if (this.streamingStarted && this.isPlaying) { this.scheduler.scheduleNewBuffers(); } diff --git a/DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts b/DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts index 4a4ffc2..44f716b 100644 --- a/DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts +++ b/DeepDrftPublic/Interop/audio/OpusStreamDecoder.test.ts @@ -33,6 +33,7 @@ import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js'; import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js'; import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js'; +import { OpusStreamDecoder } from './OpusStreamDecoder.js'; // --- tiny inline harness (no dependencies) --------------------------------------------------- let passed = 0; @@ -414,6 +415,39 @@ test('extractOpusHead returns null when no OpusHead page is present', () => { assertNull(extractOpusHead(tags), 'no OpusHead'); }); +// --- OpusStreamDecoder.totalDuration: available from the sidecar BEFORE the first push ---------- +// +// Defect 1 (dead Opus seekbar): the C# layer locks the UI Duration on the first chunk whose result +// carries a value, and AudioPlayer.processOpusChunk now surfaces `decoder.totalDuration` on that first +// chunk rather than gating it on the (async, possibly-empty-on-chunk-1) decoded buffers. The load-bearing +// guarantee that makes this correct is that `totalDuration` is known from the sidecar IMMEDIATELY — i.e. +// before any push and without WebCodecs. These tests pin that contract; the WebCodecs decode itself stays +// browser-verified. The constructor only stashes the context manager (totalDuration never touches it), so a +// null-shaped stub is safe and no AudioDecoder is constructed. + +const stubContextManager = {} as unknown as ConstructorParameters[0]; + +test('OpusStreamDecoder.totalDuration is the sidecar duration, available before any push', () => { + const sidecar = sidecarFrom({ + setupHeader: [0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], + totalByteLength: 500_000, totalDuration: 212.5, preSkip: 312, + points: [{ granule: 312, byteOffset: 4096 }], + }); + const decoder = new OpusStreamDecoder(stubContextManager, sidecar); + // No push, no configure — the value the first chunk reports to C# must already be present. + assertEqual(decoder.totalDuration, 212.5, 'totalDuration from sidecar, pre-push'); +}); + +test('OpusStreamDecoder.totalDuration is null when the sidecar carries no positive duration', () => { + const sidecar = sidecarFrom({ + setupHeader: [0], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [], + }); + const decoder = new OpusStreamDecoder(stubContextManager, sidecar); + // A zero/absent sidecar duration must report null (not 0) so the chunk result carries no spurious + // value — the WAV-header path, not a bogus Opus duration, then drives the UI. + assertEqual(decoder.totalDuration, null, 'no positive duration -> null'); +}); + function concat(arrs: Uint8Array[]): Uint8Array { let len = 0; for (const a of arrs) len += a.length;