125 lines
6.6 KiB
C#
125 lines
6.6 KiB
C#
using System.Buffers.Binary;
|
||
|
||
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) 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>
|
||
/// 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>
|
||
/// The accurate, precomputed transfer function from seek-time to true file byte offset for one Ogg
|
||
/// Opus stream (§3.4a A). Built once at transcode time by walking the encoded stream; the client reads
|
||
/// it back and binary-searches <see cref="Points"/> instead of doing inaccurate VBR byte-rate math.
|
||
/// 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 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,
|
||
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][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()
|
||
{
|
||
var size = OggOpusConstants.SeekIndexHeaderSize + Points.Count * OggOpusConstants.SeekPointSize;
|
||
var bytes = new byte[size];
|
||
var span = bytes.AsSpan();
|
||
|
||
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)
|
||
{
|
||
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor, 8), point.GranulePosition);
|
||
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor + 8, 8), point.ByteOffset);
|
||
cursor += OggOpusConstants.SeekPointSize;
|
||
}
|
||
|
||
return bytes;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses a blob produced by <see cref="ToBytes"/>. Returns null if the blob is too short or its
|
||
/// declared point count does not fit — the storage contract is exact, so a malformed blob is a
|
||
/// corruption signal, not a recoverable shape. (Provided so tests and any future server-side reader
|
||
/// share one codec with the writer.)
|
||
/// </summary>
|
||
public static OggOpusSeekIndex? FromBytes(ReadOnlySpan<byte> bytes)
|
||
{
|
||
if (bytes.Length < OggOpusConstants.SeekIndexHeaderSize)
|
||
return null;
|
||
|
||
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)
|
||
return null;
|
||
|
||
var points = new OpusSeekPoint[count];
|
||
var cursor = OggOpusConstants.SeekIndexHeaderSize;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var granule = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor, 8));
|
||
var offset = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor + 8, 8));
|
||
points[i] = new OpusSeekPoint(granule, offset);
|
||
cursor += OggOpusConstants.SeekPointSize;
|
||
}
|
||
|
||
return new OggOpusSeekIndex(points, totalDuration, totalByteLength, preSkip);
|
||
}
|
||
}
|