fix: AC9 seek fine re-sync + deterministic decoder drain (WebCodecs Opus)

Seek now trims the lead-in so playback lands at the requested time, not the page start; decoder drain polls decodeQueueSize (bounded) instead of a single timeout. Minor cleanups.
This commit is contained in:
daniel-c-harvey
2026-06-23 20:57:05 -04:00
parent 7f3fb74126
commit 5a75da1769
6 changed files with 186 additions and 42 deletions
@@ -31,7 +31,7 @@
*/
import { parseSidecar, presentationTimeSeconds, resolveOpusByteOffset, OPUS_SAMPLE_RATE } from './OpusSidecar.js';
import type { OpusSeekData } from './OpusSidecar.js';
import type { OpusSeekData, OpusSeekResolution } from './OpusSidecar.js';
import { OggDemuxer, extractOpusHead, opusHeadChannelCount } from './OggDemuxer.js';
// --- tiny inline harness (no dependencies) ---------------------------------------------------
@@ -180,12 +180,12 @@ test('resolveOpusByteOffset returns the page-start of the largest entry with tim
const sc = sidecarFrom({
setupHeader: [9, 9, 9, 9], totalByteLength: 999_999, totalDuration: 1.5, preSkip: 1000, points,
});
assertEqual(resolveOpusByteOffset(sc, 0.0), 4096, 't=0 -> first point');
assertEqual(resolveOpusByteOffset(sc, 0.4), 4096, 'just before bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.5), 9096, 'exactly bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.9), 9096, 'within bucket 1');
assertEqual(resolveOpusByteOffset(sc, 1.0), 14096, 'exactly bucket 2');
assertEqual(resolveOpusByteOffset(sc, 99), 19096, 'past end -> last point');
assertEqual(resolveOpusByteOffset(sc, 0.0).byteOffset, 4096, 't=0 -> first point');
assertEqual(resolveOpusByteOffset(sc, 0.4).byteOffset, 4096, 'just before bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 9096, 'exactly bucket 1');
assertEqual(resolveOpusByteOffset(sc, 0.9).byteOffset, 9096, 'within bucket 1');
assertEqual(resolveOpusByteOffset(sc, 1.0).byteOffset, 14096, 'exactly bucket 2');
assertEqual(resolveOpusByteOffset(sc, 99).byteOffset, 19096, 'past end -> last point');
});
test('resolveOpusByteOffset never interpolates between points', () => {
@@ -193,7 +193,7 @@ test('resolveOpusByteOffset never interpolates between points', () => {
setupHeader: [0], totalByteLength: 10_000, totalDuration: 1.0, preSkip: 0,
points: [{ granule: 0, byteOffset: 100 }, { granule: OPUS_SAMPLE_RATE, byteOffset: 9000 }],
});
assertEqual(resolveOpusByteOffset(sc, 0.5), 100, 'midpoint snaps to lower page start');
assertEqual(resolveOpusByteOffset(sc, 0.5).byteOffset, 100, 'midpoint snaps to lower page start');
});
test('resolveOpusByteOffset degrades to start of audio with an empty index', () => {
@@ -201,7 +201,58 @@ test('resolveOpusByteOffset degrades to start of audio with an empty index', ()
setupHeader: [1, 2, 3, 4, 5], totalByteLength: 0, totalDuration: 0, preSkip: 0, points: [],
});
// start of audio == setup header length (server emits [setup pages][audio pages]).
assertEqual(resolveOpusByteOffset(sc, 10), 5, 'empty index degrades to audio start');
assertEqual(resolveOpusByteOffset(sc, 10).byteOffset, 5, 'empty index degrades to audio start');
});
// --- resolveOpusByteOffset: landingTimeSeconds (AC9 fine re-sync, §3.4a step 4) -----------------
test('resolveOpusByteOffset landingTimeSeconds equals the resolved page time, not the requested time', () => {
// Index: two points at t=0 s and t=0.5 s.
const preSkip = 312;
const sc = sidecarFrom({
setupHeader: [0], totalByteLength: 50_000, totalDuration: 1.5, preSkip,
points: [
{ granule: preSkip, byteOffset: 4096 }, // t=0
{ granule: preSkip + OPUS_SAMPLE_RATE / 2, byteOffset: 9000 }, // t=0.5 s
],
});
// Seeking to 0.3 s lands on the t=0 page; landing should be 0, not 0.3.
const r03: OpusSeekResolution = resolveOpusByteOffset(sc, 0.3);
assertEqual(r03.byteOffset, 4096, 'seek 0.3 -> first page offset');
assertEqual(r03.landingTimeSeconds, 0, 'landing at t=0 (page time, not target)');
// Seeking to exactly 0.5 s lands on the second page; landing == requested time.
const r05: OpusSeekResolution = resolveOpusByteOffset(sc, 0.5);
assertEqual(r05.byteOffset, 9000, 'seek 0.5 -> second page offset');
assertEqual(r05.landingTimeSeconds, 0.5, 'landing == requested when exact page boundary');
});
test('resolveOpusByteOffset empty index returns landingTimeSeconds = 0', () => {
const sc = sidecarFrom({
setupHeader: [0, 1, 2], totalByteLength: 1000, totalDuration: 1.0, preSkip: 0, points: [],
});
const r = resolveOpusByteOffset(sc, 5.0);
assertEqual(r.landingTimeSeconds, 0, 'empty index: landing is stream start (0 s)');
});
// --- Lead-trim frame math (AC9 fine re-sync) ---------------------------------------------------
// The trim frame count is purely arithmetic: (target - landing) * 48000, rounded, clamped to ≥0.
// This is the exact formula in OpusStreamDecoder.reinitializeForRangeContinuation so we test it
// independently of the browser-bound WebCodecs decode.
function leadTrimFrames(landingTimeSeconds: number, targetTimeSeconds: number): number {
return Math.max(0, Math.round((targetTimeSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE));
}
test('lead-trim frame count is (target - landing) * 48000, rounded', () => {
// Page at t=0, seek to 0.3 s: trim 0.3 * 48000 = 14400 frames.
assertEqual(leadTrimFrames(0, 0.3), 14400, 'trim for 0.3 s offset');
// Page at t=0.5 s, seek to 0.7 s: trim 0.2 * 48000 = 9600 frames.
assertEqual(leadTrimFrames(0.5, 0.7), 9600, 'trim for 0.2 s offset');
// Exact page boundary: no trim needed.
assertEqual(leadTrimFrames(0.5, 0.5), 0, 'no trim when target == landing');
// Guard against floating-point rounding producing a tiny negative: clamp to 0.
assertEqual(leadTrimFrames(0.5000001, 0.5), 0, 'negative rounds to zero (guard)');
});
// --- OggDemuxer: page -> packet extraction ----------------------------------------------------