using System.Buffers.Binary;
using System.Runtime.InteropServices;
namespace TravelEar.Core;
/// Fixed header preceding every block of samples on the Sink pipe.
/// Interleaved channel count (1 = mono, 2 = stereo).
/// Samples per second per channel (expected 48000).
/// Capture timestamp of the first sample (the mod's Stopwatch ticks at OpusEncoder.Encode, resolved through the rings), or 0 when the first sample carries none (mod-generated silence). The Helper pairs it with its render clock for Offset.
/// Total float32 samples in the payload, across all channels.
public readonly record struct SinkFrameHeader(ushort Channels, uint SampleRate, long CaptureTimestamp, int SampleCount);
///
/// Sink pipe framing shared by the mod (writer) and the Helper (reader). Little-endian, one
/// header then SampleCount float32 samples. The magic guards against a reader attaching
/// mid-stream or to the wrong pipe.
///
public static class SinkFrame
{
/// "TESK" (TravelEar Sink) read as a little-endian uint32.
public const uint Magic = 0x4B534554;
public const int HeaderSize = 4 + 2 + 4 + 8 + 4;
/// Upper bound on samples per frame; anything larger is a corrupt header. (1 s of 8-channel 48 kHz.)
public const int MaxSampleCount = 48_000 * 8;
public static int GetEncodedSize(int sampleCount) => HeaderSize + sampleCount * sizeof(float);
/// Writes header + samples into . Returns bytes written.
public static int Write(Span destination, in SinkFrameHeader header, ReadOnlySpan samples)
{
Validate(header);
if (samples.Length != header.SampleCount)
throw new ArgumentException("Sample span length must equal header.SampleCount.", nameof(samples));
var size = GetEncodedSize(samples.Length);
if (destination.Length < size)
throw new ArgumentException("Destination too small.", nameof(destination));
WriteHeader(destination, header);
var payload = destination.Slice(HeaderSize, samples.Length * sizeof(float));
if (BitConverter.IsLittleEndian)
{
MemoryMarshal.AsBytes(samples).CopyTo(payload);
}
else
{
for (var i = 0; i < samples.Length; i++)
BinaryPrimitives.WriteSingleLittleEndian(payload.Slice(i * sizeof(float)), samples[i]);
}
return size;
}
public static void WriteHeader(Span destination, in SinkFrameHeader header)
{
Validate(header);
if (destination.Length < HeaderSize) throw new ArgumentException("Destination too small.", nameof(destination));
BinaryPrimitives.WriteUInt32LittleEndian(destination, Magic);
BinaryPrimitives.WriteUInt16LittleEndian(destination.Slice(4), header.Channels);
BinaryPrimitives.WriteUInt32LittleEndian(destination.Slice(6), header.SampleRate);
BinaryPrimitives.WriteInt64LittleEndian(destination.Slice(10), header.CaptureTimestamp);
BinaryPrimitives.WriteInt32LittleEndian(destination.Slice(18), header.SampleCount);
}
/// Parses a header. False if the bytes are too short, have no magic, or describe an impossible frame.
public static bool TryReadHeader(ReadOnlySpan source, out SinkFrameHeader header)
{
header = default;
if (source.Length < HeaderSize) return false;
if (BinaryPrimitives.ReadUInt32LittleEndian(source) != Magic) return false;
var channels = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(4));
var rate = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(6));
var ts = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(10));
var count = BinaryPrimitives.ReadInt32LittleEndian(source.Slice(18));
var candidate = new SinkFrameHeader(channels, rate, ts, count);
if (!IsValid(candidate)) return false;
header = candidate;
return true;
}
///
/// Parses one complete frame from the front of . Returns false if
/// the header is invalid or the payload is incomplete. On success
/// receives a fresh array of header.SampleCount floats and
/// the total bytes used.
///
public static bool TryRead(ReadOnlySpan source, out SinkFrameHeader header, out float[] samples, out int consumed)
{
samples = Array.Empty();
consumed = 0;
if (!TryReadHeader(source, out header)) return false;
var size = GetEncodedSize(header.SampleCount);
if (source.Length < size) return false;
samples = new float[header.SampleCount];
ReadPayload(source.Slice(HeaderSize, header.SampleCount * sizeof(float)), samples);
consumed = size;
return true;
}
/// Copies a float32 little-endian payload into .
public static void ReadPayload(ReadOnlySpan payload, Span samples)
{
if (payload.Length != samples.Length * sizeof(float))
throw new ArgumentException("Payload length must be SampleCount * 4.", nameof(payload));
if (BitConverter.IsLittleEndian)
{
payload.CopyTo(MemoryMarshal.AsBytes(samples));
}
else
{
for (var i = 0; i < samples.Length; i++)
samples[i] = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(i * sizeof(float)));
}
}
/// Writes one frame to a stream (the pipe). is reused across calls when large enough.
public static void WriteTo(Stream stream, in SinkFrameHeader header, ReadOnlySpan samples, ref byte[] scratch)
{
var size = GetEncodedSize(samples.Length);
if (scratch.Length < size) scratch = new byte[size];
Write(scratch, header, samples);
stream.Write(scratch, 0, size);
}
///
/// Reads exactly one frame from a stream. Returns false on a clean end-of-stream before a
/// header starts; throws on a bad header or a payload
/// truncated by end-of-stream. is reused when large enough; the
/// valid samples are the first header.SampleCount.
///
public static bool TryReadFrom(Stream stream, out SinkFrameHeader header, ref float[] samples, ref byte[] scratch)
{
header = default;
if (scratch.Length < HeaderSize) scratch = new byte[Math.Max(HeaderSize, 4096)];
var got = ReadFully(stream, scratch, HeaderSize);
if (got == 0) return false;
if (got < HeaderSize) throw new InvalidDataException("Sink stream ended inside a frame header.");
if (!TryReadHeader(scratch, out header)) throw new InvalidDataException("Invalid Sink frame header.");
var payloadBytes = header.SampleCount * sizeof(float);
if (scratch.Length < payloadBytes) scratch = new byte[payloadBytes];
if (ReadFully(stream, scratch, payloadBytes) < payloadBytes)
throw new InvalidDataException("Sink stream ended inside a frame payload.");
if (samples.Length < header.SampleCount) samples = new float[header.SampleCount];
ReadPayload(scratch.AsSpan(0, payloadBytes), samples.AsSpan(0, header.SampleCount));
return true;
}
private static int ReadFully(Stream stream, byte[] buffer, int count)
{
var total = 0;
while (total < count)
{
var n = stream.Read(buffer, total, count - total);
if (n <= 0) break;
total += n;
}
return total;
}
private static bool IsValid(in SinkFrameHeader h) =>
h.Channels >= 1 && h.SampleRate > 0 && h.SampleCount >= 0 && h.SampleCount <= MaxSampleCount
&& h.SampleCount % h.Channels == 0;
private static void Validate(in SinkFrameHeader h)
{
if (!IsValid(h)) throw new ArgumentException("Invalid Sink frame header.", nameof(h));
}
}