438 lines
19 KiB
C#
438 lines
19 KiB
C#
using System.Buffers.Binary;
|
||
using System.Text;
|
||
using DeepDrftContent.Processors.Opus;
|
||
|
||
namespace DeepDrftTests;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
[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<byte[]> { 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<byte[]> { 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<byte[]> { 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<byte>();
|
||
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<byte>();
|
||
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<ulong> CollectPageStartOffsets(byte[] stream)
|
||
{
|
||
var offsets = new List<ulong>();
|
||
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;
|
||
}
|
||
}
|