namespace TravelEar.Core; /// /// Lock-free single-writer / single-reader float ring. The Tap (Unity audio thread) writes, /// the Sink pump thread reads. A read that finds fewer samples than requested pads the /// remainder with silence so the Sink stream never gaps; a write that finds too little free /// space drops the samples that do not fit. Both events are counted for diagnostics. /// public sealed class VoiceRingBuffer { private readonly float[] _buffer; private long _head; // next write position (monotonic), owned by the writer private long _tail; // next read position (monotonic), owned by the reader private long _dropped; private long _underruns; public VoiceRingBuffer(int capacity) { if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); _buffer = new float[capacity]; } public int Capacity => _buffer.Length; /// Samples currently buffered and not yet read. public int Count => (int)(Volatile.Read(ref _head) - Volatile.Read(ref _tail)); /// Monotonic position of the next sample stores (samples ever stored). public long WritePosition => Volatile.Read(ref _head); /// Monotonic position of the next sample returns (samples ever consumed or discarded). public long ReadPosition => Volatile.Read(ref _tail); /// Samples dropped by because the ring was full. public long DroppedSamples => Volatile.Read(ref _dropped); /// Number of calls that had to pad with silence. public long Underruns => Volatile.Read(ref _underruns); /// Writer side. Returns how many samples were stored; the rest were dropped. public int Write(ReadOnlySpan samples) { var head = _head; // writer-owned var tail = Volatile.Read(ref _tail); var free = Capacity - (int)(head - tail); var toWrite = Math.Min(free, samples.Length); if (toWrite < samples.Length) Interlocked.Add(ref _dropped, samples.Length - toWrite); if (toWrite == 0) return 0; var start = (int)(head % Capacity); var firstRun = Math.Min(toWrite, Capacity - start); samples.Slice(0, firstRun).CopyTo(_buffer.AsSpan(start, firstRun)); if (toWrite > firstRun) samples.Slice(firstRun, toWrite - firstRun).CopyTo(_buffer.AsSpan(0, toWrite - firstRun)); Volatile.Write(ref _head, head + toWrite); return toWrite; } /// /// Reader side. Fills completely: real samples first, then /// zeros if the ring runs dry. Returns the number of real samples copied. /// public int Read(Span destination) { var tail = _tail; // reader-owned var head = Volatile.Read(ref _head); var available = (int)(head - tail); var toRead = Math.Min(available, destination.Length); if (toRead > 0) { var start = (int)(tail % Capacity); var firstRun = Math.Min(toRead, Capacity - start); _buffer.AsSpan(start, firstRun).CopyTo(destination.Slice(0, firstRun)); if (toRead > firstRun) _buffer.AsSpan(0, toRead - firstRun).CopyTo(destination.Slice(firstRun, toRead - firstRun)); Volatile.Write(ref _tail, tail + toRead); } if (toRead < destination.Length) { destination.Slice(toRead).Clear(); Interlocked.Increment(ref _underruns); } return toRead; } /// /// Discards up to of the oldest buffered samples. Reader side only. /// Used by the Helper to clamp its backlog: a consumer that falls behind the producer would /// otherwise turn the whole ring into latency. Returns how many were discarded. /// public int Discard(int count) { if (count <= 0) return 0; var tail = _tail; var head = Volatile.Read(ref _head); var toDrop = Math.Min(count, (int)(head - tail)); if (toDrop > 0) Volatile.Write(ref _tail, tail + toDrop); return toDrop; } /// Discards everything buffered. Reader side only. public void Clear() { Volatile.Write(ref _tail, Volatile.Read(ref _head)); } }