using NAudio.Wave; using TravelEar.Core; namespace TravelEar.Helper; /// /// NAudio source that drains a . WASAPI's render thread pulls /// float32 interleaved samples; the ring pads with silence when the pipe falls behind, so the /// stream never stalls. When the pipe runs ahead (start-up burst, clock drift between the game's /// DSP and the endpoint) the backlog is trimmed back to , so the /// ring never becomes unbounded latency: it holds at most before a trim. /// /// The band between the two is the jitter budget: a pad (underrun) is a gap and a trim is a cut, /// and either lands mid-word as a click. M1 ran 30-80 ms, and M2 run 4 heard "debris" with the /// game rendering; the per-frame Offset spread inside one 10 s window was ~200 ms, so the band is /// now 100-200 ms, and the ring is primed with of silence at stream /// start (): without that it floated at whatever the first frames left, 40-120 ms /// in M2 run 5, and a ~200 ms stall drained it (five pads in one second, heard as a crackle burst). /// The cost is ~100 ms more Offset, which the Offset line reports. /// /// internal sealed class RingWaveProvider : IWaveProvider { public const int MaxBacklogMs = 200; public const int TargetBacklogMs = 100; public int SampleRate { get; } public int Channels { get; } public VoiceRingBuffer Ring { get; } public WaveFormat WaveFormat { get; } public long Trims { get; private set; } public long TrimmedSamples { get; private set; } /// Re-prime rule after the ring runs dry (); its count is logged beside underruns. public StarveGuard Starve { get; } /// Capture timestamps keyed by ring position: the pipe thread marks each frame it stores (REQ-OFFSET-MEASURE). public FrameStampTable Stamps { get; } = new(); /// Seconds of audio the endpoint has played so far, from the output's audio clock; null = unknown (fall back to nominal latency). public Func? PlayedSeconds { get; set; } /// Nominal output latency used when is unavailable. public double FallbackLatencySeconds { get; set; } /// Raised on the render thread with one report per resolved frame; must not block. public Action? Reported { get; set; } public long Reports { get; private set; } private long _bytesProvided; private readonly int _maxBacklogSamples; private readonly int _targetBacklogSamples; public RingWaveProvider(int sampleRate, int channels, int ringCapacitySamples) { SampleRate = sampleRate; Channels = channels; Ring = new VoiceRingBuffer(ringCapacitySamples); WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channels); _maxBacklogSamples = sampleRate * channels * MaxBacklogMs / 1000; _targetBacklogSamples = sampleRate * channels * TargetBacklogMs / 1000; Starve = new StarveGuard(_targetBacklogSamples); } /// Queues of silence ahead of the first frame; the pipe thread calls it once before playback starts. public void Prime() { Ring.Write(new float[_targetBacklogSamples]); } public int Read(byte[] buffer, int offset, int count) { var sampleCount = count / sizeof(float); var backlog = Ring.Count; // [impl->REQ-SINK-FORMAT] // After a starve (the game stopped encoding: mute, menu) hold on silence until the backlog // is back at the target, so the next burst starts with its jitter budget (ADR-0005). if (Starve.ShouldHold(backlog, sampleCount)) { Array.Clear(buffer, offset, sampleCount * sizeof(float)); _bytesProvided += sampleCount * sizeof(float); return sampleCount * sizeof(float); } if (backlog > _maxBacklogSamples) { var excess = backlog - _targetBacklogSamples; excess -= excess % Channels; // keep channel alignment TrimmedSamples += Ring.Discard(excess); Trims++; } // [impl->REQ-OFFSET-MEASURE] // The first sample of this read is at the ring's read position; if a capture stamp covers // it, its render time is now plus whatever is still queued ahead of it in the endpoint. var readPosition = Ring.ReadPosition; if (Stamps.TryResolve(readPosition, 0, out var captured)) { var now = System.Diagnostics.Stopwatch.GetTimestamp(); var providedSeconds = (double)_bytesProvided / WaveFormat.AverageBytesPerSecond; var played = PlayedSeconds?.Invoke(); var queuedSeconds = played is double p ? Math.Max(0, providedSeconds - p) : FallbackLatencySeconds; var renderAt = now + (long)(queuedSeconds * System.Diagnostics.Stopwatch.Frequency); Reports++; Reported?.Invoke(new OffsetReport(captured, renderAt)); } var floats = System.Runtime.InteropServices.MemoryMarshal.Cast(buffer.AsSpan(offset, sampleCount * sizeof(float))); Ring.Read(floats); _bytesProvided += sampleCount * sizeof(float); return sampleCount * sizeof(float); } }