fix: Wave 18.1 review — pre-skip subtraction, t=0 anchor, PreSkip in sidecar, stderr on cancel

This commit is contained in:
daniel-c-harvey
2026-06-23 06:55:31 -04:00
parent 33d6f34d8a
commit 6add30a4ff
6 changed files with 251 additions and 45 deletions
@@ -33,6 +33,7 @@ public static class OggOpusParser
var setupHeaderEnd = -1;
var sawOpusHead = false;
var sawOpusTags = false;
ushort preSkip = 0;
var points = new List<OpusSeekPoint>();
ulong lastGranule = 0;
@@ -73,6 +74,15 @@ public static class OggOpusParser
{
sawOpusHead = true;
setupHeaderEnd = offset + pageTotalSize;
// RFC 7845 §5.1 — OpusHead layout after the 8-byte "OpusHead" magic:
// [0] version (1 byte), [1] channel count (1 byte),
// [2-3] pre_skip (little-endian uint16) ← at packet bytes 10-11
// pre_skip is the number of decoder samples to discard before presenting audio;
// all granule→time conversions must subtract it (RFC 7845 §4.3).
if (payload.Length >= OggOpusConstants.OpusHeadMinSize)
preSkip = BinaryPrimitives.ReadUInt16LittleEndian(
payload.Slice(OggOpusConstants.OpusHeadPreSkipOffset, 2));
}
else if (sawOpusHead && !sawOpusTags && StartsWith(payload, OggOpusConstants.OpusTagsSignature))
{
@@ -87,19 +97,28 @@ public static class OggOpusParser
// the byte cursor.
if (granule != OggOpusConstants.NoGranulePosition)
{
var pageTime = granule / OggOpusConstants.OpusSampleRate;
// RFC 7845 §4.3: presentation time = max(0, granule preSkip) / 48000.
// Use this corrected time for bucketing so that a stream with pre-skip 3840 (~80 ms)
// does not systematically offset every indexed time by that amount.
var correctedTime = Math.Max(0.0,
(granule - (double)preSkip) / OggOpusConstants.OpusSampleRate);
if (!firstAudioPointTaken)
{
points.Add(new OpusSeekPoint(granule, (ulong)offset));
// Anchor the first seek point at corrected time = 0 by storing the granule as
// preSkip. This guarantees that a binary search for t=0 ("largest entry with
// corrected time ≤ 0") always resolves to the first audio page's byte offset —
// even when the real granule is slightly above preSkip due to encoder lead-in.
points.Add(new OpusSeekPoint(preSkip, (ulong)offset));
firstAudioPointTaken = true;
nextBucketTime = OggOpusConstants.SeekBucketSeconds;
}
else if (pageTime >= nextBucketTime)
else if (correctedTime >= nextBucketTime)
{
points.Add(new OpusSeekPoint(granule, (ulong)offset));
// Advance past every bucket this page crossed so a long page does not emit a
// backlog of entries; the next bucket is the first boundary strictly after it.
while (nextBucketTime <= pageTime)
while (nextBucketTime <= correctedTime)
nextBucketTime += OggOpusConstants.SeekBucketSeconds;
}
@@ -114,8 +133,11 @@ public static class OggOpusParser
return null;
var setupHeader = oggBytes[..setupHeaderEnd].ToArray();
var totalDuration = lastGranule / OggOpusConstants.OpusSampleRate;
var index = new OggOpusSeekIndex(points, totalDuration, (ulong)oggBytes.Length);
// RFC 7845 §4.3: total duration is also pre-skip-corrected, matching the time a listener
// experiences (the last audio page's corrected time, clamped to ≥ 0).
var totalDuration = Math.Max(0.0,
(lastGranule - (double)preSkip) / OggOpusConstants.OpusSampleRate);
var index = new OggOpusSeekIndex(points, totalDuration, (ulong)oggBytes.Length, preSkip);
return new OggOpusWalk(setupHeader, index);
}