33d6f34d8a
Background-job transcode (ffmpeg/libopus) after source store; pure C# Ogg walker builds the 0.5s-bucketed granule→byte seek index + captures the OpusHead/OpusTags setup header into a per-track sidecar in a new track-opus vault. Best-effort, additive, regenerated on replace-audio.
305 lines
12 KiB
C#
305 lines
12 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, 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.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class OggOpusParserTests
|
|
{
|
|
[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_IsTheFirstAudioPageAtItsExactByteOffset()
|
|
{
|
|
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 firstAudioOffset = (ulong)(head.Length + tags.Length);
|
|
|
|
var walk = OggOpusParser.Walk(stream);
|
|
|
|
Assert.That(walk, Is.Not.Null);
|
|
var first = walk!.SeekIndex.Points[0];
|
|
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));
|
|
}
|
|
|
|
[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. At 0.5 s buckets we expect ~10 entries
|
|
// (the first audio page is always taken, then one per crossed half-second boundary).
|
|
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 → 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),
|
|
"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_ReflectFinalGranuleAndTotalByteLength()
|
|
{
|
|
var head = OggPage(granule: 0, OpusHeadPacket());
|
|
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 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");
|
|
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)");
|
|
}
|
|
|
|
[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(48000, 200),
|
|
new OpusSeekPoint(72000, 512),
|
|
new OpusSeekPoint(96000, 900),
|
|
};
|
|
var index = new OggOpusSeekIndex(points, TotalDurationSeconds: 2.0, TotalByteLength: 1024);
|
|
|
|
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.Points, Is.EqualTo(points));
|
|
}
|
|
|
|
[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);
|
|
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));
|
|
}
|
|
|
|
[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()
|
|
{
|
|
// "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 };
|
|
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;
|
|
}
|
|
}
|