diff --git a/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs
index 4e7f7e1..65af701 100644
--- a/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs
+++ b/DeepDrftContent/Processors/Opus/FfmpegOpusEncoder.cs
@@ -90,11 +90,13 @@ public sealed class FfmpegOpusEncoder
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
TryKill(process);
+ await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
throw; // genuine shutdown cancellation — let it propagate
}
catch (OperationCanceledException)
{
TryKill(process);
+ await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
_logger.LogError("Opus transcode: ffmpeg exceeded the {Timeout}s timeout for {Source}.",
_options.TimeoutSeconds, sourcePath);
return false;
diff --git a/DeepDrftContent/Processors/Opus/OggOpusConstants.cs b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs
index f6fa431..fbba891 100644
--- a/DeepDrftContent/Processors/Opus/OggOpusConstants.cs
+++ b/DeepDrftContent/Processors/Opus/OggOpusConstants.cs
@@ -36,8 +36,23 @@ public static class OggOpusConstants
/// Sentinel granule position for a page that ends mid-packet (no usable timestamp).
public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL;
- /// Header size of the serialized seek-index blob: totalBytes(8) + duration(8) + count(4).
- public const int SeekIndexHeaderSize = 20;
+ ///
+ /// Minimum byte length of an OpusHead packet payload to safely read pre_skip.
+ /// RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) + pre_skip(2) = 12 bytes minimum.
+ ///
+ public const int OpusHeadMinSize = 12;
+
+ ///
+ /// Byte offset of pre_skip within the full OpusHead packet payload (including the
+ /// magic). RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) = 10 bytes before pre_skip.
+ ///
+ public const int OpusHeadPreSkipOffset = 10;
+
+ ///
+ /// Header size of the serialized seek-index blob:
+ /// totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2) = 24 bytes.
+ ///
+ public const int SeekIndexHeaderSize = 24;
/// Size of one serialized seek point: granulepos(8) + byteOffset(8).
public const int SeekPointSize = 16;
diff --git a/DeepDrftContent/Processors/Opus/OggOpusParser.cs b/DeepDrftContent/Processors/Opus/OggOpusParser.cs
index 98452b4..0e4f850 100644
--- a/DeepDrftContent/Processors/Opus/OggOpusParser.cs
+++ b/DeepDrftContent/Processors/Opus/OggOpusParser.cs
@@ -33,6 +33,7 @@ public static class OggOpusParser
var setupHeaderEnd = -1;
var sawOpusHead = false;
var sawOpusTags = false;
+ ushort preSkip = 0;
var points = new List();
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);
}
diff --git a/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs
index b5bff4c..465a1b5 100644
--- a/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs
+++ b/DeepDrftContent/Processors/Opus/OggOpusSeekIndex.cs
@@ -4,16 +4,25 @@ namespace DeepDrftContent.Processors.Opus;
///
/// A single seek-index entry: an authoritative 48 kHz (Opus granule
-/// positions are always sample counts at 48 kHz, so time = granulepos / 48000) paired with the exact
-/// byte offset of the Ogg page that carries it. Every is a real page-start
-/// boundary, so a Range: bytes={ByteOffset}- fetch lands the decoder Ogg-sync-aligned.
+/// positions are always sample counts at 48 kHz) paired with the exact byte offset of the Ogg page that
+/// carries it. Every is a real page-start boundary, so a
+/// Range: bytes={ByteOffset}- fetch lands the decoder Ogg-sync-aligned.
///
+///
+/// Per RFC 7845 §4.3, the PCM presentation time is (granulepos − preSkip) / 48000. The raw
+/// is stored here as-is; callers should subtract the containing
+/// before converting to a presentation time. Use
+/// for the corrected value.
+///
/// The page's end granule position (48 kHz sample count).
/// The byte offset of the page start in the Opus file.
public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset)
{
- /// Time in seconds this granule position represents (granulepos / 48 kHz).
- public double TimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
+ ///
+ /// Raw granule-position-to-time conversion (granulepos / 48 kHz). Does NOT subtract pre-skip — use
+ /// for the RFC 7845-correct presentation time.
+ ///
+ public double RawTimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
}
///
@@ -23,20 +32,38 @@ public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOff
/// One entry per 0.5 s of audio (), each snapped to the
/// nearest enclosing page start, plus the totals needed to clamp a seek to range.
///
-/// Ordered (granulepos, byteOffset) entries, ascending. The first is always the
-/// first audio page (offset just past the setup headers).
-/// Total stream duration from the final granule position.
+/// Ordered (granulepos, byteOffset) entries, ascending. The first entry always
+/// has == (corrected time = 0)
+/// and points at the first audio page start, ensuring a seek to t=0 always resolves.
+///
+/// Pre-skip-corrected total stream duration: max(0, lastGranule − preSkip) / 48000.
+///
/// Total Opus file byte length, for clamping a seek past the end.
+///
+/// The pre_skip value from the OpusHead identification header (RFC 7845 §5.1). Opus
+/// decoders must discard this many samples from the decoded start before presenting audio. The client
+/// (wave 18.4) needs this to trim the first decoded buffer; storing it here avoids a re-parse of the
+/// Ogg stream at delivery time.
+///
public sealed record OggOpusSeekIndex(
IReadOnlyList Points,
double TotalDurationSeconds,
- ulong TotalByteLength)
+ ulong TotalByteLength,
+ ushort PreSkip)
{
+ ///
+ /// Returns the RFC 7845-correct presentation time for a seek point: max(0, granule − preSkip) / 48000.
+ /// Use this for all time comparisons; raw omits the pre-skip.
+ ///
+ public double PresentationTimeSeconds(OpusSeekPoint point) =>
+ Math.Max(0.0, (point.GranulePosition - (double)PreSkip) / OggOpusConstants.OpusSampleRate);
+
///
/// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout:
- /// [uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount] then
- /// pointCount × (uint64 granulepos, uint64 byteOffset). Fixed-width records keep the client
- /// parse to a single typed-array read.
+ /// [uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount][uint16 preSkip][uint16 reserved]
+ /// then pointCount × (uint64 granulepos, uint64 byteOffset). The four-byte preSkip+reserved
+ /// region pads the header to 24 bytes, keeping the point table 8-byte-aligned.
+ /// Fixed-width records keep the client parse to a single typed-array read.
///
public byte[] ToBytes()
{
@@ -47,6 +74,8 @@ public sealed record OggOpusSeekIndex(
BinaryPrimitives.WriteUInt64LittleEndian(span[..8], TotalByteLength);
BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(8, 8), TotalDurationSeconds);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), (uint)Points.Count);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(20, 2), PreSkip);
+ // bytes 22-23: reserved (zero-initialized by array allocation)
var cursor = OggOpusConstants.SeekIndexHeaderSize;
foreach (var point in Points)
@@ -73,6 +102,8 @@ public sealed record OggOpusSeekIndex(
var totalByteLength = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]);
var totalDuration = BinaryPrimitives.ReadDoubleLittleEndian(bytes.Slice(8, 8));
var count = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(16, 4));
+ var preSkip = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(20, 2));
+ // bytes 22-23: reserved — ignored on read for forward-compatibility
var expected = OggOpusConstants.SeekIndexHeaderSize + (long)count * OggOpusConstants.SeekPointSize;
if (bytes.Length < expected)
@@ -88,6 +119,6 @@ public sealed record OggOpusSeekIndex(
cursor += OggOpusConstants.SeekPointSize;
}
- return new OggOpusSeekIndex(points, totalDuration, totalByteLength);
+ return new OggOpusSeekIndex(points, totalDuration, totalByteLength, preSkip);
}
}
diff --git a/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs
index 712344c..dba73e9 100644
--- a/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs
+++ b/DeepDrftContent/Processors/Opus/OpusTranscodeService.cs
@@ -132,6 +132,9 @@ public sealed class OpusTranscodeService
private async Task EnsureVaultAsync()
{
+ // The TrackOpus vault is created at host startup (Startup.cs), so this guard is normally a
+ // no-op for the upload path. It is retained for the backfill path, which may run via a
+ // standalone CLI or a host that skips vault pre-creation, where the vault might not exist.
if (!_fileDatabase.HasVault(VaultConstants.TrackOpus))
await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
}
diff --git a/DeepDrftTests/OggOpusParserTests.cs b/DeepDrftTests/OggOpusParserTests.cs
index 2a7728f..9d024f9 100644
--- a/DeepDrftTests/OggOpusParserTests.cs
+++ b/DeepDrftTests/OggOpusParserTests.cs
@@ -7,13 +7,19 @@ namespace DeepDrftTests;
///
/// Coverage for the Phase 18.1 seek-index + setup-header extraction (§3.4a). These exercise the pure
/// Ogg-Opus walker and the sidecar codec over hand-built Ogg streams — no ffmpeg dependency — so the
-/// granule→byte mapping, page-boundary snapping, 0.5 s bucketing, clamp totals, and setup-header capture
-/// are asserted deterministically. The byte layout mirrors a real Opus stream: an OpusHead page, an
-/// OpusTags page, then audio pages each carrying an end granule position at 48 kHz.
+/// granule→byte mapping, pre-skip correction (RFC 7845 §4.3), page-boundary snapping, 0.5 s bucketing,
+/// t=0 anchor, clamp totals, and setup-header capture are asserted deterministically. The byte layout
+/// mirrors a real Opus stream: an OpusHead page, an OpusTags page, then audio pages each carrying an
+/// end granule position at 48 kHz.
///
[TestFixture]
public class OggOpusParserTests
{
+ // libopus default pre-skip: 312 samples at 48 kHz (≈ 6.5 ms). FFmpeg may use 3840 (~80 ms).
+ // Using 312 here as a realistic non-zero value that is small enough not to affect the test
+ // granules (which start at 48000), while still exercising the pre-skip subtraction path.
+ private const ushort TestPreSkip = 312;
+
[Test]
public void Walk_CapturesSetupHeader_AsLeadingOpusHeadAndOpusTagsPagesVerbatim()
{
@@ -32,9 +38,11 @@ public class OggOpusParserTests
}
[Test]
- public void Walk_FirstSeekPoint_IsTheFirstAudioPageAtItsExactByteOffset()
+ public void Walk_FirstSeekPoint_IsAnchoredAtTimeZero_PointingAtFirstAudioPage()
{
- var head = OggPage(granule: 0, OpusHeadPacket());
+ // Use a non-zero pre-skip to verify that the first seek point is explicitly anchored at
+ // corrected time = 0, not at the raw granule time.
+ var head = OggPage(granule: 0, OpusHeadPacket(preSkip: TestPreSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
var audio = OggPage(granule: 48000, AudioPacket(64));
@@ -45,9 +53,87 @@ public class OggOpusParserTests
Assert.That(walk, Is.Not.Null);
var first = walk!.SeekIndex.Points[0];
+
+ // The first point's byte offset must be the first audio page start (exact page boundary).
Assert.That(first.ByteOffset, Is.EqualTo(firstAudioOffset),
"The first seek point must land on the first audio page's start offset (an exact page boundary)");
- Assert.That(first.GranulePosition, Is.EqualTo(48000UL));
+
+ // The first point's stored granule is clamped to preSkip so corrected presentation time = 0.
+ // This guarantees a binary search for t=0 always resolves to the first audio page.
+ Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(TestPreSkip),
+ "PreSkip must be parsed from OpusHead and carried into the seek index");
+ Assert.That(walk.SeekIndex.PresentationTimeSeconds(first), Is.EqualTo(0.0).Within(1e-12),
+ "First seek point must have corrected presentation time = 0 so a seek to t=0 always resolves");
+ }
+
+ [Test]
+ public void Walk_PreSkip_IsSubtractedFromGranuleInTimeCalculations()
+ {
+ // Pre-skip of 3840 samples (≈ 80 ms, the libopus typical value used by ffmpeg).
+ // Without the fix, pageTime = 48000 / 48000 = 1.0 s; with fix, (48000 - 3840) / 48000 = 0.92 s.
+ const ushort preSkip = 3840;
+ var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
+ var tags = OggPage(granule: 0, OpusTagsPacket());
+ // First audio page at granule 48000 (1.0 s raw; 0.92 s corrected)
+ var a1 = OggPage(granule: 48000, AudioPacket(64));
+ // Second audio page at granule 96000 (2.0 s raw; 1.92 s corrected)
+ var a2 = OggPage(granule: 96000, AudioPacket(64));
+ // Third audio page at granule 144000 (3.0 s raw; 2.92 s corrected)
+ var a3 = OggPage(granule: 144000, AudioPacket(64));
+
+ var walk = OggOpusParser.Walk(Concat(head, tags, a1, a2, a3));
+
+ Assert.That(walk, Is.Not.Null);
+ var index = walk!.SeekIndex;
+
+ Assert.That(index.PreSkip, Is.EqualTo(preSkip), "PreSkip must be parsed from OpusHead");
+
+ // TotalDurationSeconds must be pre-skip-corrected: (144000 - 3840) / 48000 = 2.92 s
+ var expectedDuration = (144000.0 - preSkip) / 48000.0;
+ Assert.That(index.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9),
+ "TotalDurationSeconds must subtract preSkip (RFC 7845 §4.3), not use raw lastGranule / 48000");
+
+ // The second indexed point (first real bucket) must have corrected time, not raw time.
+ // With correctedTime(a2) = 1.92 s and bucket = 0.5 s, it should fall in the 1.5 s bucket.
+ if (index.Points.Count > 1)
+ {
+ var secondPoint = index.Points[1];
+ var corrected = index.PresentationTimeSeconds(secondPoint);
+ Assert.That(corrected, Is.GreaterThan(0.0),
+ "Non-first indexed points must have positive corrected presentation times");
+ Assert.That(secondPoint.RawTimeSeconds, Is.GreaterThan(corrected),
+ "Raw time must be greater than corrected time when pre-skip > 0");
+ }
+ }
+
+ [Test]
+ public void Walk_SeekToZero_ResolvesToFirstAudioPageOffset_WithNonZeroPreSkip()
+ {
+ // This is the AC9 / Critical-2 regression test: a seek to t=0 must resolve to the first audio
+ // page's byte offset, not produce "no entry found". With the old code (no t=0 anchor and no
+ // pre-skip correction), the first indexed point had correctedTime ≈ 0.92 s (for preSkip=3840),
+ // so a binary search for t=0 would find no entry with time ≤ 0 and fail.
+ const ushort preSkip = 3840;
+ var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
+ var tags = OggPage(granule: 0, OpusTagsPacket());
+ var a1 = OggPage(granule: 48000, AudioPacket(64));
+ var a2 = OggPage(granule: 96000, AudioPacket(64));
+
+ var stream = Concat(head, tags, a1, a2);
+ var firstAudioByteOffset = (ulong)(head.Length + tags.Length);
+
+ var walk = OggOpusParser.Walk(stream);
+ Assert.That(walk, Is.Not.Null);
+
+ var index = walk!.SeekIndex;
+ var firstPoint = index.Points[0];
+
+ // Simulate the binary search: find the largest entry with PresentationTimeSeconds ≤ 0.
+ // With the fix, the first point has corrected time = 0.0, so it IS found.
+ Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12),
+ "First point corrected time must be exactly 0.0 so binary search for t=0 resolves it");
+ Assert.That(firstPoint.ByteOffset, Is.EqualTo(firstAudioByteOffset),
+ "The t=0 anchor must point at the first audio page's byte offset, not the stream start");
}
[Test]
@@ -82,8 +168,8 @@ public class OggOpusParserTests
[Test]
public void Walk_Bucketing_EmitsRoughlyOneEntryPerHalfSecond()
{
- // Twenty audio pages of 0.25 s each = 5 s total. At 0.5 s buckets we expect ~10 entries
- // (the first audio page is always taken, then one per crossed half-second boundary).
+ // Twenty audio pages of 0.25 s each = 5 s total (zero pre-skip). At 0.5 s buckets:
+ // first point (anchored at t=0) + one per 0.5 s boundary = 1 + 10 = 11 entries expected.
var head = OggPage(granule: 0, OpusHeadPacket());
var tags = OggPage(granule: 0, OpusTagsPacket());
@@ -99,9 +185,9 @@ public class OggOpusParserTests
var walk = OggOpusParser.Walk(stream);
Assert.That(walk, Is.Not.Null);
- // 5 s of audio → first point + one per 0.5 s boundary up to 5.0 s. Allow a small tolerance for
- // boundary rounding, but it must be far below "one per page" (20) and at least the ~10 buckets.
- Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(9, 12),
+ // 5 s of audio with 0.5 s buckets: 1 anchor + 10 bucket crossings = 11 entries.
+ // Accept 10–12 for floating-point boundary tolerance, but must be far below 20 (one per page).
+ Assert.That(walk!.SeekIndex.Points.Count, Is.InRange(10, 12),
"Bucketing must coalesce ~0.25 s pages into ~0.5 s index entries, not one per page");
}
@@ -131,12 +217,13 @@ public class OggOpusParserTests
}
[Test]
- public void Walk_ClampValues_ReflectFinalGranuleAndTotalByteLength()
+ public void Walk_ClampValues_ReflectPreSkipCorrectedDurationAndTotalByteLength()
{
- var head = OggPage(granule: 0, OpusHeadPacket());
+ const ushort preSkip = 312;
+ var head = OggPage(granule: 0, OpusHeadPacket(preSkip: preSkip));
var tags = OggPage(granule: 0, OpusTagsPacket());
- var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s
- var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s (final)
+ var a1 = OggPage(granule: 48000, AudioPacket(64)); // 1.0 s raw; ~0.9935 s corrected
+ var a2 = OggPage(granule: 144000, AudioPacket(64)); // 3.0 s raw; ~2.9935 s corrected (final)
var stream = Concat(head, tags, a1, a2);
@@ -145,8 +232,13 @@ public class OggOpusParserTests
Assert.That(walk!.SeekIndex.TotalByteLength, Is.EqualTo((ulong)stream.Length),
"Total byte length must equal the full stream length for end-of-stream clamping");
- Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(3.0).Within(1e-9),
- "Total duration must derive from the final page's granule position (144000 / 48000 = 3.0 s)");
+
+ var expectedDuration = (144000.0 - preSkip) / 48000.0;
+ Assert.That(walk.SeekIndex.TotalDurationSeconds, Is.EqualTo(expectedDuration).Within(1e-9),
+ "TotalDurationSeconds must be pre-skip-corrected: (lastGranule - preSkip) / 48000");
+
+ Assert.That(walk.SeekIndex.PreSkip, Is.EqualTo(preSkip),
+ "PreSkip must round-trip through the seek index");
}
[Test]
@@ -165,27 +257,58 @@ public class OggOpusParserTests
{
var points = new[]
{
- new OpusSeekPoint(48000, 200),
+ new OpusSeekPoint(312, 200), // first point anchored at preSkip
new OpusSeekPoint(72000, 512),
new OpusSeekPoint(96000, 900),
};
- var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024);
+ var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024,
+ PreSkip: 312);
var restored = OggOpusSeekIndex.FromBytes(index.ToBytes());
Assert.That(restored, Is.Not.Null);
Assert.That(restored!.TotalByteLength, Is.EqualTo(1024UL));
Assert.That(restored.TotalDurationSeconds, Is.EqualTo(2.0));
+ Assert.That(restored.PreSkip, Is.EqualTo((ushort)312),
+ "PreSkip must survive the binary round-trip");
Assert.That(restored.Points, Is.EqualTo(points));
}
+ [Test]
+ public void SeekIndex_PresentationTimeSeconds_SubtractsPreSkip()
+ {
+ const ushort preSkip = 3840;
+ var point = new OpusSeekPoint(GranulePosition: 48000, ByteOffset: 200);
+ var index = new OggOpusSeekIndex(
+ new[] { point }, TotalDurationSeconds: 0.92, TotalByteLength: 500, PreSkip: preSkip);
+
+ var corrected = index.PresentationTimeSeconds(point);
+ var expected = (48000.0 - preSkip) / 48000.0; // ≈ 0.92 s
+
+ Assert.That(corrected, Is.EqualTo(expected).Within(1e-9),
+ "PresentationTimeSeconds must return (granule - preSkip) / 48000, not raw granule / 48000");
+ }
+
+ [Test]
+ public void SeekIndex_PresentationTimeSeconds_ClampsToZeroForFirstAnchorPoint()
+ {
+ const ushort preSkip = 3840;
+ // First anchor point: granule stored as preSkip, so corrected time = 0.
+ var firstPoint = new OpusSeekPoint(GranulePosition: preSkip, ByteOffset: 150);
+ var index = new OggOpusSeekIndex(
+ new[] { firstPoint }, TotalDurationSeconds: 2.0, TotalByteLength: 500, PreSkip: preSkip);
+
+ Assert.That(index.PresentationTimeSeconds(firstPoint), Is.EqualTo(0.0).Within(1e-12),
+ "The t=0 anchor point (granule == preSkip) must yield corrected time = 0.0 exactly");
+ }
+
[Test]
public void Sidecar_RoundTrips_PreservingSetupHeaderAndIndex()
{
var setup = Encoding.ASCII.GetBytes("OpusHead-and-OpusTags-bytes-go-here");
var index = new OggOpusSeekIndex(
- new[] { new OpusSeekPoint(48000, 200), new OpusSeekPoint(96000, 700) },
- TotalDurationSeconds: 2.0, TotalByteLength: 800);
+ new[] { new OpusSeekPoint(312, 200), new OpusSeekPoint(96000, 700) },
+ TotalDurationSeconds: 2.0, TotalByteLength: 800, PreSkip: 312);
var sidecar = new OpusSidecar(setup, index);
var restored = OpusSidecar.FromBytes(sidecar.ToBytes());
@@ -195,6 +318,8 @@ public class OggOpusParserTests
"The sidecar must preserve the setup header so the client can prepend it to mid-stream slices");
Assert.That(restored.SeekIndex.Points, Is.EqualTo(index.Points));
Assert.That(restored.SeekIndex.TotalByteLength, Is.EqualTo(800UL));
+ Assert.That(restored.SeekIndex.PreSkip, Is.EqualTo((ushort)312),
+ "PreSkip must survive the sidecar binary round-trip");
}
[Test]
@@ -240,10 +365,18 @@ public class OggOpusParserTests
return Concat(header, packet);
}
- private static byte[] OpusHeadPacket()
+ private static byte[] OpusHeadPacket(ushort preSkip = 0)
{
- // "OpusHead" + a minimal valid-ish identification header tail (version, channels, pre-skip, etc.).
- var tail = new byte[] { 1, 2, 0, 0, 0x80, 0xBB, 0, 0, 0, 0, 0 };
+ // "OpusHead" + RFC 7845 §5.1 identification header:
+ // [0] version = 1, [1] channel count = 2,
+ // [2-3] pre_skip (little-endian uint16), [4-7] input sample rate = 0xBB80 = 48000,
+ // [8-9] output gain = 0, [10] channel mapping family = 0.
+ var tail = new byte[11];
+ tail[0] = 1; // version
+ tail[1] = 2; // channels
+ BinaryPrimitives.WriteUInt16LittleEndian(tail.AsSpan(2, 2), preSkip); // pre_skip
+ BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4, 4), 48000); // input sample rate
+ tail[10] = 0; // channel mapping family
return Concat(OggOpusConstants.OpusHeadSignature.ToArray(), tail);
}