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:
@@ -229,14 +229,21 @@ export class AudioPlayer {
|
|||||||
const decoder = this.opusDecoder!;
|
const decoder = this.opusDecoder!;
|
||||||
const buffers = await decoder.push(chunk);
|
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) {
|
if (buffers.length > 0) {
|
||||||
for (const buffer of buffers) {
|
for (const buffer of buffers) {
|
||||||
this.scheduler.addBuffer(buffer);
|
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) {
|
if (this.streamingStarted && this.isPlaying) {
|
||||||
this.scheduler.scheduleNewBuffers();
|
this.scheduler.scheduleNewBuffers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
|
import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
|
||||||
import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js';
|
import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js';
|
||||||
import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
|
import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
|
||||||
|
import { OpusStreamDecoder } from './OpusStreamDecoder.js';
|
||||||
|
|
||||||
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
// --- tiny inline harness (no dependencies) ---------------------------------------------------
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
@@ -414,6 +415,39 @@ test('extractOpusHead returns null when no OpusHead page is present', () => {
|
|||||||
assertNull(extractOpusHead(tags), 'no OpusHead');
|
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 {
|
function concat(arrs: Uint8Array[]): Uint8Array {
|
||||||
let len = 0;
|
let len = 0;
|
||||||
for (const a of arrs) len += a.length;
|
for (const a of arrs) len += a.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user