Bump to v1.2.0: recording, sound cues, drift compensation, diagnostics

New user-facing features:

* Recording. Dedicated Record menu (Alt+O — moved from Alt+R to
  avoid clashing with the Receive audio checkbox), Start/Stop on
  Ctrl+R, settings dialog, per-profile source / format / bit-depth /
  channel-mode / folder. Three source modes (received only, sent
  only, both). Files are crash-resilient — a process crash
  mid-recording leaves a playable file containing everything up to
  the last header refresh (~5 seconds).

* Four output formats, all functional:
    - WAV: 16/24-bit PCM or 32-bit float, custom writer with
      periodic RIFF re-patching.
    - MP3: LAME 128–320 kbps CBR (via NAudio.Lame).
    - OGG-Opus: 96–256 kbps VBR (via Concentus.Oggfile, reusing the
      Concentus encoder from the wire path).
    - FLAC: 16/24-bit lossless (via CUETools.Codecs.FLAKE — pure
      managed, no native DLL).

* Recording start/stop sound cues. record start.wav and
  record stop.wav play around the recording transition. Played via
  System.Media.SoundPlayer to the default Windows output, separate
  from the recording pipeline so a normal recording does not contain
  the cue.

* Per-cue Preferences. The old single "Mute connect/disconnect
  sounds" checkbox is replaced by a CheckedListBox: Connect /
  Disconnect / Recording start / Recording stop. Old profiles with
  the legacy MuteConnectionCues=true are honoured on first load via
  a migration path in the new Load* helpers.

* Receiver-side drift compensation switched from discrete
  single-frame splices to a continuous WdlResampler at a smoothed
  rate ratio. SessionPlayout.cs rewrite.

Diagnostics (only active with Enable logs ticked):

* Per-stage discontinuity probes — sender raw capture (per backend,
  PushModeWasapi + Asio both wired), sender pre-encode (now per
  lane in BothIndependent, fixing a cross-stream artefact), receiver
  post-decode, post-ring, post-resampler.

* Wire-level packet sequence tracking on each PCM stream — in-order
  / missed / reordered / duplicated counts in the diag log.

* Clipped-sample delta in the diag log.

* New AudioStepProbe in RemSound.Core with per-channel scan helper.

UI changes:

* Record menu uses Alt+O (Rec&ord). Inside the menu, item mnemonics
  unchanged (S / T / O / C).

* Auto-tune interval combo label is mode-aware: "Auto-tune latency
  interval" in classic modes, "Auto-tune interval — WASAPI and ASIO"
  in BothIndependent. The combo's Enabled state now follows EITHER
  lane's auto-tune checkbox (was only the WASAPI one — bug).

