using System.Diagnostics; using System.IO.Pipes; using TravelEar.Core; namespace TravelEar; /// /// Drains the Tap ring into the Sink: a named-pipe server (TravelEar.Sink, outbound bytes) /// that the Helper connects to as a client, fed with s on a dedicated /// thread. Pacing follows the audio thread's clock: the pump sends whatever the Tap has produced /// since the last tick instead of asking for a fixed amount, so it never pads with silence on its /// own. Every disconnect (Helper closed, never started) re-arms the server, no more often than /// every 5 s (, REQ-SINK-LIFECYCLE); nothing here can reach /// gameplay or the game's audio (docs/KNOWN-HAZARDS.md 2.1). With config Downmix on, each /// block is folded to mono before framing (REQ-SINK-FORMAT). /// internal sealed class SinkPump : IDisposable { private const int TickMs = 5; private const int MinFramesPerSend = 240; // 5 ms at 48 kHz private const int MaxFramesPerSend = 4800; // 100 ms at 48 kHz private readonly VoiceRingBuffer _ring; private readonly FrameStampTable _stamps; private readonly Func _channels; private readonly Func _sampleRate; private readonly Func _downmix; private readonly HelperLifecycle _lifecycle = new(); private readonly CancellationTokenSource _stop = new(); private Thread _thread; public long FramesSent; public long Connections; public volatile bool Connected; public volatile string LastError; /// The feed point's ring: or (ADR-0005). /// The capture stamps keyed by that ring's positions. public SinkPump(VoiceRingBuffer ring, FrameStampTable stamps, Func channels, Func sampleRate, Func downmix = null) { _ring = ring; _stamps = stamps; _channels = channels; _sampleRate = sampleRate; _downmix = downmix ?? (() => false); } private static double NowMs() => Stopwatch.GetTimestamp() * 1000.0 / Stopwatch.Frequency; public void Start() { _thread = new Thread(Run) { Name = "TravelEar.SinkPump", IsBackground = true, Priority = ThreadPriority.AboveNormal }; _thread.Start(); } private void Run() { var token = _stop.Token; var buffer = new float[MaxFramesPerSend * 8]; var scratch = Array.Empty(); while (!token.IsCancellationRequested) { NamedPipeServerStream server = null; try { // [impl->REQ-SINK-LIFECYCLE] var delay = (int)_lifecycle.DelayBeforeArmMs(NowMs()); if (delay > 0 && token.WaitHandle.WaitOne(delay)) break; server = new NamedPipeServerStream(HelperOptions.DefaultPipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); server.WaitForConnectionAsync(token).GetAwaiter().GetResult(); Connected = true; Interlocked.Increment(ref Connections); Plugin.Logger.LogInfo("Sink: Helper connected to the pipe."); _ring.Clear(); // start fresh; stale audio would only add latency while (!token.IsCancellationRequested) { var channels = _channels(); if (channels <= 0) { Thread.Sleep(20); continue; } var available = _ring.Count; var frames = Math.Min(available / channels, MaxFramesPerSend); if (frames < MinFramesPerSend) { Thread.Sleep(TickMs); continue; } var count = frames * channels; var samples = buffer.AsSpan(0, count); // [impl->REQ-OFFSET-MEASURE] var readPosition = _ring.ReadPosition; _ring.Read(samples); if (!_stamps.TryResolve(readPosition, 0, out var captured)) captured = FrameStampTable.NoStamp; // [impl->REQ-SINK-FORMAT] if (channels > 1 && _downmix()) { Downmixer.ToMono(samples, channels, samples); samples = samples.Slice(0, frames); channels = 1; count = frames; } var header = new SinkFrameHeader((ushort)channels, (uint)_sampleRate(), captured, count); SinkFrame.WriteTo(server, header, samples, ref scratch); Interlocked.Increment(ref FramesSent); } } catch (OperationCanceledException) { } catch (Exception e) when (e is IOException or ObjectDisposedException) { if (Connected) Plugin.Logger.LogInfo($"Sink: Helper disconnected ({e.GetType().Name}); waiting for a new connection."); LastError = e.Message; } catch (Exception e) { LastError = e.Message; Plugin.Logger.LogWarning($"Sink: pump error, re-arming in 1 s: {e}"); Thread.Sleep(1000); } finally { Connected = false; server?.Dispose(); } } } public void Dispose() { _stop.Cancel(); _thread?.Join(500); } }