using System.Buffers.Binary; using System.Text; using DeepDrftContent.Processors.Opus; 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, 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() { var head = OggPage(granule: 0, OpusHeadPacket()); var tags = OggPage(granule: 0, OpusTagsPacket()); var audio = OggPage(granule: 48000, AudioPacket(64)); var stream = Concat(head, tags, audio); var walk = OggOpusParser.Walk(stream); Assert.That(walk, Is.Not.Null, "A well-formed Ogg Opus stream must walk"); var expectedSetup = Concat(head, tags); Assert.That(walk!.SetupHeaderBytes, Is.EqualTo(expectedSetup), "Setup header must be the OpusHead + OpusTags pages, byte-for-byte, and stop before the first audio page"); } [Test] public void Walk_FirstSeekPoint_IsAnchoredAtTimeZero_PointingAtFirstAudioPage() { // 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)); var stream = Concat(head, tags, audio); var firstAudioOffset = (ulong)(head.Length + tags.Length); var walk = OggOpusParser.Walk(stream); 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)"); // 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] public void Walk_EverySeekOffset_LandsOnARealPageBoundary() { // Ten audio pages, ~0.25 s each (12000 samples). Bucketing at 0.5 s means roughly every other // page is indexed; every indexed offset must still be the start of some OggS page. var head = OggPage(granule: 0, OpusHeadPacket()); var tags = OggPage(granule: 0, OpusTagsPacket()); var pages = new List { head, tags }; ulong granule = 0; for (var i = 0; i < 10; i++) { granule += 12000; // 0.25 s at 48 kHz pages.Add(OggPage(granule, AudioPacket(50 + i))); } var stream = Concat(pages.ToArray()); var pageOffsets = CollectPageStartOffsets(stream); var walk = OggOpusParser.Walk(stream); Assert.That(walk, Is.Not.Null); foreach (var point in walk!.SeekIndex.Points) { Assert.That(pageOffsets, Does.Contain(point.ByteOffset), $"Seek offset {point.ByteOffset} must be a real OggS page start"); } } [Test] public void Walk_Bucketing_EmitsRoughlyOneEntryPerHalfSecond() { // 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()); var pages = new List { head, tags }; ulong granule = 0; for (var i = 0; i < 20; i++) { granule += 12000; // 0.25 s pages.Add(OggPage(granule, AudioPacket(40))); } var stream = Concat(pages.ToArray()); var walk = OggOpusParser.Walk(stream); Assert.That(walk, Is.Not.Null); // 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"); } [Test] public void Walk_PointsAreStrictlyAscending_InBothGranuleAndOffset() { var head = OggPage(granule: 0, OpusHeadPacket()); var tags = OggPage(granule: 0, OpusTagsPacket()); var pages = new List { head, tags }; ulong granule = 0; for (var i = 0; i < 12; i++) { granule += 24000; // 0.5 s — one index entry per page pages.Add(OggPage(granule, AudioPacket(30))); } var walk = OggOpusParser.Walk(Concat(pages.ToArray())); Assert.That(walk, Is.Not.Null); var points = walk!.SeekIndex.Points; for (var i = 1; i < points.Count; i++) { Assert.That(points[i].GranulePosition, Is.GreaterThan(points[i - 1].GranulePosition)); Assert.That(points[i].ByteOffset, Is.GreaterThan(points[i - 1].ByteOffset)); } } [Test] public void Walk_ClampValues_ReflectPreSkipCorrectedDurationAndTotalByteLength() { 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 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); var walk = OggOpusParser.Walk(stream); Assert.That(walk, Is.Not.Null); Assert.That(walk!.SeekIndex.TotalByteLength, Is.EqualTo((ulong)stream.Length), "Total byte length must equal the full stream length for end-of-stream clamping"); 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] public void Walk_MalformedStream_ReturnsNull_RatherThanThrowing() { var notOgg = Encoding.ASCII.GetBytes("this is not an ogg stream at all"); Assert.That(OggOpusParser.Walk(notOgg), Is.Null); // OpusHead present but no audio pages → no seek points → null (nothing to index). var headOnly = Concat(OggPage(0, OpusHeadPacket()), OggPage(0, OpusTagsPacket())); Assert.That(OggOpusParser.Walk(headOnly), Is.Null); } [Test] public void SeekIndex_RoundTrips_ThroughBinaryEncoding() { var points = new[] { 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, 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(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()); Assert.That(restored, Is.Not.Null); Assert.That(restored!.SetupHeaderBytes, Is.EqualTo(setup), "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] public void Sidecar_FromBytes_RejectsTruncatedBlob() { Assert.That(OpusSidecar.FromBytes(new byte[2]), Is.Null, "A blob shorter than the length prefix is corruption"); // A length prefix that overruns the buffer must be rejected, not over-read. var bad = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(bad, 9999); Assert.That(OpusSidecar.FromBytes(bad), Is.Null); } // ---- Ogg stream construction helpers (minimal, single-packet pages) ---- // Builds one Ogg page wrapping a single packet payload with the given end granule position. The page // header layout matches the spec the parser reads: capture "OggS", version, header-type, granulepos, // serial, sequence, checksum (zeroed — the parser does not verify CRC), page-segments, segment table. private static byte[] OggPage(ulong granule, byte[] packet) { // Lacing: a packet of length L is split into 255-byte segments plus a final < 255 segment. var segments = new List(); var remaining = packet.Length; while (remaining >= 255) { segments.Add(255); remaining -= 255; } segments.Add((byte)remaining); var header = new byte[OggOpusConstants.OggPageHeaderSize + segments.Count]; OggOpusConstants.CapturePattern.CopyTo(header); header[4] = 0; // version header[5] = 0; // header-type flags BinaryPrimitives.WriteUInt64LittleEndian(header.AsSpan(OggOpusConstants.GranulePositionOffset, 8), granule); BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(14, 4), 0xDEAD); // serial BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(18, 4), 0); // sequence (unused by parser) BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(22, 4), 0); // checksum (unverified) header[OggOpusConstants.PageSegmentCountOffset] = (byte)segments.Count; for (var i = 0; i < segments.Count; i++) header[OggOpusConstants.OggPageHeaderSize + i] = segments[i]; return Concat(header, packet); } private static byte[] OpusHeadPacket(ushort preSkip = 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); } private static byte[] OpusTagsPacket() { // "OpusTags" + a tiny vendor string region (length-prefixed) + zero user comments. var vendor = Encoding.ASCII.GetBytes("test"); var packet = new List(); packet.AddRange(OggOpusConstants.OpusTagsSignature.ToArray()); packet.AddRange(BitConverter.GetBytes((uint)vendor.Length)); packet.AddRange(vendor); packet.AddRange(BitConverter.GetBytes(0u)); // user comment count return packet.ToArray(); } private static byte[] AudioPacket(int size) { var packet = new byte[size]; for (var i = 0; i < size; i++) packet[i] = (byte)(i & 0xFF); return packet; } private static List CollectPageStartOffsets(byte[] stream) { var offsets = new List(); var span = stream.AsSpan(); var offset = 0; while (offset + OggOpusConstants.OggPageHeaderSize <= span.Length) { var page = span.Slice(offset); if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern)) break; var segmentCount = page[OggOpusConstants.PageSegmentCountOffset]; var payload = 0; for (var i = 0; i < segmentCount; i++) payload += page[OggOpusConstants.OggPageHeaderSize + i]; offsets.Add((ulong)offset); offset += OggOpusConstants.OggPageHeaderSize + segmentCount + payload; } return offsets; } private static byte[] Concat(params byte[][] parts) { var total = parts.Sum(p => p.Length); var result = new byte[total]; var cursor = 0; foreach (var part in parts) { part.CopyTo(result, cursor); cursor += part.Length; } return result; } }