using System;
using System.IO;
using System.Runtime.InteropServices;
using T3.Core.Logging;
namespace T3.Core.Audio;
///
/// Streaming RIFF / WAVE writer for 16-bit PCM audio. Opens a file with a placeholder
/// header, accepts interleaved float32 sample buffers from a WASAPI capture callback,
/// converts to int16 PCM on the fly, and finalises the RIFF / data chunk sizes on
/// .
///
///
///
/// Thread-safe: is called from the BASS WASAPI
/// capture callback while is typically called from the main thread.
/// A single lock guards both. The lock is uncontended in the common case (one writer per
/// recording session, one callback firing every ~10 ms).
///
///
/// Allocation behaviour: the internal conversion buffers grow once (the first time a
/// callback delivers a larger payload than the initial 4096-sample size) and then stay
/// resident for the lifetime of the writer. There are no allocations on the steady-state
/// hot path.
///
///
public sealed class WavFileWriter : IDisposable
{
///
/// Opens for writing and emits a placeholder 44-byte WAV header.
/// The header is rewritten with the correct sizes when the writer is disposed.
///
/// Destination file path. Parent directories must exist.
/// Sample rate of the incoming float32 samples (e.g. 48000).
/// Number of interleaved channels (1 = mono, 2 = stereo).
/// Invalid sample rate or channel count.
public WavFileWriter(string path, int sampleRate, int channels)
{
if (sampleRate <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRate), sampleRate, "Sample rate must be positive.");
if (channels <= 0 || channels > 8)
throw new ArgumentOutOfRangeException(nameof(channels), channels, "Channel count must be 1..8.");
Path = path;
SampleRate = sampleRate;
Channels = channels;
_stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
WritePlaceholderHeader();
}
public string Path { get; }
public int SampleRate { get; }
public int Channels { get; }
/// Total PCM sample bytes written so far (header bytes excluded).
public long BytesWritten => _bytesWritten;
/// Duration in seconds of audio written so far.
public double DurationSeconds => _bytesWritten / (double)(SampleRate * Channels * BytesPerSample);
///
/// Appends a buffer of interleaved float32 samples to the WAV file.
/// Samples are clamped to [-1, 1] and converted to 16-bit PCM little-endian.
///
/// Pointer to the source buffer (BASS callback's buffer).
/// Number of bytes available at .
public void AppendFloat32Samples(IntPtr buffer, int byteCount)
{
if (byteCount <= 0 || buffer == IntPtr.Zero)
return;
var floatCount = byteCount / 4;
var pcmByteCount = floatCount * BytesPerSample;
lock (_lock)
{
if (_disposed)
return;
EnsureBufferCapacity(floatCount);
Marshal.Copy(buffer, _floatBuffer, 0, floatCount);
for (var i = 0; i < floatCount; i++)
{
var sample = _floatBuffer[i];
if (sample > 1f)
sample = 1f;
else if (sample < -1f)
sample = -1f;
_shortBuffer[i] = (short)(sample * 32767f);
}
Buffer.BlockCopy(_shortBuffer, 0, _byteBuffer, 0, pcmByteCount);
try
{
_stream.Write(_byteBuffer, 0, pcmByteCount);
_bytesWritten += pcmByteCount;
}
catch (Exception e)
{
Log.Warning($"WavFileWriter: write failed for '{Path}' - {e.Message}");
}
}
}
///
/// Closes the file and rewrites the RIFF / data chunk sizes in the header.
/// Safe to call multiple times.
///
public void Dispose()
{
lock (_lock)
{
if (_disposed)
return;
_disposed = true;
try
{
FinaliseHeader();
}
catch (Exception e)
{
Log.Warning($"WavFileWriter: header finalise failed for '{Path}' - {e.Message}. File may be unreadable.");
}
finally
{
_stream.Dispose();
}
}
}
private void EnsureBufferCapacity(int floatCount)
{
if (_floatBuffer.Length < floatCount)
_floatBuffer = new float[floatCount];
if (_shortBuffer.Length < floatCount)
_shortBuffer = new short[floatCount];
var pcmByteCount = floatCount * BytesPerSample;
if (_byteBuffer.Length < pcmByteCount)
_byteBuffer = new byte[pcmByteCount];
}
private void WritePlaceholderHeader()
{
var byteRate = SampleRate * Channels * BytesPerSample;
var blockAlign = (short)(Channels * BytesPerSample);
// RIFF header
WriteAscii("RIFF");
WriteInt32(0); // placeholder: RIFF chunk size (file size - 8)
WriteAscii("WAVE");
// fmt chunk
WriteAscii("fmt ");
WriteInt32(16); // fmt chunk size for PCM
WriteInt16(1); // audio format: 1 = PCM
WriteInt16((short)Channels);
WriteInt32(SampleRate);
WriteInt32(byteRate);
WriteInt16(blockAlign);
WriteInt16(16); // bits per sample
// data chunk header
WriteAscii("data");
WriteInt32(0); // placeholder: data chunk size
}
private void FinaliseHeader()
{
var dataChunkSize = (int)Math.Min(_bytesWritten, int.MaxValue);
var riffChunkSize = dataChunkSize + 36; // header bytes after the RIFF size field
_stream.Flush();
_stream.Seek(4, SeekOrigin.Begin);
WriteInt32(riffChunkSize);
_stream.Seek(40, SeekOrigin.Begin);
WriteInt32(dataChunkSize);
_stream.Flush();
}
private void WriteAscii(string ascii)
{
for (var i = 0; i < ascii.Length; i++)
_stream.WriteByte((byte)ascii[i]);
}
private void WriteInt32(int value)
{
_stream.WriteByte((byte)(value & 0xff));
_stream.WriteByte((byte)((value >> 8) & 0xff));
_stream.WriteByte((byte)((value >> 16) & 0xff));
_stream.WriteByte((byte)((value >> 24) & 0xff));
}
private void WriteInt16(short value)
{
_stream.WriteByte((byte)(value & 0xff));
_stream.WriteByte((byte)((value >> 8) & 0xff));
}
private const int BytesPerSample = 2; // 16-bit PCM
private const int InitialSampleCapacity = 4096;
private readonly object _lock = new();
private readonly FileStream _stream;
private long _bytesWritten;
private bool _disposed;
private float[] _floatBuffer = new float[InitialSampleCapacity];
private short[] _shortBuffer = new short[InitialSampleCapacity];
private byte[] _byteBuffer = new byte[InitialSampleCapacity * BytesPerSample];
}