Wire format and audio pipeline unchanged from v1.1 — v1.1 and v1.2
peers interoperate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 12:40:01 +01:00
co-authored by Claude Opus 4.7
parent a0fe8070ed
commit c59e413c1f
27 changed files with 3348 additions and 350 deletions
+864
View File
@@ -0,0 +1,864 @@
using System.Diagnostics;
using Concentus;
using Concentus.Enums;
using Concentus.Oggfile;
using CUETools.Codecs;
using CUETools.Codecs.FLAKE;
using NAudio.Lame;
using NAudio.Wave;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Background recorder that writes float audio to disk as WAV (custom PCM writer with
/// crash-resilient header refresh), MP3 (LAME via NAudio.Lame), OGG-Opus (Concentus +
/// Concentus.Oggfile), or FLAC (CUETools.Codecs.FLAKE — pure managed lossless).
///
/// Pipeline:
/// 1. Sender / receiver audio threads call <see cref="WriteSent"/> /
/// <see cref="WriteReceived"/> — each appends to a pre-allocated lock-free SPSC ring
/// buffer (one per direction) using nothing but a memcpy, an atomic add on the write
/// head, and an event Set. Zero allocations, zero locks, zero signaling primitives
/// that could contend with disk I/O. Audio threads NEVER touch the disk and never
/// touch the file writers.
/// 2. A single background writer thread waits on the wake-up event, drains both rings,
/// mixes the two directions when source mode is "Both", and feeds the resulting
/// samples to the format writer.
/// 3. <see cref="Stop"/> drains anything still in the rings, closes the file, and
/// signals the caller with the final path and byte count.
///
/// This shape replaced an earlier BlockingCollection + ArrayPool design (2026-05-14)
/// that exhibited intermittent pops under priority mode + recording. The semaphore
/// signaling inside BlockingCollection and the per-call ArrayPool rents were both
/// occasional sources of multi-hundred-microsecond audio-thread spikes; with a
/// 32-sample ASIO buffer (0.67 ms callback budget) that was enough to miss deadlines.
/// The lock-free ring keeps audio-thread work bounded to a handful of nanoseconds.
///
/// "Both" source mode: when both rings have audio, the writer thread drains
/// min(sent_avail, received_avail) frames and sum-mixes them. When only one side has
/// data (e.g. the user has Send Audio off, or no peer is connected), that side is
/// drained solo with the other treated as silence — the recording never stalls because
/// of a quiet direction.
///
/// Channel-mode downmix happens at the writer-thread layer (one place to do it cleanly)
/// rather than at each enqueue point.
///
/// Lifecycle: one AudioRecorder per recording session. The MainForm creates a fresh one
/// on Start and disposes it on Stop. Reconfiguring mid-session is not supported — the user
/// stops, edits settings, and starts again.
/// </summary>
internal sealed class AudioRecorder : IDisposable
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
/// <summary>Per-direction ring capacity in floats. 5 s of stereo float @ 48 kHz =
/// 480 000 floats ≈ 1.9 MB. Sized to cover any reasonable disk hiccup; in steady
/// state the rings hover near empty because the writer drains continuously. Two
/// rings means ~3.8 MB of fixed-cost memory per running recording — modest.</summary>
private const int RingCapacityFloats = MixSampleRate * MixChannels * 5;
/// <summary>Minimum frames the writer waits for before doing a drain pass. 480 frames
/// = 10 ms of audio. Below this, signaling overhead dominates; above this, the
/// chunks are big enough that a single Write to the file format writer is efficient.
/// Also caps the latency between an audio thread's tap and the disk write at ~10 ms.</summary>
private const int DrainChunkFrames = 480;
/// <summary>Maximum frames the writer drains in a single Process call. Caps the
/// CPU burst on the writer thread when the rings have been allowed to fill (e.g.
/// after a brief disk stall). At 4800 frames = 100 ms of audio per Process, the
/// writer can still keep up with a 5 s ring (50 Process calls to drain it fully).</summary>
private const int DrainChunkMaxFrames = 4800;
private readonly RecordingSettings settings;
private readonly string resolvedPath;
private readonly Action<string>? onDiagnostic;
private readonly Action<string, long>? onFinished;
// === Lock-free SPSC rings, one per direction ===
// Write head is monotonically increasing (NOT wrapped). Ring index = head % capacity.
// This avoids the ABA problem on wraparound and means the audio thread only needs an
// atomic add (not a CAS) to publish a write. The writer thread holds the read head
// (no atomic needed; single consumer).
private readonly float[] sentRing = new float[RingCapacityFloats];
private readonly float[] receivedRing = new float[RingCapacityFloats];
private long sentWriteHead; // updated atomically from audio thread
private long sentReadHead; // owned by writer thread
private long receivedWriteHead; // updated atomically from audio thread
private long receivedReadHead; // owned by writer thread
private long droppedSampleFrames;
// Wake-up event. Audio threads Set after appending to a ring; writer thread Waits.
// ManualResetEventSlim has a Spin phase before falling back to a kernel wait, so
// light contention stays in user-mode and is cheap.
private readonly ManualResetEventSlim wakeup = new(initialState: false, spinCount: 32);
private readonly Thread writerThread;
private readonly CancellationTokenSource cts = new();
private long writtenSampleFrames;
private long writtenBytes;
private volatile bool stopped;
public string FilePath => resolvedPath;
public RecordingSettings Settings => settings;
public long WrittenSampleFrames => Interlocked.Read(ref writtenSampleFrames);
/// <summary>Total stereo frames the audio thread had to drop because its ring was
/// full. Non-zero indicates the writer can't keep up with the audio rate — usually
/// a sign of a stalled disk. Surfaced in the on-stop diagnostic line.</summary>
public long DroppedSampleFrames => Interlocked.Read(ref droppedSampleFrames);
/// <summary>Constructs the recorder, opens the output file, and starts the writer
/// thread. If anything fails the constructor throws and no cleanup is needed (no
/// file has been opened yet).</summary>
public AudioRecorder(RecordingSettings settings, Action<string>? onDiagnostic, Action<string, long>? onFinished)
{
this.settings = settings.Clone();
this.onDiagnostic = onDiagnostic;
this.onFinished = onFinished;
var folder = settings.ResolvedFolder();
if (string.IsNullOrWhiteSpace(folder)) folder = RecordingSettings.DefaultFolder();
Directory.CreateDirectory(folder);
var ext = ExtensionFor(settings.FileFormat);
var stamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
resolvedPath = Path.Combine(folder, $"RemSound-{stamp}.{ext}");
// Writer creation happens on the constructor thread so any open errors are surfaced
// synchronously to the caller.
formatWriter = CreateWriter(settings.FileFormat, resolvedPath, settings);
// Writer thread at Normal priority. Previously AboveNormal, lowered 2026-05-14:
// there's no reason for the writer to compete with audio threads (which run at
// MMCSS Pro Audio priority anyway, well above any "Normal" worker). Keeping the
// writer at Normal lets the OS scheduler push it out of the way whenever the
// audio thread needs the CPU.
writerThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "RemSound.Recorder",
Priority = ThreadPriority.Normal,
};
writerThread.Start();
}
// === Audio-thread side: bounded to a memcpy + atomic add + event-set ===
/// <summary>Tap target for sender-side audio. Discarded silently if this recorder's
/// source mode is "received only". Lock-free, allocation-free; safe to call from
/// the audio thread.</summary>
public void WriteSent(ReadOnlyMemory<float> stereoFloats)
{
if (stopped) return;
if (settings.Source == RecordingSource.ReceivedOnly) return;
AppendToRing(stereoFloats.Span, sentRing, ref sentWriteHead, ref sentReadHead);
}
/// <summary>Tap target for receiver-side audio. Discarded silently if this recorder's
/// source mode is "sent only". Lock-free, allocation-free; safe to call from the
/// render thread.</summary>
public void WriteReceived(ReadOnlyMemory<float> stereoFloats)
{
if (stopped) return;
if (settings.Source == RecordingSource.SentOnly) return;
AppendToRing(stereoFloats.Span, receivedRing, ref receivedWriteHead, ref receivedReadHead);
}
/// <summary>Lock-free, allocation-free append to a single-producer-single-consumer
/// ring buffer. The producer (audio thread) owns the write head; the consumer (writer
/// thread) owns the read head. The producer reads BOTH heads (Volatile.Read) to
/// compute available space; the consumer reads BOTH heads similarly. Cross-thread
/// visibility is provided by Volatile.Read/Write — sufficient for x86/x64 memory
/// model on Windows and the only platform we target.</summary>
private void AppendToRing(ReadOnlySpan<float> samples, float[] ring, ref long writeHeadRef, ref long readHeadRef)
{
var len = samples.Length;
if (len == 0) return;
var cap = ring.Length;
var write = Volatile.Read(ref writeHeadRef);
var read = Volatile.Read(ref readHeadRef);
var used = (int)(write - read);
var free = cap - used;
if (free < len)
{
// Ring is full. Audio thread can't block (deadline-bound); we drop these
// samples and bump the counter. In practice this fires only if the writer
// thread is genuinely stuck (very slow disk, OS hang).
Interlocked.Add(ref droppedSampleFrames, len / MixChannels);
return;
}
var pos = (int)(write % cap);
var part1 = Math.Min(len, cap - pos);
samples.Slice(0, part1).CopyTo(ring.AsSpan(pos));
if (part1 < len)
{
// Wrap-around: copy the tail into the start of the ring.
samples.Slice(part1).CopyTo(ring.AsSpan(0));
}
// Publish the write — Volatile.Write ensures the sample writes above are visible
// to the consumer BEFORE it sees the advanced write head.
Volatile.Write(ref writeHeadRef, write + len);
// Wake the writer. ManualResetEventSlim.Set is a single Interlocked.CompareExchange
// on the fast path; subsequent Sets while the event is already set are essentially
// free.
wakeup.Set();
}
// === Writer thread: drains both rings, mixes if "Both", writes to file ===
private void WriterLoop()
{
try
{
while (!cts.IsCancellationRequested)
{
// Block until the audio thread signals data OR we time out (the timeout is
// a backstop so periodic format-writer flushes still happen during a long
// silent stretch with no incoming audio).
wakeup.Wait(50, cts.Token);
wakeup.Reset();
// Drain as much as is available, in chunks of up to DrainChunkMaxFrames.
while (!cts.IsCancellationRequested && HasEnoughData())
{
Process();
}
}
}
catch (OperationCanceledException) { /* normal shutdown */ }
catch (Exception ex)
{
onDiagnostic?.Invoke($"recording: writer-thread error: {ex.GetType().Name}: {ex.Message}");
}
// Final drain on shutdown: anything still queued in the rings goes to disk before
// we close the file.
try
{
while (HasEnoughData(minFrames: 1)) Process();
}
catch { /* shutdown drain is best-effort */ }
}
private bool HasEnoughData(int minFrames = DrainChunkFrames)
{
var sentAvail = (Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels;
var recvAvail = (Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels;
return settings.Source switch
{
RecordingSource.SentOnly => sentAvail >= minFrames,
RecordingSource.ReceivedOnly => recvAvail >= minFrames,
RecordingSource.Both => sentAvail >= minFrames || recvAvail >= minFrames,
_ => false,
};
}
private void Process()
{
var sentAvailFrames = (int)((Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels);
var recvAvailFrames = (int)((Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels);
int framesThisCall;
switch (settings.Source)
{
case RecordingSource.SentOnly:
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.ReceivedOnly:
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.Both:
// Mix the two sides. Drain min(sent, received) frames so both sides
// advance together. If one side has zero (e.g. peer disconnected, or
// local capture is off), drain the other side alone — treat the silent
// side as zero for those frames. This prevents permanent stalls in
// "Both" mode when one direction has no traffic.
if (sentAvailFrames > 0 && recvAvailFrames > 0)
{
framesThisCall = Math.Min(Math.Min(sentAvailFrames, recvAvailFrames), DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
EnsureSecondaryScratchSize(framesThisCall * MixChannels);
var dst = mixScratch.AsSpan(0, framesThisCall * MixChannels);
var aux = mixScratchAux.AsSpan(0, framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, dst);
CopyFromRing(receivedRing, ref receivedReadHead, aux);
// Sum-mix. Soft-tanh limiter on the sum keeps two simultaneously
// hot inputs from clipping.
for (var i = 0; i < dst.Length; i++)
{
var s = dst[i] + aux[i];
if (s > 1f) s = 1f - MathF.Tanh(s - 1f);
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
dst[i] = s;
}
}
else if (sentAvailFrames > 0)
{
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
}
else if (recvAvailFrames > 0)
{
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
}
else
{
return;
}
EmitMixBuffer(framesThisCall);
break;
default:
return;
}
}
/// <summary>Copy <paramref name="dst"/>.Length floats from <paramref name="ring"/>
/// starting at <paramref name="readHeadRef"/>, advancing the head atomically.</summary>
private static void CopyFromRing(float[] ring, ref long readHeadRef, Span<float> dst)
{
var len = dst.Length;
var cap = ring.Length;
var read = readHeadRef;
var pos = (int)(read % cap);
var part1 = Math.Min(len, cap - pos);
ring.AsSpan(pos, part1).CopyTo(dst);
if (part1 < len)
{
ring.AsSpan(0, len - part1).CopyTo(dst.Slice(part1));
}
// Publish the consumed bytes — Volatile.Write so the producer (audio thread)
// sees the freed slots before its next free-space calculation.
Volatile.Write(ref readHeadRef, read + len);
}
private void EmitMixBuffer(int frames)
{
var src = mixScratch.AsSpan(0, frames * MixChannels);
if (settings.ChannelMode == RecordingChannelMode.Mono)
{
EnsureMonoScratchSize(frames);
for (var i = 0; i < frames; i++)
{
monoScratch[i] = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
}
formatWriter?.Write(monoScratch.AsSpan(0, frames));
Interlocked.Add(ref writtenSampleFrames, frames);
}
else
{
formatWriter?.Write(src);
Interlocked.Add(ref writtenSampleFrames, frames);
}
}
private void EnsureScratchSize(int floats)
{
if (mixScratch.Length < floats) mixScratch = new float[floats];
}
private void EnsureSecondaryScratchSize(int floats)
{
if (mixScratchAux.Length < floats) mixScratchAux = new float[floats];
}
private void EnsureMonoScratchSize(int frames)
{
if (monoScratch.Length < frames) monoScratch = new float[frames];
}
/// <summary>Stops the recorder. Drains any audio still in the rings, closes the file,
/// and signals the finish callback with the path + byte count. Safe to call multiple
/// times.</summary>
public void Stop()
{
if (stopped) return;
stopped = true;
cts.Cancel();
wakeup.Set();
try
{
writerThread?.Join(TimeSpan.FromSeconds(3));
}
catch { /* don't propagate join failures */ }
try
{
formatWriter?.Dispose();
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"recording: format-writer close failed: {ex.GetType().Name}: {ex.Message}");
}
formatWriter = null;
try
{
var fi = new FileInfo(resolvedPath);
if (fi.Exists)
{
writtenBytes = fi.Length;
}
}
catch { /* file-size lookup failure is benign */ }
if (DroppedSampleFrames > 0)
{
onDiagnostic?.Invoke($"recording: dropped {DroppedSampleFrames} stereo frames due to writer back-pressure");
}
onFinished?.Invoke(resolvedPath, writtenBytes);
}
public void Dispose()
{
try { Stop(); } catch { /* shutdown is best-effort */ }
cts.Dispose();
wakeup.Dispose();
}
// === format-writer plumbing ===
private IFormatWriter? formatWriter;
private float[] mixScratch = new float[DrainChunkFrames * MixChannels];
private float[] mixScratchAux = new float[DrainChunkFrames * MixChannels];
private float[] monoScratch = new float[DrainChunkFrames];
private static string ExtensionFor(RecordingFileFormat format) => format switch
{
RecordingFileFormat.Wav => "wav",
RecordingFileFormat.Mp3 => "mp3",
RecordingFileFormat.Ogg => "opus", // OGG container, Opus codec — ".opus" is the conventional ext
RecordingFileFormat.Flac => "flac",
_ => "wav",
};
private static IFormatWriter CreateWriter(RecordingFileFormat format, string path, RecordingSettings settings)
{
var channels = settings.ChannelMode == RecordingChannelMode.Mono ? 1 : MixChannels;
return format switch
{
RecordingFileFormat.Wav => new WavFormatWriter(path, MixSampleRate, channels, settings.WavBitsPerSample),
RecordingFileFormat.Mp3 => new Mp3FormatWriter(path, MixSampleRate, channels, settings.Mp3BitrateKbps),
RecordingFileFormat.Ogg => new OggOpusFormatWriter(path, MixSampleRate, channels, settings.OggOpusBitrateKbps),
RecordingFileFormat.Flac => new FlacFormatWriter(path, MixSampleRate, channels, settings.FlacBitsPerSample, settings.FlacCompressionLevel),
// Defensive: unknown format → WAV (shouldn't happen since all enum members are
// handled above, but keeps the switch exhaustive).
_ => new WavFormatWriter(path, MixSampleRate, channels, settings.WavBitsPerSample),
};
}
private interface IFormatWriter : IDisposable
{
void Write(ReadOnlySpan<float> samples);
}
/// <summary>WAV writer with crash-resilient periodic header updates.
///
/// NAudio's stock WaveFileWriter writes the RIFF / data-chunk size fields ONCE at file
/// close (in Dispose), with placeholder zeros up until then. A process crash before
/// Dispose runs leaves the file with header-says-zero-samples, which most players
/// either refuse or stop after the first audio frame — meaning an hour-long crashed
/// session is unrecoverable. This implementation owns the FileStream directly and
/// re-patches the two size fields every <see cref="HeaderRefreshSeconds"/> seconds
/// PLUS on Dispose. A crash any time after the first refresh leaves a playable WAV
/// containing all the audio captured up to the last refresh.
///
/// Header layout (PCM 16/24-bit):
/// offset 0 "RIFF"
/// offset 4 uint32 (file size - 8) ← patched periodically
/// offset 8 "WAVE"
/// offset 12 "fmt "
/// offset 16 uint32 16 (PCM fmt chunk size)
/// offset 20 uint16 1 (PCM format code)
/// offset 22 uint16 channels
/// offset 24 uint32 sample rate
/// offset 28 uint32 byte rate
/// offset 32 uint16 block align
/// offset 34 uint16 bits per sample
/// offset 36 "data"
/// offset 40 uint32 data chunk size ← patched periodically
/// offset 44 audio samples...
///
/// For 32-bit IEEE float we use the slightly-longer 18-byte fmt chunk variant with
/// format code 3 and a trailing cbSize=0 field, so the data chunk starts at offset 46.
/// </summary>
private sealed class WavFormatWriter : IFormatWriter
{
private const int HeaderRefreshSeconds = 5;
private readonly FileStream stream;
private readonly int bitsPerSample;
private readonly bool isFloat;
private readonly long dataChunkSizeFieldPos;
private readonly long dataStartPos;
private long dataBytesWritten;
private DateTime lastHeaderRefreshUtc;
private byte[] scratchBytes = new byte[4096];
public WavFormatWriter(string path, int sampleRate, int channels, int bitsPerSample)
{
this.bitsPerSample = bitsPerSample is 16 or 24 or 32 ? bitsPerSample : 24;
isFloat = this.bitsPerSample == 32;
// FileShare.Read lets the user open the WAV in a player mid-recording to check
// progress. ReadWrite access is required because we seek back to patch the
// header. 8 KB stream buffer balances responsiveness (small enough that a
// crash loses at most ~50 ms at 48 kHz / 16-bit stereo) with throughput.
stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, 8192, useAsync: false);
WriteInitialHeader(sampleRate, channels);
dataStartPos = stream.Position;
dataChunkSizeFieldPos = dataStartPos - 4;
lastHeaderRefreshUtc = DateTime.UtcNow;
}
private void WriteInitialHeader(int sampleRate, int channels)
{
var formatCode = (ushort)(isFloat ? 3 : 1);
var byteRate = (uint)(sampleRate * channels * bitsPerSample / 8);
var blockAlign = (ushort)(channels * bitsPerSample / 8);
// PCM fmt chunk is 16 bytes; IEEE-float adds a 2-byte cbSize trailer (zero,
// meaning no extension data) for a total of 18 bytes.
var fmtChunkSize = (uint)(isFloat ? 18 : 16);
using var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true);
bw.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
bw.Write((uint)36); // placeholder RIFF size — patched in FlushHeader
bw.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));
bw.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
bw.Write(fmtChunkSize);
bw.Write(formatCode);
bw.Write((ushort)channels);
bw.Write((uint)sampleRate);
bw.Write(byteRate);
bw.Write(blockAlign);
bw.Write((ushort)bitsPerSample);
if (isFloat) bw.Write((ushort)0); // cbSize: no extra extension fields
bw.Write(System.Text.Encoding.ASCII.GetBytes("data"));
bw.Write((uint)0); // placeholder data chunk size — patched in FlushHeader
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
int bytesAppended;
switch (bitsPerSample)
{
case 32:
bytesAppended = samples.Length * sizeof(float);
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
System.Runtime.InteropServices.MemoryMarshal.AsBytes(samples).CopyTo(scratchBytes);
stream.Write(scratchBytes, 0, bytesAppended);
break;
case 24:
bytesAppended = samples.Length * 3;
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
PcmPack.FloatToInt24LE(samples, scratchBytes.AsSpan(0, bytesAppended));
stream.Write(scratchBytes, 0, bytesAppended);
break;
default: // 16
bytesAppended = samples.Length * 2;
if (scratchBytes.Length < bytesAppended) scratchBytes = new byte[bytesAppended];
var dst = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, short>(scratchBytes.AsSpan(0, bytesAppended));
for (var i = 0; i < samples.Length; i++)
{
var v = Math.Clamp(samples[i], -1f, 1f);
dst[i] = (short)(v * 32767f);
}
stream.Write(scratchBytes, 0, bytesAppended);
break;
}
dataBytesWritten += bytesAppended;
// Periodic header refresh — every HeaderRefreshSeconds. We seek back, patch the
// two size fields, seek forward to the data tail, and flush all the way to disk.
// The seek + write is cheap (a few bytes); the flush is the expensive part but
// it's only every ~5 s. A crash any time after the first refresh leaves a
// playable WAV containing all audio captured up to that refresh.
if ((DateTime.UtcNow - lastHeaderRefreshUtc).TotalSeconds >= HeaderRefreshSeconds)
{
FlushHeader();
lastHeaderRefreshUtc = DateTime.UtcNow;
}
}
private void FlushHeader()
{
var tailPos = stream.Position;
stream.Position = 4;
using (var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true))
{
bw.Write((uint)(tailPos - 8)); // RIFF chunk size = total file size - 8
}
stream.Position = dataChunkSizeFieldPos;
using (var bw = new BinaryWriter(stream, System.Text.Encoding.ASCII, leaveOpen: true))
{
bw.Write((uint)dataBytesWritten); // data chunk size
}
stream.Position = tailPos;
// Flush forces the OS to push our user-space buffer to the disk cache; FlushFileBuffers
// (via Flush(true)) would force the disk cache to platter, but that's expensive enough
// to skip — a kernel crash that loses the disk cache is rare enough not to plan for.
stream.Flush();
}
public void Dispose()
{
try { FlushHeader(); } catch { /* best-effort final header patch */ }
try { stream.Dispose(); } catch { /* best-effort stream close */ }
}
}
/// <summary>MP3 writer. NAudio.Lame's LameMP3FileWriter takes a 16-bit PCM WaveFormat
/// input and an int kbps for CBR. MP3 is naturally crash-resilient — every encoded
/// frame is self-contained and the file-on-disk is always a valid (truncated) MP3
/// representing everything LAME has emitted so far — but LAME and the OS both buffer
/// internally, so we Flush every <see cref="FlushIntervalSeconds"/> seconds to bound
/// the loss-on-crash to a couple of seconds rather than however-much fit in the
/// kernel file cache.</summary>
private sealed class Mp3FormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private readonly LameMP3FileWriter writer;
private byte[] scratchBytes = new byte[4096];
private readonly int channels;
private DateTime lastFlushUtc;
public Mp3FormatWriter(string path, int sampleRate, int channels, int bitrateKbps)
{
this.channels = channels;
var pcmFormat = new WaveFormat(sampleRate, 16, channels);
// Direct kbps constructor — NAudio.Lame accepts a plain int and configures LAME
// for CBR at that rate. Clamp to the LAME range (8..320 for MPEG-1 layer 3 at
// 48 kHz). Values from our dialog are 128/192/256/320 so no clamping fires in
// practice; the guard is for future-proofing if the UI gains finer steps.
var clamped = Math.Clamp(bitrateKbps, 8, 320);
writer = new LameMP3FileWriter(path, pcmFormat, clamped);
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
var byteLength = samples.Length * 2;
if (scratchBytes.Length < byteLength) scratchBytes = new byte[byteLength];
var dst = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, short>(scratchBytes.AsSpan(0, byteLength));
for (var i = 0; i < samples.Length; i++)
{
var v = Math.Clamp(samples[i], -1f, 1f);
dst[i] = (short)(v * 32767f);
}
writer.Write(scratchBytes, 0, byteLength);
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { writer.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose() => writer.Dispose();
}
/// <summary>OGG-Opus writer. Reuses the Concentus encoder that the wire path uses, wrapped
/// in the Concentus.Oggfile OGG container writer so the result is a standard .opus file
/// playable in VLC / mpv / browsers.
///
/// Opus operates on fixed-size frames (we use 20 ms = 960 samples per channel at 48 kHz).
/// The writer buffers incoming float samples, converts to int16, and emits one frame to
/// the Ogg writer per accumulated chunk. Any partial frame at Dispose is zero-padded and
/// flushed so no audio is lost.
///
/// Crash resilience: the OGG container is a stream of self-contained packets, so the file
/// on disk is always a valid (truncated) Opus file representing everything written so far.
/// We Flush the underlying FileStream every <see cref="FlushIntervalSeconds"/> seconds to
/// bound loss-on-crash to that window.</summary>
private sealed class OggOpusFormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private const int OpusFrameSamplesPerChannel = 960; // 20 ms at 48 kHz
private readonly FileStream fileStream;
private readonly IOpusEncoder encoder;
private readonly OpusOggWriteStream writer;
private readonly int channels;
private readonly short[] frameScratch;
private int frameScratchWritten; // interleaved shorts buffered toward the next frame
private DateTime lastFlushUtc;
public OggOpusFormatWriter(string path, int sampleRate, int channels, int bitrateKbps)
{
this.channels = channels;
// Frame scratch holds one full Opus frame of interleaved shorts.
frameScratch = new short[OpusFrameSamplesPerChannel * channels];
encoder = OpusCodecFactory.CreateEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO);
encoder.Bitrate = Math.Clamp(bitrateKbps, 6, 510) * 1000;
// VBR mode unconstrained — Opus's default for music. Good music quality at the
// bitrates we expose (96..256 kbps).
encoder.UseVBR = true;
encoder.UseConstrainedVBR = false;
fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 8192, useAsync: false);
writer = new OpusOggWriteStream(encoder, fileStream, null, sampleRate);
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
// Convert float → int16 inline as we copy into the per-frame scratch. Flush a
// complete Opus frame to the OGG writer each time the scratch is full.
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
frameScratch[frameScratchWritten++] = (short)(v * 32767f);
if (frameScratchWritten >= frameScratch.Length)
{
writer.WriteSamples(frameScratch, 0, frameScratch.Length);
frameScratchWritten = 0;
}
}
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { fileStream.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose()
{
// Final partial frame: pad with zeros so the encoder has a full frame to encode,
// then call Finish() to write the OGG end-of-stream packet so the file is well-formed.
try
{
if (frameScratchWritten > 0)
{
Array.Clear(frameScratch, frameScratchWritten, frameScratch.Length - frameScratchWritten);
writer.WriteSamples(frameScratch, 0, frameScratch.Length);
frameScratchWritten = 0;
}
writer.Finish();
}
catch { /* best-effort final flush */ }
try { fileStream.Dispose(); } catch { /* best-effort close */ }
}
}
/// <summary>FLAC writer using CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no
/// native DLL. Lossless at every compression level; level 5 (default) matches the
/// libFLAC reference encoder's default speed/size compromise.
///
/// FLAC is integer-PCM only — 16 or 24 bit. Float input is scaled to the configured bit
/// depth with hard clamping at the rails.
///
/// Crash resilience: FLAC's stream format is self-framing — every frame is independently
/// decodable. A truncated file remains a valid (shorter) FLAC representing everything
/// Flake emitted so far. We Flush the underlying stream every <see cref="FlushIntervalSeconds"/>
/// seconds to bound the OS-cache-loss window.</summary>
private sealed class FlacFormatWriter : IFormatWriter
{
private const int FlushIntervalSeconds = 5;
private readonly FileStream fileStream;
private readonly FlakeWriter writer;
private readonly AudioPCMConfig config;
private readonly int channels;
private readonly int bitsPerSample;
private readonly int bytesPerSample;
private readonly int scaleFactor;
// Reused per-Write byte buffer in the packed PCM layout the AudioBuffer constructor
// accepts. Interleaved [L0 R0 L1 R1 ...], with each sample serialised as
// signed little-endian using <see cref="bytesPerSample"/> bytes.
private byte[] packedBytes = new byte[4096];
private DateTime lastFlushUtc;
public FlacFormatWriter(string path, int sampleRate, int channels, int bitsPerSample, int compressionLevel)
{
this.channels = channels;
// FLAC accepts 16 or 24 here. Anything else (e.g. WAV's 32-bit-float leaking
// through) coerces to 24, which matches the wire bit depth.
this.bitsPerSample = bitsPerSample is 16 or 24 ? bitsPerSample : 24;
bytesPerSample = this.bitsPerSample / 8;
scaleFactor = (1 << (this.bitsPerSample - 1)) - 1;
config = new AudioPCMConfig(this.bitsPerSample, channels, sampleRate);
fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 8192, useAsync: false);
writer = new FlakeWriter(path, fileStream, config)
{
CompressionLevel = Math.Clamp(compressionLevel, 0, 8),
};
lastFlushUtc = DateTime.UtcNow;
}
public void Write(ReadOnlySpan<float> samples)
{
if (samples.IsEmpty) return;
var frames = samples.Length / channels;
if (frames <= 0) return;
// Pack interleaved float → signed little-endian PCM (2 or 3 bytes per sample).
var byteLen = samples.Length * bytesPerSample;
if (packedBytes.Length < byteLen) packedBytes = new byte[byteLen];
if (bitsPerSample == 16)
{
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
var s = (short)(v * 32767f);
var off = i * 2;
packedBytes[off] = (byte)(s & 0xFF);
packedBytes[off + 1] = (byte)((s >> 8) & 0xFF);
}
}
else // 24
{
for (var i = 0; i < samples.Length; i++)
{
var v = samples[i];
if (v > 1f) v = 1f; else if (v < -1f) v = -1f;
var s = (int)(v * 8388607f); // 2^23 - 1
var off = i * 3;
packedBytes[off] = (byte)(s & 0xFF);
packedBytes[off + 1] = (byte)((s >> 8) & 0xFF);
packedBytes[off + 2] = (byte)((s >> 16) & 0xFF);
}
}
// AudioBuffer(config, byte[], frameCount) wraps the packed bytes without copying.
// FlakeWriter encodes one block per Write call; block size adapts to the supplied
// frame count.
var buf = new AudioBuffer(config, packedBytes, frames);
writer.Write(buf);
if ((DateTime.UtcNow - lastFlushUtc).TotalSeconds >= FlushIntervalSeconds)
{
try { fileStream.Flush(); } catch { /* flush is best-effort */ }
lastFlushUtc = DateTime.UtcNow;
}
}
public void Dispose()
{
try { writer.Close(); } catch { /* best-effort final flush */ }
try { fileStream.Dispose(); } catch { /* best-effort close */ }
}
}
}