using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using BepInEx.Logging;
using TravelEar.Core;
namespace TravelEar;
///
/// T4a reference capture (M3-PLAN): while Calibration.Capture is on, everything needed to
/// compare Local Voice with what a real listener renders for the same speech is written under
/// %LOCALAPPDATA%\TravelEar\calibration\<timestamp>\:
///
/// - input.wav: the decoded outbound voice before any of the mod's processing, one
/// frame per encoded frame, gate silence included, so sample position is time.
/// - local-voice.wav: the chain's output exactly as sent to the Helper (tails and
/// silence included), sample-aligned with input.wav.
/// - frames.csv: per frame, its sample offset, the encode timestamp and wall clock, and
/// whether the gate passed it, rendered a tail, or silenced it.
/// - floats.csv: every exposed mixer float the game wrote, with elapsed ms.
/// - session.txt: start time and the config in force.
///
/// The Helper's --capture mode records the other machine's output beside these as
/// peer.wav; tools/calibrate.py aligns and compares the three.
///
internal sealed class CalibrationCapture : IDisposable
{
private readonly ManualLogSource _log;
private readonly WavWriter _input;
private readonly WavWriter _output;
private readonly StreamWriter _frames;
private readonly StreamWriter _floats;
private readonly object _floatsLock = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
private long _inputSamples;
private long _frameIndex;
private long _floatWrites;
private bool _disposed;
/// The live capture, set by the plugin while Calibration.Capture is on; read on the encoder thread.
public static volatile CalibrationCapture Instance;
public string Directory { get; }
public static string Root =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TravelEar", "calibration");
public CalibrationCapture(ManualLogSource log, string sessionNotes)
{
_log = log;
Directory = Path.Combine(Root, DateTime.Now.ToString("yyyyMMdd-HHmmss"));
System.IO.Directory.CreateDirectory(Directory);
_input = new WavWriter(Path.Combine(Directory, "input.wav"), LocalVoiceDecoder.SampleRate);
_output = new WavWriter(Path.Combine(Directory, "local-voice.wav"), LocalVoiceDecoder.SampleRate);
_frames = new StreamWriter(Path.Combine(Directory, "frames.csv"), false, Encoding.ASCII) { AutoFlush = false };
_frames.WriteLine("frame,input_sample,elapsed_ms,captured_at_ticks,wallclock,disposition");
_floats = new StreamWriter(Path.Combine(Directory, "floats.csv"), false, Encoding.ASCII) { AutoFlush = false };
_floats.WriteLine("elapsed_ms,name,value");
File.WriteAllText(Path.Combine(Directory, "session.txt"),
$"started {DateTime.Now:O}\nsample_rate {LocalVoiceDecoder.SampleRate}\nstopwatch_frequency {Stopwatch.Frequency}\n{sessionNotes}\n");
MixerFloats.Observer = OnFloat;
_log.LogInfo($"Calibration capture: recording to {Directory}");
}
/// Encoder thread: one decoded frame before the chain, and how the gate treated it.
public void Input(ReadOnlySpan pcm, long capturedAtTicks, string disposition)
{
if (_disposed) return;
var offset = _inputSamples;
_input.Write(pcm);
_inputSamples += pcm.Length;
var index = _frameIndex++;
lock (_frames)
{
_frames.Write(index.ToString(CultureInfo.InvariantCulture)); _frames.Write(',');
_frames.Write(offset.ToString(CultureInfo.InvariantCulture)); _frames.Write(',');
_frames.Write(_clock.Elapsed.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture)); _frames.Write(',');
_frames.Write(capturedAtTicks.ToString(CultureInfo.InvariantCulture)); _frames.Write(',');
_frames.Write(DateTime.Now.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)); _frames.Write(',');
_frames.WriteLine(disposition);
if ((index & 63) == 0) _frames.Flush();
}
}
/// Encoder thread: what went to the Helper for the frame just reported by .
public void Output(ReadOnlySpan frame)
{
if (_disposed) return;
_output.Write(frame);
}
public void OutputSilence(int count)
{
if (_disposed) return;
_output.WriteSilence(count);
}
private void OnFloat(string name, float value)
{
if (_disposed) return;
lock (_floatsLock)
{
_floats.Write(_clock.Elapsed.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture)); _floats.Write(',');
_floats.Write(name); _floats.Write(',');
_floats.WriteLine(value.ToString("R", CultureInfo.InvariantCulture));
if ((++_floatWrites & 255) == 0) _floats.Flush();
}
}
public string Status => $"calibration capture: {_inputSamples / (double)LocalVoiceDecoder.SampleRate:F0} s, {_frameIndex} frames, {_floatWrites} float writes";
public void Dispose()
{
if (_disposed) return;
_disposed = true;
MixerFloats.Observer = null;
try { _input.Dispose(); } catch (Exception) { /* best effort */ }
try { _output.Dispose(); } catch (Exception) { /* best effort */ }
lock (_frames) { try { _frames.Dispose(); } catch (Exception) { /* best effort */ } }
lock (_floatsLock) { try { _floats.Dispose(); } catch (Exception) { /* best effort */ } }
_log.LogInfo($"Calibration capture: closed {Directory} ({_inputSamples / (double)LocalVoiceDecoder.SampleRate:F0} s).");
}
}