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
+28 -6
View File
@@ -123,21 +123,40 @@ export function presentationTimeSeconds(granulePosition: number, preSkip: number
return Math.max(0, (granulePosition - preSkip) / OPUS_SAMPLE_RATE);
}
/**
* Result of resolving a seek time to a page-start byte offset.
* `byteOffset` is the Range request origin; `landingTimeSeconds` is the actual presentation time of that
* page (t_page ≤ positionSeconds). The caller uses the delta `positionSeconds - landingTimeSeconds` to
* trim the decoded leading frames so playback lands at the requested position, not at t_page (AC9).
*/
export interface OpusSeekResolution {
/** Page-start byte offset to use as the Range request origin (Ogg-sync-aligned). */
byteOffset: number;
/**
* Presentation time of the resolved index page (seconds). Always ≤ positionSeconds. The decoder
* must trim `(positionSeconds - landingTimeSeconds) * OPUS_SAMPLE_RATE` leading frames so the
* audible start and the reported clock both land at positionSeconds, not at landingTimeSeconds.
*/
landingTimeSeconds: number;
}
/**
* Resolve a seek time (seconds) to a file-absolute, page-start byte offset via the precomputed index —
* the accurate VBR-safe transfer function (§3.4a A/C). Binary-searches for the largest entry whose
* presentation time is <= `positionSeconds` and returns its exact page-start byte offset. NOT
* interpolation, NOT byteRate math. With an empty index it degrades to the start of audio (the offset
* of the first audio page == the setup-header length, since the server emits [setup pages][audio pages]).
* presentation time is <= `positionSeconds`. Returns both the page-start byte offset AND the actual
* landing time of that page, so callers can trim leading decoded frames to land precisely at
* `positionSeconds` (AC9 fine re-sync). NOT interpolation, NOT byteRate math.
*
* With an empty index it degrades to the start of audio (offset == setup-header length, landing == 0).
*
* This is the single source of truth for Opus seek-offset math, shared by the seek-beyond-buffer path
* (AudioPlayer) and any byte-offset resolver. The Range fetch from this offset lands the decoder
* Ogg-sync-aligned because every indexed offset is a real page start.
*/
export function resolveOpusByteOffset(sidecar: OpusSeekData, positionSeconds: number): number {
export function resolveOpusByteOffset(sidecar: OpusSeekData, positionSeconds: number): OpusSeekResolution {
const points = sidecar.points;
if (points.length === 0) {
return sidecar.setupHeaderBytes.length;
return { byteOffset: sidecar.setupHeaderBytes.length, landingTimeSeconds: 0 };
}
let lo = 0;
@@ -153,7 +172,10 @@ export function resolveOpusByteOffset(sidecar: OpusSeekData, positionSeconds: nu
hi = mid - 1;
}
}
return points[best].byteOffset;
return {
byteOffset: points[best].byteOffset,
landingTimeSeconds: presentationTimeSeconds(points[best].granulePosition, sidecar.preSkip)
};
}
function toUint8Array(input: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array {