using System.IO.Pipes;
using NAudio.CoreAudioApi;
using NAudio.Wave;
using TravelEar.Core;
namespace TravelEar.Helper;
///
/// Owns the audio side of the Helper on a dedicated thread: selects the render endpoint, then either
/// plays a test tone (--tone) or connects to the Sink pipe and renders every frame it
/// receives through NAudio (shared mode, event-driven). Exits when the
/// pipe closes, when the endpoint cannot be found, or when is called.
///
internal sealed class SinkRenderer : IDisposable
{
private const int LatencyMs = 40;
private const int ToneSampleRate = 48_000;
private const int ToneChannels = 2;
private const float ToneFrequencyHz = 440f;
private const float ToneAmplitude = 0.25f; // about -12 dBFS
private const int PipeRetryMs = 1000;
private const int StatusIntervalMs = 500;
private readonly HelperOptions _options;
private readonly Action _report;
private readonly CancellationTokenSource _stop = new();
private Thread? _thread;
private int _exitCode = Program.ExitOk;
private int _exited;
public event Action? Exited;
public SinkRenderer(HelperOptions options, Action report)
{
_options = options;
_report = report;
}
public void Start()
{
if (_thread is not null) return;
_thread = new Thread(Run) { Name = "TravelEar.SinkRenderer", IsBackground = true };
_thread.Start();
}
public void Stop()
{
_stop.Cancel();
// The pipe read is blocking; closing the stream unblocks it (see Run).
_pipe?.Dispose();
if (_thread is not null && _thread != Thread.CurrentThread) _thread.Join(3000);
_backThread?.Join(1500);
}
public void Dispose()
{
Stop();
_stop.Dispose();
}
private NamedPipeClientStream? _pipe;
private readonly System.Collections.Concurrent.ConcurrentQueue _reports = new();
// [impl->REQ-OFFSET-MEASURE]
// The window's Offset figure: the same capture/render pairs the mod averages, averaged here
// too (M3 T4d: the Helper's window is TravelEar's one UI; the game's menus are untouched).
private readonly OffsetAverager _averager = new();
private readonly object _averagerLock = new();
private const int MaxQueuedReports = 256;
private Thread? _backThread;
private NamedPipeClientStream? _backPipe;
private void Run()
{
try
{
using var enumerator = new MMDeviceEnumerator();
var device = SelectDevice(enumerator);
if (device is null)
{
_exitCode = Program.ExitNoEndpoint;
return;
}
using (device)
{
if (_options.Tone) RunTone(device);
else RunPipe(device);
}
}
catch (OperationCanceledException) { }
catch (Exception e)
{
HelperLog.Write($"Renderer failed: {e}");
_report($"Renderer failed:\n{e.Message}");
_exitCode = Program.ExitAudioFailure;
}
finally
{
if (Interlocked.Exchange(ref _exited, 1) == 0)
Exited?.Invoke(_exitCode);
}
}
// [impl->REQ-SINK-ENDPOINT-CONFIG]
/// Resolves --endpoint against the active render devices; null (and a logged list) when nothing matches.
private MMDevice? SelectDevice(MMDeviceEnumerator enumerator)
{
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active).ToList();
var names = devices.Select(d => d.FriendlyName).ToList();
if (!SinkEndpointSelector.TryChoose(names, _options.EndpointSetting, out var index))
{
var listing = string.Join("\n ", names);
HelperLog.Write($"No render device matches '{_options.EndpointSetting}'. Active devices:\n {listing}");
_report($"No playback device matches '{_options.EndpointSetting}'.\nActive devices:\n {listing}");
foreach (var d in devices) d.Dispose();
return null;
}
MMDevice chosen;
if (index == SinkEndpointSelector.DefaultDevice)
{
chosen = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
foreach (var d in devices) d.Dispose();
}
else
{
chosen = devices[index];
for (var i = 0; i < devices.Count; i++) if (i != index) devices[i].Dispose();
}
HelperLog.Write($"Render endpoint: {chosen.FriendlyName}");
return chosen;
}
private void RunTone(MMDevice device)
{
var provider = new ToneWaveProvider(ToneSampleRate, ToneChannels, ToneFrequencyHz, ToneAmplitude);
using var output = new WasapiOut(device, AudioClientShareMode.Shared, true, LatencyMs);
output.Init(provider);
output.Play();
HelperLog.Write($"Tone: {ToneFrequencyHz} Hz on {device.FriendlyName}");
var started = DateTime.UtcNow;
while (!_stop.IsCancellationRequested)
{
_report($"Endpoint : {device.FriendlyName}\nMode : test tone {ToneFrequencyHz} Hz, {ToneSampleRate} Hz {ToneChannels} ch\n" +
$"Playing : {(DateTime.UtcNow - started):hh\\:mm\\:ss}\n\nClose this window to stop.");
_stop.Token.WaitHandle.WaitOne(StatusIntervalMs);
}
output.Stop();
}
private void RunPipe(MMDevice device)
{
_pipe = new NamedPipeClientStream(".", _options.PipeName, PipeDirection.In, PipeOptions.None);
while (!_stop.IsCancellationRequested)
{
try
{
_report($"Endpoint : {device.FriendlyName}\nPipe : {_options.PipeName}\nState : waiting for the game...");
_pipe.Connect(PipeRetryMs);
break;
}
catch (TimeoutException) { }
catch (IOException) { _stop.Token.WaitHandle.WaitOne(PipeRetryMs); }
}
if (_stop.IsCancellationRequested) return;
HelperLog.Write("Pipe connected.");
var scratch = Array.Empty();
var samples = Array.Empty();
WasapiOut? output = null;
RingWaveProvider? provider = null;
long frames = 0;
var lastStatus = DateTime.MinValue;
var lastLog = DateTime.UtcNow;
try
{
while (!_stop.IsCancellationRequested)
{
bool got;
try
{
got = SinkFrame.TryReadFrom(_pipe, out var header, ref samples, ref scratch);
if (!got) break; // clean EOF: the game closed the pipe
if (provider is null || provider.Channels != header.Channels || provider.SampleRate != header.SampleRate)
{
output?.Stop();
output?.Dispose();
provider = new RingWaveProvider((int)header.SampleRate, header.Channels, (int)header.SampleRate * header.Channels / 2);
output = new WasapiOut(device, AudioClientShareMode.Shared, true, LatencyMs);
output.Init(provider);
provider.Prime();
var clock = output;
provider.FallbackLatencySeconds = LatencyMs / 1000.0;
provider.PlayedSeconds = () =>
{
try { return (double)clock.GetPosition() / clock.OutputWaveFormat.AverageBytesPerSecond; }
catch (Exception) { return null; }
};
provider.Reported = report =>
{
if (_reports.Count < MaxQueuedReports) _reports.Enqueue(report);
var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
lock (_averagerLock)
_averager.Add((report.RenderTimestamp - report.CaptureTimestamp) * 1000.0 / frequency, report.RenderTimestamp * 1000.0 / frequency);
};
output.Play();
StartBackPipe();
HelperLog.Write($"Stream format: {header.SampleRate} Hz, {header.Channels} ch");
}
// [impl->REQ-OFFSET-MEASURE]
var writePosition = provider.Ring.WritePosition;
provider.Ring.Write(samples.AsSpan(0, header.SampleCount));
if (header.SampleCount > 0) provider.Stamps.Mark(writePosition, header.SampleCount, header.CaptureTimestamp);
frames++;
}
catch (IOException) when (_stop.IsCancellationRequested) { break; }
catch (ObjectDisposedException) { break; }
catch (InvalidDataException e)
{
HelperLog.Write($"Pipe framing error, closing: {e.Message}");
_report($"Pipe framing error:\n{e.Message}");
_exitCode = Program.ExitAudioFailure;
break;
}
var now = DateTime.UtcNow;
if ((now - lastLog).TotalSeconds >= 10 && provider is not null)
{
lastLog = now;
HelperLog.Write($"Stream: frames {frames}, buffered {provider.Ring.Count} smp, underruns {provider.Ring.Underruns}, starves {provider.Starve.Starves}{(provider.Starve.Holding ? " (holding)" : "")}, dropped {provider.Ring.DroppedSamples} smp, trims {provider.Trims} ({provider.TrimmedSamples} smp), offset reports {provider.Reports}");
}
if ((now - lastStatus).TotalMilliseconds >= StatusIntervalMs && provider is not null)
{
lastStatus = now;
_report($"Endpoint : {device.FriendlyName}\nPipe : {_options.PipeName}\n" +
$"Stream : {provider.SampleRate} Hz {provider.Channels} ch\n" +
$"Frames : {frames} buffered {provider.Ring.Count} samples\n" +
$"Underruns: {provider.Ring.Underruns} starves {provider.Starve.Starves} dropped {provider.Ring.DroppedSamples}\n" +
$"Trims : {provider.Trims} ({provider.TrimmedSamples} samples; cap {RingWaveProvider.MaxBacklogMs} ms)\n" +
$"Offset : {OffsetStatus(provider.Reports)}{(_backPipe?.IsConnected == true ? "" : " (return pipe not connected)")}");
}
}
}
finally
{
output?.Stop();
output?.Dispose();
}
HelperLog.Write($"Pipe closed after {frames} frames.");
}
// [impl->REQ-OFFSET-MEASURE]
///
/// Drains to the return pipe on its own thread. The mod serves the pipe;
/// while it is not there, reports are dropped and the connection retried every second. Nothing
/// here can stall the render thread or the Sink read.
///
// [impl->REQ-OFFSET-MEASURE]
/// "TravelEar offset: N ms (rolling 10 s, K frames)" or "measuring" while the window is still empty.
private string OffsetStatus(long reports)
{
double average, min, max;
int count;
bool ok;
lock (_averagerLock)
ok = _averager.TryAverage(System.Diagnostics.Stopwatch.GetTimestamp() * 1000.0 / System.Diagnostics.Stopwatch.Frequency, out average, out min, out max, out count);
return ok
? $"{OffsetCaption.Format(average)} (rolling 10 s, {count} frames, {min:F0}-{max:F0} ms; {reports} reports)"
: $"{OffsetCaption.Format(double.NaN)} ({reports} reports)";
}
private void StartBackPipe()
{
if (_backThread is not null) return;
_backThread = new Thread(RunBackPipe) { Name = "TravelEar.OffsetBack", IsBackground = true };
_backThread.Start();
}
private void RunBackPipe()
{
var scratch = new byte[OffsetReportFrame.Size];
var name = HelperOptions.BackPipeName(_options.PipeName);
while (!_stop.IsCancellationRequested)
{
try
{
using var pipe = new NamedPipeClientStream(".", name, PipeDirection.Out, PipeOptions.None);
_backPipe = pipe;
try { pipe.Connect(PipeRetryMs); }
catch (TimeoutException) { while (_reports.TryDequeue(out _)) { } continue; }
HelperLog.Write("Return pipe connected.");
while (!_stop.IsCancellationRequested)
{
if (!_reports.TryDequeue(out var report))
{
_stop.Token.WaitHandle.WaitOne(20);
continue;
}
OffsetReportFrame.WriteTo(pipe, report, scratch);
}
}
catch (Exception e) when (e is IOException or ObjectDisposedException or UnauthorizedAccessException)
{
HelperLog.Write($"Return pipe closed ({e.GetType().Name}); retrying.");
_stop.Token.WaitHandle.WaitOne(PipeRetryMs);
}
catch (Exception e)
{
HelperLog.Write($"Return pipe error: {e.Message}");
_stop.Token.WaitHandle.WaitOne(PipeRetryMs);
}
finally
{
_backPipe = null;
}
}
}
}