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]; }