fix: Wave 18.1 review — pre-skip subtraction, t=0 anchor, PreSkip in sidecar, stderr on cancel
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -36,8 +36,23 @@ public static class OggOpusConstants
|
||||
/// <summary>Sentinel granule position for a page that ends mid-packet (no usable timestamp).</summary>
|
||||
public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL;
|
||||
|
||||
/// <summary>Header size of the serialized seek-index blob: totalBytes(8) + duration(8) + count(4).</summary>
|
||||
public const int SeekIndexHeaderSize = 20;
|
||||
/// <summary>
|
||||
/// Minimum byte length of an <c>OpusHead</c> packet payload to safely read <c>pre_skip</c>.
|
||||
/// RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) + pre_skip(2) = 12 bytes minimum.
|
||||
/// </summary>
|
||||
public const int OpusHeadMinSize = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Byte offset of <c>pre_skip</c> within the full <c>OpusHead</c> packet payload (including the
|
||||
/// magic). RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) = 10 bytes before pre_skip.
|
||||
/// </summary>
|
||||
public const int OpusHeadPreSkipOffset = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Header size of the serialized seek-index blob:
|
||||
/// totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2) = 24 bytes.
|
||||
/// </summary>
|
||||
public const int SeekIndexHeaderSize = 24;
|
||||
|
||||
/// <summary>Size of one serialized seek point: granulepos(8) + byteOffset(8).</summary>
|
||||
public const int SeekPointSize = 16;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,25 @@ namespace DeepDrftContent.Processors.Opus;
|
||||
|
||||
/// <summary>
|
||||
/// A single seek-index entry: an authoritative 48 kHz <see cref="GranulePosition"/> (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 <see cref="ByteOffset"/> is a real page-start
|
||||
/// boundary, so a <c>Range: bytes={ByteOffset}-</c> 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 <see cref="ByteOffset"/> is a real page-start boundary, so a
|
||||
/// <c>Range: bytes={ByteOffset}-</c> fetch lands the decoder Ogg-sync-aligned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per RFC 7845 §4.3, the PCM presentation time is <c>(granulepos − preSkip) / 48000</c>. The raw
|
||||
/// <see cref="GranulePosition"/> is stored here as-is; callers should subtract the containing
|
||||
/// <see cref="OggOpusSeekIndex.PreSkip"/> before converting to a presentation time. Use
|
||||
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the corrected value.
|
||||
/// </remarks>
|
||||
/// <param name="GranulePosition">The page's end granule position (48 kHz sample count).</param>
|
||||
/// <param name="ByteOffset">The byte offset of the page start in the Opus file.</param>
|
||||
public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset)
|
||||
{
|
||||
/// <summary>Time in seconds this granule position represents (granulepos / 48 kHz).</summary>
|
||||
public double TimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
|
||||
/// <summary>
|
||||
/// Raw granule-position-to-time conversion (granulepos / 48 kHz). Does NOT subtract pre-skip — use
|
||||
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the RFC 7845-correct presentation time.
|
||||
/// </summary>
|
||||
public double RawTimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,20 +32,38 @@ public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOff
|
||||
/// One entry per 0.5 s of audio (<see cref="OggOpusConstants.SeekBucketSeconds"/>), each snapped to the
|
||||
/// nearest enclosing page start, plus the totals needed to clamp a seek to range.
|
||||
/// </summary>
|
||||
/// <param name="Points">Ordered (granulepos, byteOffset) entries, ascending. The first is always the
|
||||
/// first audio page (offset just past the setup headers).</param>
|
||||
/// <param name="TotalDurationSeconds">Total stream duration from the final granule position.</param>
|
||||
/// <param name="Points">Ordered (granulepos, byteOffset) entries, ascending. The first entry always
|
||||
/// has <see cref="OpusSeekPoint.GranulePosition"/> == <paramref name="PreSkip"/> (corrected time = 0)
|
||||
/// and points at the first audio page start, ensuring a seek to t=0 always resolves.</param>
|
||||
/// <param name="TotalDurationSeconds">
|
||||
/// Pre-skip-corrected total stream duration: <c>max(0, lastGranule − preSkip) / 48000</c>.
|
||||
/// </param>
|
||||
/// <param name="TotalByteLength">Total Opus file byte length, for clamping a seek past the end.</param>
|
||||
/// <param name="PreSkip">
|
||||
/// The <c>pre_skip</c> value from the <c>OpusHead</c> 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.
|
||||
/// </param>
|
||||
public sealed record OggOpusSeekIndex(
|
||||
IReadOnlyList<OpusSeekPoint> Points,
|
||||
double TotalDurationSeconds,
|
||||
ulong TotalByteLength)
|
||||
ulong TotalByteLength,
|
||||
ushort PreSkip)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the RFC 7845-correct presentation time for a seek point: <c>max(0, granule − preSkip) / 48000</c>.
|
||||
/// Use this for all time comparisons; raw <see cref="OpusSeekPoint.RawTimeSeconds"/> omits the pre-skip.
|
||||
/// </summary>
|
||||
public double PresentationTimeSeconds(OpusSeekPoint point) =>
|
||||
Math.Max(0.0, (point.GranulePosition - (double)PreSkip) / OggOpusConstants.OpusSampleRate);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout:
|
||||
/// <c>[uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount]</c> then
|
||||
/// <c>pointCount × (uint64 granulepos, uint64 byteOffset)</c>. Fixed-width records keep the client
|
||||
/// parse to a single typed-array read.
|
||||
/// <c>[uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount][uint16 preSkip][uint16 reserved]</c>
|
||||
/// then <c>pointCount × (uint64 granulepos, uint64 byteOffset)</c>. 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,19 @@ 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.
|
||||
/// 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()
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user