Fix Opus duration reporting so seekbar and visualizer work

Surface the sidecar duration on the first Opus chunk instead of gating it on the first decoded buffers; C# locks UI Duration on chunk 1, and async WebCodecs decode left it at 0 — killing seek and the duration-gated visualizer.
This commit is contained in:
daniel-c-harvey
2026-06-23 21:23:43 -04:00
parent d80b777e9f
commit eb58ae4a72
2 changed files with 45 additions and 4 deletions
@@ -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<typeof OpusStreamDecoder>[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;