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
+68
View File
@@ -20,6 +20,74 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v1.2
Recording, sound cues, and receiver-side drift compensation.
This release is mostly about features that sit on top of the
v1.1 transport the wire format and audio pipeline are
unchanged, so v1.1 and v1.2 peers interoperate.
What's new:
* Recording. New Record menu (Alt+O) Start / Stop with
Ctrl+R, dedicated settings dialog, per-profile choice of
source (received only, sent only, or both), file format
(WAV, MP3, OGG-Opus, FLAC), bit depth or bitrate, mono or
stereo, and recordings folder. Files are crash-resilient:
WAV re-patches its RIFF header every 5 seconds, MP3 / FLAC
/ OGG-Opus all produce well-formed truncated files if the
app crashes mid-recording.
* OGG-Opus and FLAC encoders now wired up they were stubs
in earlier builds. OGG-Opus reuses the same Concentus
encoder as the wire path; FLAC uses pure-managed CUETools
FLAKE (no native DLL).
* Recording start / stop sound cues. Plays a short ding
when recording transitions on or off. Played via the
default Windows output device, separate from the
recording pipeline, so a normal recording does not include
the cue.
* Sound-cue Preferences. The old single "Mute connect /
disconnect sounds" checkbox is replaced by a per-cue
CheckedListBox: Connect / Disconnect / Recording start /
Recording stop, each independently toggleable. Old profile
settings that had the legacy mute on are honoured on first
load.
* Receiver-side drift compensation switched from discrete
single-frame splices to a continuous WdlResampler running
at a slowly-updated rate ratio. Smooths out long-session
clock drift between sender and receiver without the
occasional 21 µs splice the v1.1 corrector emitted.
UI changes:
* Record menu moved to Alt+O (Rec&ord). The old Alt+R chord
conflicted with the Receive audio checkbox on the main
form. Inside the menu the item mnemonics are unchanged
(S / T / O / C for Start, settings, Open folder, Change
folder).
* Auto-tune interval combo label and accessible name are now
mode-aware. In BothIndependent mode it reads "Auto-tune
interval WASAPI and ASIO" so it's clear the same combo
drives ticks for both lanes; each lane still independently
tunes to its own target latency. Earlier builds also had
a bug where ticking ASIO auto-tune alone left this combo
greyed out fixed.
Diagnostics (only active with Enable logs ticked):
* Per-stage discontinuity probes sender raw capture,
sender pre-encode (now per lane in BothIndependent),
receiver post-decode, receiver post-ring, receiver
post-resampler. Lets a log inspection localise where a
click was introduced (capture / wire / decode / playout).
* Wire-level sequence tracking on each PCM stream:
in-order / missed / reordered / duplicated packet counts
in the diag log. Healthy LAN should show all-zero except
in-order; non-zero on the others points to transport
issues rather than software.
* Clipped-sample delta in the diag log.
Bug fixes:
* Auto-tune interval combo no longer greys out when only
ASIO auto-tune is ticked in BothIndependent.
RemSound v1.1
Priority and performance hardening, plus always-on network
+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 */ }
}
}
}
+359 -14
View File
@@ -37,6 +37,11 @@ public sealed class MainForm : Form
private readonly System.Windows.Forms.Timer updateCheckTimer = new();
private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController;
private readonly RecordingController recordingController;
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip
// the visible text + accessibility name between "Start recording" and "Stop recording"
// without rebuilding the menu.
private ToolStripMenuItem? startStopRecordingMenuItem;
// --- Main form controls ---
// Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises
@@ -175,6 +180,13 @@ public sealed class MainForm : Form
// checkbox doing almost the same thing in a less convenient one-shot shape.
private readonly AccessibleCheckBox continuousTuneBox = new() { Text = "Continuous auto-tune latency", AutoSize = true };
private readonly ComboBox continuousIntervalBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 90, AccessibleName = "Auto-tune latency interval (Alt+I)" };
// Label for continuousIntervalBox. Held as a field (rather than a local in
// BuildAudioReceiveGroupContents) so UpdateBothIndependentVisibility can rewrite the
// text and mnemonic when the user flips audio mode — the interval governs both lanes'
// auto-tune ticks in BothIndependent, and the label needs to say so. Initialised in
// BuildAudioReceiveGroupContents alongside the other receive-side controls; visibility
// is shared with the WASAPI row (always shown when the row is shown).
private Label? continuousIntervalLabel;
// BothIndependent-mode companion controls. Created up front so SelectedIndexChanged
// handlers can be wired alongside the originals; they live in their own TableLayoutPanel
// row that toggles Visible=true only when the audio mode is BothIndependent. The labels
@@ -284,6 +296,14 @@ public sealed class MainForm : Form
private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase);
private System.Media.SoundPlayer? connectSound;
private System.Media.SoundPlayer? disconnectSound;
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
// same path as connect/disconnect. They don't pass through our recording taps (those
// sit on the internal sender mix bus and receiver render path), so they don't appear
// in normal recordings. A user who has a WASAPI loopback of the same output device as
// a capture source would still get them, but that's their loopback configuration, not
// anything the recorder is doing.
private System.Media.SoundPlayer? recordStartSound;
private System.Media.SoundPlayer? recordStopSound;
// Labels for the three send/receive device lists, captured at layout time so they can be
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
// (driver channel pairs). null until BuildLayout has run.
@@ -344,6 +364,30 @@ public sealed class MainForm : Form
private bool firstSenderPacketLogged;
private bool firstReceiverPacketLogged;
// Previous-tick values for the per-second deltas surfaced in the diag log line. Each is
// the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
// subtracting from the current value gives "how many fired this second". Only read when
// DiagnosticsGate.Enabled (i.e. logs on); otherwise SnapshotLogIfDue early-outs before
// touching these.
private long prevDiagDriftDrops;
private long prevDiagDriftReps;
private long prevDiagConceal;
private long prevDiagShortRead;
private long prevDiagTrimFires;
// Wire-level packet-sequence tracking deltas. Detects packet reordering, loss, or
// duplication on the UDP path between sender and receiver. On a healthy LAN all three
// failure counters should stay at zero; any non-zero delta in the diag log is a smoking
// gun for transport-layer-induced pops.
private long prevDiagWireInOrder;
private long prevDiagWireMissed;
private long prevDiagWireReordered;
private long prevDiagWireDuplicated;
// Per-second delta for the sender's hard-clamp clipping counter. A non-zero clipΔ means
// the mix bus was producing samples whose magnitude exceeded 1.0 and got clamped. Clipping
// itself doesn't create steps but is a signal that the input is hot enough that something
// could be saturating.
private long prevDiagClippedSamples;
// Profile system (2026-05-02). The active profile (if any) was selected at app start and
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
// below). Control-level state (device ticks, send/receive checkboxes, audio port, volume
@@ -481,6 +525,13 @@ public sealed class MainForm : Form
() => receiveAudioCheckbox.Checked = true,
Close);
recordingController = new RecordingController(
sender,
receiver,
settings,
msg => logFile.Event($"recorder: {msg}"));
recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel;
// --- Set accessibility names ---
// For these four controls the keyboard shortcut is included explicitly in both the
// visible label (set in BuildLayout) and the AccessibleName, instead of relying on the
@@ -694,6 +745,8 @@ public sealed class MainForm : Form
// Files are deployed alongside the .exe (see RemSound.App.csproj Content rules).
TryLoadCueSound("connect.wav", out connectSound);
TryLoadCueSound("disconnect.wav", out disconnectSound);
TryLoadCueSound("record start.wav", out recordStartSound);
TryLoadCueSound("record stop.wav", out recordStopSound);
LoadAudioDevices();
// Apply persisted ASIO mode from settings — switches sender/receiver backends so the
@@ -1065,11 +1118,123 @@ public sealed class MainForm : Form
aboutItem,
});
var recordMenu = BuildRecordMenu();
menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
menu.Items.Add(helpMenu);
return menu;
}
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
/// settings dialog, open the configured folder, and change the configured folder.
/// Ctrl+R is the global toggle so the user can start/stop without going through the
/// menu. Profile-dirty flag is set when the user changes the folder or the settings
/// inside the sub-dialog because both live on the profile.</summary>
private ToolStripMenuItem BuildRecordMenu()
{
// Record menu uses Alt+O (Rec&ord) rather than Alt+R. The form's "Receive audio
// (Alt+R)" checkbox lives on the main canvas alongside the menu bar and Alt+R was
// ambiguous between the two. Alt+O is unused elsewhere on the menu bar (File / Help
// / Record) and reads as "Recording" naturally enough for the mnemonic to stick.
var recordMenu = new ToolStripMenuItem("Rec&ord") { AccessibleName = "Record menu" };
startStopRecordingMenuItem = new ToolStripMenuItem("&Start recording")
{
ShortcutKeys = Keys.Control | Keys.R,
AccessibleName = "Start recording",
};
startStopRecordingMenuItem.Click += (_, _) => ToggleRecording();
var settingsItem = new ToolStripMenuItem("Recording se&ttings...")
{
AccessibleName = "Recording settings",
};
settingsItem.Click += (_, _) => OpenRecordingSettingsDialog();
var openFolderItem = new ToolStripMenuItem("&Open current recordings folder")
{
AccessibleName = "Open current recordings folder",
};
openFolderItem.Click += (_, _) => recordingController.OpenCurrentFolder(this);
var changeFolderItem = new ToolStripMenuItem("&Change recordings folder...")
{
AccessibleName = "Change recordings folder",
};
changeFolderItem.Click += (_, _) =>
{
if (recordingController.ChangeFolder(this)) MarkProfileDirty();
};
recordMenu.DropDownItems.AddRange(new ToolStripItem[]
{
startStopRecordingMenuItem,
new ToolStripSeparator(),
settingsItem,
new ToolStripSeparator(),
openFolderItem,
changeFolderItem,
});
return recordMenu;
}
/// <summary>Toggle the recording state. Single source of truth for both Ctrl+R and the
/// menu-item click — both paths route through here so the start/stop transition is
/// handled consistently. The state-change event fires UpdateStartStopRecordingMenuLabel
/// which rewrites the menu item text.</summary>
private void ToggleRecording()
{
if (recordingController.IsRecording)
{
// Stop the recorder FIRST, then play the cue. SoundPlayer goes through the
// default Windows output device — separate from the internal taps the recorder
// listens on — so the cue isn't in the file regardless of ordering, but
// stopping first means a user with a WASAPI-loopback-of-default-output capture
// source won't catch the tail of the cue either.
recordingController.Stop();
if (settings.LoadEnableRecordStopCue()) recordStopSound?.Play();
}
else
{
// Symmetric: play the start cue BEFORE the recorder turns on, for the same
// loopback-courtesy reason. The cue is short (~0.4 s), so any subjective lag
// between "I pressed Ctrl+R" and "audio starts being captured" is well under
// the cue itself.
if (settings.LoadEnableRecordStartCue()) recordStartSound?.Play();
recordingController.Start();
}
}
/// <summary>Reflect the recording state in the menu item label. NVDA reads the text +
/// AccessibleName, both flipped here so users on screen readers hear the new state
/// straight away. Marshalled to the UI thread because the recorder's finish callback
/// can fire from its writer thread when Stop() is called from there.</summary>
private void UpdateStartStopRecordingMenuLabel(bool nowRecording)
{
void Apply()
{
if (startStopRecordingMenuItem is null) return;
startStopRecordingMenuItem.Text = nowRecording ? "&Stop recording" : "&Start recording";
startStopRecordingMenuItem.AccessibleName = nowRecording ? "Stop recording" : "Start recording";
}
if (InvokeRequired) BeginInvoke(Apply);
else Apply();
}
/// <summary>Open the recording settings dialog. On OK, write the settings back through
/// <see cref="RemSoundSettingsStore"/> and flag the profile dirty if anything changed.
/// The dialog reads its initial state from the same store, so settings persist across
/// re-opens until the user explicitly saves the profile.</summary>
private void OpenRecordingSettingsDialog()
{
using var dialog = new RecordingSettingsDialog(settings.LoadRecordingSettings());
if (dialog.ShowDialog(this) != DialogResult.OK) return;
settings.SaveRecordingSettings(dialog.Result);
if (dialog.ChangedAnything) MarkProfileDirty();
}
/// <summary>Show a file-picker rooted at the profiles folder; on selection, schedule a
/// switch to that profile (same close-and-relaunch flow as the old Switch button).</summary>
private void OpenProfileFromPicker()
@@ -1749,8 +1914,17 @@ public sealed class MainForm : Form
continuousIntervalBox.Items.Clear();
continuousIntervalBox.Items.AddRange(new object[] { "3 seconds", "5 seconds", "10 seconds", "15 seconds", "30 seconds" });
continuousIntervalBox.SelectedIndex = continuousTuneIntervalSec switch { 3 => 0, 5 => 1, 15 => 3, 30 => 4, _ => 2 };
continuousIntervalBox.Enabled = continuousTuneEnabled;
var continuousIntervalLabel = new Label { Text = "Auto-tune latency interval (Alt+&I)", AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) };
// Enable the interval combo whenever EITHER lane's auto-tune is on — the single
// interval value governs both lanes' tick rates (see comment at row-1 docstring).
// Previously this only followed the WASAPI checkbox, which made the combo grey out
// in BothIndependent mode when only ASIO auto-tune was ticked, even though the
// timer was running and the interval was being honoured for the ASIO lane.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
// Label text is set by UpdateBothIndependentVisibility — it differs between classic
// modes (single lane → "Auto-tune latency interval") and BothIndependent
// (two lanes → "Auto-tune interval (WASAPI + ASIO)") to make explicit that the same
// dropdown drives both lanes' tick cadence in the latter case.
continuousIntervalLabel = new Label { AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) };
var delayContainer = new FlowLayoutPanel
{
AutoSize = true,
@@ -1769,7 +1943,7 @@ public sealed class MainForm : Form
{
continuousTuneEnabled = continuousTuneBox.Checked;
settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled);
continuousIntervalBox.Enabled = continuousTuneEnabled;
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer();
MarkProfileDirty();
};
@@ -2694,6 +2868,11 @@ public sealed class MainForm : Form
continuousTuneAsioBox.CheckedChanged += (_, _) =>
{
settings.SaveContinuousAutoTuneAsioEnabled(continuousTuneAsioBox.Checked);
// The interval combo is shared between both lanes — keep it enabled whenever
// either lane's auto-tune is on. Without this, ticking ASIO auto-tune (in
// BothIndependent) left the interval combo greyed out and made the recheck
// cadence invisible to the user even though it was actively in effect.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer();
MarkProfileDirty();
};
@@ -2720,12 +2899,27 @@ public sealed class MainForm : Form
asioDelayContainer.Visible = inBothIndependent;
maxLatencyAsioBox.Visible = inBothIndependent;
continuousTuneAsioBox.Visible = inBothIndependent;
// Mode change may have changed which auto-tune flags count toward "any enabled":
// leaving BothIndependent drops the ASIO lane's checkbox from consideration, and
// entering it brings it back. Re-evaluate so the shared interval combo's Enabled
// state tracks reality after every mode flip.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
if (inBothIndependent)
{
wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)";
maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)";
continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)";
continuousTuneBox.AccessibleName = "Continuous auto-tune WASAPI latency";
// The interval combo drives ticks for BOTH lanes' auto-tunes — each lane
// independently lands wherever its own algorithm decides (40 ms WASAPI / 20 ms
// ASIO is fine), but the cadence dropdown is shared. Make that explicit in the
// label so a user looking at the WASAPI row doesn't assume the interval only
// applies there.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune interval — WASAPI and ASIO (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune interval for WASAPI and ASIO (Alt+I)";
}
else
{
@@ -2733,6 +2927,12 @@ public sealed class MainForm : Form
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)";
continuousTuneBox.AccessibleName = "Continuous auto-tune latency";
// Classic mode — single lane, original label is unambiguous.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune latency interval (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune latency interval (Alt+I)";
}
}
@@ -3410,6 +3610,26 @@ public sealed class MainForm : Form
// music; informational only).
var driftDrops = receiver.DriftDropFrames;
var driftReps = receiver.DriftRepeatFrames;
// Per-second deltas for the same counters — easier to read at a glance than
// ever-growing cumulative numbers. driftDropΔ + driftRepΔ tell us how fast
// the corrector is firing right now. concealΔ tells us how many real underruns
// fired this second (audible). shortReadΔ tracks the now-silent partial-read
// events for clock-phase diagnostics. Trim fires + delta gives us "is the
// click-trim safety net firing".
var concealNow = receiver.ConcealmentFires;
var shortReadNow = receiver.ShortReadFires;
var driftDropDelta = driftDrops - prevDiagDriftDrops; prevDiagDriftDrops = driftDrops;
var driftRepDelta = driftReps - prevDiagDriftReps; prevDiagDriftReps = driftReps;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state (not deltas) — current LP-filtered drift error and accumulator
// value. Both let us see "where the corrector thinks the buffer is" between
// explicit drop/repeat events. filtErr negative = buffer running below target
// on average; positive = above. driftAcc near 0 = corrector idle; near ±1 =
// about to fire.
var filteredErrorFrames = receiver.FilteredDriftErrorFrames;
var driftAccumulator = receiver.DriftAccumulator;
// 2026-05-11 added timing-split metrics:
// emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send)
// sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only)
@@ -3426,12 +3646,57 @@ public sealed class MainForm : Form
// samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero =
// the input ALREADY has discontinuities (capture-side issue).
// stepPostDec = receiver's float buffer just after PCM/Opus decode. If this
// is significantly larger than stepPreEnc, the wire codec
// roundtrip introduced steps.
// stepPostRing = receiver's float buffer just out of the ring (before
// resampler). Roughly equal to stepPostDec in steady state;
// bigger here means the ring buffer is fishy.
// stepPostRsm = receiver's float buffer just out of the resampler. Bigger
// here than stepPostRing fingers the resampler integration.
// sampleStepMax= the final output buffer (after volume + limiter), the
// legacy spot the diag already tracked.
// Per-lane pre-encode probes (2026-05-15) — split so BothIndependent mode
// can show which lane is producing the discontinuity, free of the cross-
// stream artefact that the old shared probe registered when both lanes'
// callbacks interleaved into one probe's lastL/R carry.
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDec = receiver.TakeMaxPostDecodeStep();
var stepPostRing = receiver.TakeMaxPostRingReadStep();
var stepPostRsm = receiver.TakeMaxPostResamplerStep();
// Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
// wireDupΔ are the smoking-gun counters — any non-zero value here means the
// UDP path between sender and receiver dropped, reordered, or duplicated
// packets, and that on the PCM path translates directly into audible pops.
var wireInOrderNow = receiver.WireInOrderCount;
var wireMissedNow = receiver.WireMissedCount;
var wireReorderedNow = receiver.WireReorderedCount;
var wireDuplicatedNow = receiver.WireDuplicatedCount;
var wireInOrderDelta = wireInOrderNow - prevDiagWireInOrder; prevDiagWireInOrder = wireInOrderNow;
var wireMissedDelta = wireMissedNow - prevDiagWireMissed; prevDiagWireMissed = wireMissedNow;
var wireReorderedDelta = wireReorderedNow - prevDiagWireReordered; prevDiagWireReordered = wireReorderedNow;
var wireDuplicatedDelta = wireDuplicatedNow - prevDiagWireDuplicated; prevDiagWireDuplicated = wireDuplicatedNow;
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " +
$"trimB={trimBytes} trimN={trimFires} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftRep={driftReps} " +
$"sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
}
else if (sender.IsRunning)
@@ -3440,9 +3705,24 @@ public sealed class MainForm : Form
// sendCbGapMs is visible — that's the most important metric on a send-only box,
// since it tells us whether THIS machine's capture path is stalling. Without
// this branch, send-only sessions logged zero diag info.
// stepPreEnc included so the send-only machine's pre-encode discontinuity
// probe is visible — needed for the laptop→desktop direction where the laptop
// is the source and we want to see if the audio coming OUT of the capture
// already has steps before it touches the wire.
var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs();
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
// Per-lane pre-encode probes — see the full-diag comment above for the
// rationale (per-lane fixes the cross-stream artefact in BothIndependent).
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
// Raw-capture step: now per-backend (each backend owns its own probe). The
// accessor returns max across all backends. PushModeWasapiBackend has been
// wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0.
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
}
// Synthesised end-to-end one-way latency estimate. Sums:
@@ -3521,13 +3801,60 @@ public sealed class MainForm : Form
/// </summary>
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// No ProcessCmdKey overrides currently — base class handles everything. The previous
// Alt+M tab-local gating became unnecessary once the Audio mode listbox was retired
// (2026-05-11); minimise to tray is reachable via Alt+F → M (File menu mnemonic) or
// the configurable "Show or hide window" global hotkey (default Ctrl+Shift+F10).
// Defensive gate for the global menu shortcuts that change state (Ctrl+R = toggle
// recording, Ctrl+S = save profile). The default WinForms behaviour fires these
// shortcuts any time the form has keyboard focus — which technically includes the
// case where another tool (NVDA Remote in send-keys mode, an automation script,
// etc.) calls SetForegroundWindow on us and then SendInput a keystroke a few
// milliseconds later. The form receives focus + the keystroke arrives + the menu
// shortcut fires, all without the user touching anything.
//
// The gate adds two extra requirements before we let these shortcuts run:
// 1. The OS-level foreground window must be us. Same check the base class
// effectively makes, but explicit so the intent is documented.
// 2. At least RecentActivationGuardMs must have elapsed since we last became
// activated. Programmatic SetForegroundWindow + SendInput typically runs in
// under 50 ms; a human Alt+Tabbing in then pressing Ctrl+R can't physically
// do it inside 250 ms.
// If the gate fails we consume the keystroke (return true) so the menu shortcut
// doesn't fire, log a diagnostic, and silently ignore it. The user can still drive
// the same actions via the Alt+R / Alt+F menu chord which inherently requires the
// multi-step menu-open interaction and isn't vulnerable to drive-by injection.
if (keyData == (Keys.Control | Keys.R) || keyData == (Keys.Control | Keys.S))
{
if (!IsWindowAvailableForGatedShortcut())
{
logFile.Event($"shortcut ignored (window not in interactive state): {keyData}");
return true; // consumed; don't let MenuStrip see it
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
// UTC time the form last became activated. Compared against UtcNow when a gated
// shortcut fires to reject keystrokes that arrive within the RecentActivationGuardMs
// window after a window-activation — the signature of a drive-by injection.
private DateTime lastActivatedAtUtc = DateTime.MinValue;
private const int RecentActivationGuardMs = 250;
protected override void OnActivated(EventArgs e)
{
lastActivatedAtUtc = DateTime.UtcNow;
base.OnActivated(e);
}
/// <summary>Defensive gate for global menu shortcuts that change state. See the comment
/// in <see cref="ProcessCmdKey"/> for the full rationale.</summary>
private bool IsWindowAvailableForGatedShortcut()
{
if (!Visible || WindowState == FormWindowState.Minimized) return false;
if ((DateTime.UtcNow - lastActivatedAtUtc).TotalMilliseconds < RecentActivationGuardMs) return false;
return GetForegroundWindow() == Handle;
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
// ===================== Profile system =====================
@@ -3973,13 +4300,13 @@ public sealed class MainForm : Form
previousPeerHealthStates.TryGetValue(key, out var prior);
if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy)
{
if (!settings.LoadMuteConnectionCues()) connectSound?.Play();
if (settings.LoadEnableConnectCue()) connectSound?.Play();
logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)");
}
else if (ph.State == PeerHealthState.Unreachable
&& (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale))
{
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play();
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)");
}
previousPeerHealthStates[key] = ph.State;
@@ -3991,7 +4318,7 @@ public sealed class MainForm : Form
{
if (previousPeerHealthStates[key] == PeerHealthState.Healthy)
{
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play();
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)");
}
previousPeerHealthStates.Remove(key);
@@ -4229,6 +4556,18 @@ public sealed class MainForm : Form
/// Mixed flag; in BothIndependent either WASAPI or ASIO being on is enough to keep the
/// timer running. The per-route filtering inside the tick gates which sliders actually
/// move.</summary>
/// <summary>True if either lane's continuous auto-tune is enabled. Used by the shared
/// interval combo's Enabled state — the combo governs both lanes' tick rates, so it
/// should be usable as long as at least one lane wants ticking. Reading from the live
/// checkbox states keeps this consistent with the lane's checkbox even before the
/// CheckedChanged handlers have updated the persisted setting.</summary>
private bool AnyAutoTuneEnabled()
{
var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent;
var asioOn = inBothIndependent && continuousTuneAsioBox.Checked;
return continuousTuneEnabled || asioOn;
}
private void ApplyContinuousTuneTimer()
{
continuousTuneTimer.Stop();
@@ -4704,6 +5043,12 @@ public sealed class MainForm : Form
}
}
// Stop any active recording before the engines tear down. The recorder will flush
// its queue and close the file cleanly. Done here (rather than in Dispose) because
// we want the on-disk file finalised before the form closes, so opening the
// recordings folder right after exit shows the file at its full size.
try { recordingController.Stop(); } catch { /* recording cleanup is best-effort */ }
base.OnFormClosing(e);
}
}
+78 -16
View File
@@ -3,15 +3,21 @@ using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Preferences dialog. Holds the three settings that used to live on the (now-removed)
/// Profiles and preferences tab and aren't profile-management actions in their own right:
/// * Mute connect/disconnect sounds — the small ding on peer state changes.
/// Preferences dialog. Holds settings that aren't profile-management actions in their own
/// right:
/// * Browse for RemSound profiles folder — picks the directory the profile picker scans
/// next launch.
/// * Cue sounds — per-cue enable list (connect, disconnect, recording start/stop). One
/// CheckedListBox; ticked items play, unticked are silent. Replaced the old single
/// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues
/// were added — a CheckedListBox scales to future cues without dialog re-layout.
/// * Accept remote volume commands from peers — opt-in for the remote-control feature.
/// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog.
/// * Update settings — frequency, manual check, silent-install toggle.
/// * Enable logs + Write logs now.
///
/// Both checkboxes save through <see cref="RemSoundSettingsStore"/> on every change (so
/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button
/// just opens the existing modal sub-dialog. Esc or the Close button dismisses.
/// All settings save through <see cref="RemSoundSettingsStore"/> or <see cref="AppConfig"/>
/// on every change (no OK-to-commit). Esc or Close dismisses.
///
/// Reachable via the File → Preferences menu item or Ctrl+P from the main window.
/// </summary>
@@ -24,13 +30,36 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
private readonly AccessibleCheckBox muteCuesBox = new()
// Per-cue enable list (2026-05-15). Replaces the single "mute connect/disconnect"
// checkbox with one item per cue sound, ticked = play, unticked = silent. Same
// CheckOnClick / mnemonic-via-label pattern as the audio device lists on the main
// form — visually familiar and NVDA-friendly. The Items collection order MUST match
// the CueIndex enum below so the ItemCheck handler can dispatch by index.
private readonly Label cueListLabel = new()
{
Text = "Mute connect/disconnect sounds (Alt+&M)",
AccessibleName = "Mute connect/disconnect sounds",
Text = "Cue sou&nds (Alt+N):",
AccessibleName = "Cue sounds",
AutoSize = true,
Padding = new Padding(0, 6, 0, 4),
};
private readonly CheckedListBox cueList = new()
{
CheckOnClick = true,
IntegralHeight = false,
Height = 100,
Width = 360,
AccessibleName = "Cue sounds",
};
private enum CueIndex
{
Connect = 0,
Disconnect = 1,
RecordStart = 2,
RecordStop = 3,
}
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
{
Text = "Accept remote volume commands from peers (Alt+&A)",
@@ -119,7 +148,7 @@ internal sealed class PreferencesDialog : Form
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(560, 440);
ClientSize = new Size(560, 540);
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
@@ -154,10 +183,27 @@ internal sealed class PreferencesDialog : Form
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
muteCuesBox.Checked = settings.LoadMuteConnectionCues();
muteCuesBox.CheckedChanged += (_, _) =>
// Populate the cue list — order must match CueIndex enum. Each item is ticked from
// its corresponding settings flag; the toggle handler dispatches by index so adding
// a future cue is just two lines (enum value + Items.Add + Save case).
cueList.Items.Clear();
cueList.Items.Add("Connect sound", settings.LoadEnableConnectCue());
cueList.Items.Add("Disconnect sound", settings.LoadEnableDisconnectCue());
cueList.Items.Add("Recording start sound", settings.LoadEnableRecordStartCue());
cueList.Items.Add("Recording stop sound", settings.LoadEnableRecordStopCue());
cueList.ItemCheck += (_, e) =>
{
settings.SaveMuteConnectionCues(muteCuesBox.Checked);
// ItemCheck fires BEFORE the visual state actually flips; e.NewValue is what
// it's about to become. Use that for the persist call so the saved value
// matches what the user just clicked.
var nowEnabled = e.NewValue == CheckState.Checked;
switch ((CueIndex)e.Index)
{
case CueIndex.Connect: settings.SaveEnableConnectCue(nowEnabled); break;
case CueIndex.Disconnect: settings.SaveEnableDisconnectCue(nowEnabled); break;
case CueIndex.RecordStart: settings.SaveEnableRecordStartCue(nowEnabled); break;
case CueIndex.RecordStop: settings.SaveEnableRecordStopCue(nowEnabled); break;
}
ChangedAnyProfileSetting = true;
};
@@ -224,11 +270,11 @@ internal sealed class PreferencesDialog : Form
for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
// Tab order top-to-bottom: browse, mute cues, accept remote, startup, update
// Tab order top-to-bottom: browse, cue-sound list, accept remote, startup, update
// frequency, check-now, silent install, enable logs, write logs now, close. Updates
// sit above the log row so a user setting up the app meets them first.
browseProfilesFolderButton.TabIndex = 0;
muteCuesBox.TabIndex = 1;
cueList.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2;
startupBehaviourButton.TabIndex = 3;
updateFrequencyBox.TabIndex = 4;
@@ -252,8 +298,24 @@ internal sealed class PreferencesDialog : Form
freqRow.Controls.Add(updateFrequencyLabel);
freqRow.Controls.Add(updateFrequencyBox);
// Wrap label + list as one logical group so they share the same row in the
// top-level layout. The label's Alt+N mnemonic focuses the list when activated.
var cueGroup = new TableLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
ColumnCount = 1,
RowCount = 2,
};
cueGroup.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.Controls.Add(cueListLabel, 0, 0);
cueGroup.Controls.Add(cueList, 0, 1);
cueListLabel.Click += (_, _) => cueList.Focus();
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(muteCuesBox, 0, 1);
panel.Controls.Add(cueGroup, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
panel.Controls.Add(startupBehaviourButton, 0, 3);
panel.Controls.Add(freqRow, 0, 4);
+152
View File
@@ -0,0 +1,152 @@
using RemSound.Core;
using RemSound.Receiver;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>
/// Glue between MainForm's Record menu and the actual recording pipeline. Owns the
/// lifecycle of the currently-running <see cref="AudioRecorder"/> (if any) and wires
/// the sender / receiver taps to it. Reading the user's saved settings, persisting
/// changes after the settings dialog, opening / changing the recordings folder — all
/// flow through here so MainForm stays focused on UI wiring.
///
/// Threading: the public methods are called from the UI thread only. The recorder
/// itself runs on its own background thread (it owns a queue + writer); the controller
/// just constructs and disposes it.
/// </summary>
internal sealed class RecordingController
{
private readonly AudioSender sender;
private readonly AudioReceiver receiver;
private readonly RemSoundSettingsStore settings;
private readonly Action<string> diagnostic;
private AudioRecorder? active;
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
{
this.sender = sender;
this.receiver = receiver;
this.settings = settings;
this.diagnostic = diagnostic;
}
public bool IsRecording => active is not null;
/// <summary>Optional callback fired when the user starts or stops a recording. The
/// MainForm hooks this to flip the menu item text "Start recording" ↔ "Stop recording"
/// and announce the change to NVDA.</summary>
public event Action<bool>? RecordingStateChanged;
/// <summary>Start a new recording using the currently-saved profile settings. If a
/// recording is already running this is a no-op (the menu shouldn't ever offer Start
/// while recording, but the guard is here for safety).</summary>
public void Start()
{
if (active is not null) return;
var s = settings.LoadRecordingSettings();
try
{
active = new AudioRecorder(s, diagnostic, OnRecorderFinished);
}
catch (Exception ex)
{
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
MessageBox.Show(
$"Could not start recording:\n\n{ex.Message}",
"RemSound — recording",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
// Wire taps. Each tap is independent — the recorder's source-mode filter decides
// whether to actually write the samples.
sender.OnSentSamples = active.WriteSent;
receiver.OnReceivedSamples = active.WriteReceived;
diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})");
RecordingStateChanged?.Invoke(true);
}
/// <summary>Stop the currently-running recording. Unhooks taps, flushes the writer
/// queue, closes the file, and surfaces the resulting path in a brief MessageBox
/// so the user knows where the file landed.</summary>
public void Stop()
{
var recorder = active;
if (recorder is null) return;
// Unhook taps FIRST so no more audio gets queued during the drain.
sender.OnSentSamples = null;
receiver.OnReceivedSamples = null;
active = null;
try
{
recorder.Stop();
recorder.Dispose();
}
catch (Exception ex)
{
diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}");
}
RecordingStateChanged?.Invoke(false);
}
private void OnRecorderFinished(string path, long bytes)
{
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
}
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
/// rather than swallowing them silently.</summary>
public void OpenCurrentFolder(IWin32Window? owner)
{
var s = settings.LoadRecordingSettings();
var folder = s.ResolvedFolder();
try
{
Directory.CreateDirectory(folder);
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folder,
UseShellExecute = true,
});
}
catch (Exception ex)
{
diagnostic($"recording: open folder failed: {ex.GetType().Name}: {ex.Message}");
MessageBox.Show(owner,
$"Could not open recordings folder:\n\n{ex.Message}",
"RemSound — recordings folder",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
/// <summary>Show a folder-picker rooted at the current recordings folder. If the
/// user picks a different folder, save it on the profile and return true so the
/// caller can flag the profile dirty.</summary>
public bool ChangeFolder(IWin32Window? owner)
{
var s = settings.LoadRecordingSettings();
var startFolder = s.ResolvedFolder();
using var picker = new FolderBrowserDialog
{
Description = "Choose a folder for RemSound recordings",
UseDescriptionForTitle = true,
SelectedPath = Directory.Exists(startFolder) ? startFolder : RecordingSettings.DefaultFolder(),
ShowNewFolderButton = true,
};
if (picker.ShowDialog(owner) != DialogResult.OK) return false;
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return false;
if (string.Equals(picker.SelectedPath, startFolder, StringComparison.OrdinalIgnoreCase)) return false;
s.Folder = picker.SelectedPath;
settings.SaveRecordingSettings(s);
diagnostic($"recording: folder changed → {picker.SelectedPath}");
return true;
}
}
+432
View File
@@ -0,0 +1,432 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Recording settings dialog. Three listboxes laid out left-to-right:
/// * Recording &source (Alt+S) — what audio gets captured
/// * File &format (Alt+F) — WAV / MP3 / Ogg / FLAC
/// * Audio &attributes (Alt+A) — bit depth or bitrate, plus channel mode
///
/// The attributes list repopulates whenever the file-format selection changes, so the user
/// always sees only the choices that make sense for the format. Selecting a WAV-only
/// attribute then switching the format to MP3 doesn't carry forward — the format-attributes
/// list resets to a sensible default for the new format.
///
/// Settings are written back to the profile only when the user presses OK. Cancel / Esc
/// discards. The dialog also exposes <see cref="ChangedAnything"/> so the caller can
/// MarkProfileDirty after a successful OK.
///
/// Reachable from the Record menu → "Recording settings...".
/// </summary>
internal sealed class RecordingSettingsDialog : Form
{
private readonly RecordingSettings working; // mutated as the user interacts
private readonly Label sourceLabel = new()
{
Text = "Recording &source (Alt+S):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox sourceList = new()
{
AccessibleName = "Recording source",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label formatLabel = new()
{
Text = "File &format (Alt+F):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox formatList = new()
{
AccessibleName = "File format",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 120,
};
private readonly Label attributesLabel = new()
{
Text = "Audio format &attributes (Alt+A):",
AutoSize = true,
Padding = new Padding(0, 0, 0, 4),
};
private readonly ListBox attributesList = new()
{
AccessibleName = "Audio format attributes",
SelectionMode = SelectionMode.One,
IntegralHeight = false,
Height = 200,
};
private readonly Button okButton = new()
{
Text = "&OK",
AutoSize = true,
DialogResult = DialogResult.OK,
};
private readonly Button cancelButton = new()
{
Text = "&Cancel",
AutoSize = true,
DialogResult = DialogResult.Cancel,
};
/// <summary>True if the user pressed OK and any setting actually changed. The caller
/// uses this to mark the profile dirty.</summary>
public bool ChangedAnything { get; private set; }
/// <summary>The final settings (after OK). Equals the input settings if Cancel was
/// pressed — caller should ignore this on a non-OK DialogResult.</summary>
public RecordingSettings Result => working;
public RecordingSettingsDialog(RecordingSettings current)
{
working = current?.Clone() ?? new RecordingSettings();
var initialSnapshot = working.Clone();
Text = "Recording settings";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(700, 360);
PopulateSourceList();
PopulateFormatList();
PopulateAttributesList();
SelectFromSource(working.Source);
SelectFromFormat(working.FileFormat);
SelectFromAttributes(working);
sourceList.SelectedIndexChanged += (_, _) =>
{
if (sourceList.SelectedIndex < 0) return;
working.Source = (RecordingSource)sourceList.SelectedIndex;
};
formatList.SelectedIndexChanged += (_, _) =>
{
if (formatList.SelectedIndex < 0) return;
var newFormat = (RecordingFileFormat)formatList.SelectedIndex;
if (newFormat == working.FileFormat) return;
working.FileFormat = newFormat;
PopulateAttributesList();
SelectFromAttributes(working);
};
attributesList.SelectedIndexChanged += (_, _) =>
{
if (attributesList.SelectedIndex < 0) return;
ApplyAttributesSelection();
};
okButton.Click += (_, _) =>
{
ChangedAnything = !SettingsEqual(initialSnapshot, working);
};
// Three columns side by side, OK/Cancel row beneath.
var grid = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 3,
RowCount = 2,
};
for (var i = 0; i < 3; i++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.3f));
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var sourceColumn = MakeColumn(sourceLabel, sourceList);
var formatColumn = MakeColumn(formatLabel, formatList);
var attributesColumn = MakeColumn(attributesLabel, attributesList);
grid.Controls.Add(sourceColumn, 0, 0);
grid.SetRowSpan(sourceColumn, 2);
grid.Controls.Add(formatColumn, 1, 0);
grid.SetRowSpan(formatColumn, 2);
grid.Controls.Add(attributesColumn, 2, 0);
grid.SetRowSpan(attributesColumn, 2);
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 0, 12, 12),
};
buttonRow.Controls.Add(cancelButton);
buttonRow.Controls.Add(okButton);
// Order: dialog body first (grid), then buttons docked beneath.
Controls.Add(grid);
Controls.Add(buttonRow);
AcceptButton = okButton;
CancelButton = cancelButton;
// Tab order top-to-bottom of the visible flow: source, format, attributes, OK, Cancel.
sourceList.TabIndex = 0;
formatList.TabIndex = 1;
attributesList.TabIndex = 2;
okButton.TabIndex = 3;
cancelButton.TabIndex = 4;
}
private static Control MakeColumn(Label label, ListBox list)
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
list.Dock = DockStyle.Fill;
panel.Controls.Add(label, 0, 0);
panel.Controls.Add(list, 0, 1);
return panel;
}
private void PopulateSourceList()
{
sourceList.BeginUpdate();
sourceList.Items.Clear();
// Order MUST match RecordingSource enum values 0/1/2.
sourceList.Items.Add("Record all received audio");
sourceList.Items.Add("Record all sent audio");
sourceList.Items.Add("Record both sent and received audio");
sourceList.EndUpdate();
}
private void PopulateFormatList()
{
formatList.BeginUpdate();
formatList.Items.Clear();
// Order MUST match RecordingFileFormat enum values 0..3.
formatList.Items.Add("WAV (uncompressed)");
formatList.Items.Add("MP3");
formatList.Items.Add("Ogg-Opus");
formatList.Items.Add("FLAC (lossless)");
formatList.EndUpdate();
}
// === Per-format attribute tables ===
// All four formats currently record at the engine's 48 kHz mix rate, so labels include
// "48 kHz" to make the sample rate explicit (it's not a choice — it's a statement of fact
// about what gets written, which removes a common surprise for users who expected to see
// a rate picker). Channel mode is part of every row because it determines file shape
// alongside the format-specific quality knob.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] WavAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit PCM, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit PCM, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit PCM, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit PCM, 48 kHz, mono"),
(32, RecordingChannelMode.Stereo, "32-bit float, 48 kHz, stereo"),
(32, RecordingChannelMode.Mono, "32-bit float, 48 kHz, mono"),
};
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] Mp3Attributes =
{
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
(320, RecordingChannelMode.Stereo, "320 kbps, 48 kHz, stereo"),
(320, RecordingChannelMode.Mono, "320 kbps, 48 kHz, mono"),
};
// OGG-Opus is VBR — kbps numbers are the encoder's target average. Opus' music-quality
// sweet spot starts around 96 kbps; we expose 96 / 128 / 192 / 256 so users have a
// smaller-file option without it sounding obviously lossy on dense material.
private static readonly (int Kbps, RecordingChannelMode Mode, string Label)[] OggOpusAttributes =
{
(96, RecordingChannelMode.Stereo, "96 kbps, 48 kHz, stereo"),
(96, RecordingChannelMode.Mono, "96 kbps, 48 kHz, mono"),
(128, RecordingChannelMode.Stereo, "128 kbps, 48 kHz, stereo"),
(128, RecordingChannelMode.Mono, "128 kbps, 48 kHz, mono"),
(192, RecordingChannelMode.Stereo, "192 kbps, 48 kHz, stereo"),
(192, RecordingChannelMode.Mono, "192 kbps, 48 kHz, mono"),
(256, RecordingChannelMode.Stereo, "256 kbps, 48 kHz, stereo"),
(256, RecordingChannelMode.Mono, "256 kbps, 48 kHz, mono"),
};
// FLAC is lossless — quality knob is just bit depth (and silently, compression level,
// which we hard-fix at the reference encoder's default 5). 32-bit float isn't a FLAC
// option (FLAC stores integer PCM), so it's deliberately absent.
private static readonly (int Bits, RecordingChannelMode Mode, string Label)[] FlacAttributes =
{
(16, RecordingChannelMode.Stereo, "16-bit, 48 kHz, stereo"),
(16, RecordingChannelMode.Mono, "16-bit, 48 kHz, mono"),
(24, RecordingChannelMode.Stereo, "24-bit, 48 kHz, stereo"),
(24, RecordingChannelMode.Mono, "24-bit, 48 kHz, mono"),
};
private void PopulateAttributesList()
{
attributesList.BeginUpdate();
attributesList.Items.Clear();
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
foreach (var (_, _, label) in WavAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Mp3:
foreach (var (_, _, label) in Mp3Attributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Ogg:
foreach (var (_, _, label) in OggOpusAttributes) attributesList.Items.Add(label);
break;
case RecordingFileFormat.Flac:
foreach (var (_, _, label) in FlacAttributes) attributesList.Items.Add(label);
break;
default:
attributesList.Items.Add("Default settings");
break;
}
attributesList.EndUpdate();
}
private void SelectFromSource(RecordingSource src)
{
var idx = (int)src;
if (idx >= 0 && idx < sourceList.Items.Count) sourceList.SelectedIndex = idx;
}
private void SelectFromFormat(RecordingFileFormat fmt)
{
var idx = (int)fmt;
if (idx >= 0 && idx < formatList.Items.Count) formatList.SelectedIndex = idx;
}
private void SelectFromAttributes(RecordingSettings s)
{
switch (s.FileFormat)
{
case RecordingFileFormat.Wav:
for (var i = 0; i < WavAttributes.Length; i++)
{
var (bits, mode, _) = WavAttributes[i];
if (bits == s.WavBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
case RecordingFileFormat.Mp3:
for (var i = 0; i < Mp3Attributes.Length; i++)
{
var (kbps, mode, _) = Mp3Attributes[i];
if (kbps == s.Mp3BitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 6; // 320 kbps stereo default
break;
case RecordingFileFormat.Ogg:
for (var i = 0; i < OggOpusAttributes.Length; i++)
{
var (kbps, mode, _) = OggOpusAttributes[i];
if (kbps == s.OggOpusBitrateKbps && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 4; // 192 kbps stereo default
break;
case RecordingFileFormat.Flac:
for (var i = 0; i < FlacAttributes.Length; i++)
{
var (bits, mode, _) = FlacAttributes[i];
if (bits == s.FlacBitsPerSample && mode == s.ChannelMode)
{
attributesList.SelectedIndex = i;
return;
}
}
attributesList.SelectedIndex = 2; // 24-bit stereo default
break;
default:
if (attributesList.Items.Count > 0) attributesList.SelectedIndex = 0;
break;
}
}
private void ApplyAttributesSelection()
{
var idx = attributesList.SelectedIndex;
if (idx < 0) return;
switch (working.FileFormat)
{
case RecordingFileFormat.Wav:
if (idx < WavAttributes.Length)
{
var (bits, mode, _) = WavAttributes[idx];
working.WavBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Mp3:
if (idx < Mp3Attributes.Length)
{
var (kbps, mode, _) = Mp3Attributes[idx];
working.Mp3BitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Ogg:
if (idx < OggOpusAttributes.Length)
{
var (kbps, mode, _) = OggOpusAttributes[idx];
working.OggOpusBitrateKbps = kbps;
working.ChannelMode = mode;
}
break;
case RecordingFileFormat.Flac:
if (idx < FlacAttributes.Length)
{
var (bits, mode, _) = FlacAttributes[idx];
working.FlacBitsPerSample = bits;
working.ChannelMode = mode;
}
break;
default:
break;
}
}
private static bool SettingsEqual(RecordingSettings a, RecordingSettings b) =>
a.Source == b.Source
&& a.FileFormat == b.FileFormat
&& a.ChannelMode == b.ChannelMode
&& a.WavBitsPerSample == b.WavBitsPerSample
&& a.Mp3BitrateKbps == b.Mp3BitrateKbps
&& a.OggOpusBitrateKbps == b.OggOpusBitrateKbps
&& a.FlacBitsPerSample == b.FlacBitsPerSample
&& a.FlacCompressionLevel == b.FlacCompressionLevel
&& string.Equals(a.Folder ?? string.Empty, b.Folder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
+22 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>1.1.0</Version>
<Version>1.2.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -22,6 +22,17 @@
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" />
<!-- LAME wrapper for MP3 encoding. Pulled in for the recording feature. The native
libmp3lame.dll ships with the package and is copied to the output folder. -->
<PackageReference Include="NAudio.Lame" Version="2.1.0" />
<!-- OGG container writer that wraps Concentus-encoded Opus. Same Concentus the wire
path already uses (transitive dep), so the recording-side OGG-Opus output is
byte-for-byte the same encoder we'd send on a live Opus stream. -->
<PackageReference Include="Concentus.Oggfile" Version="1.0.7" />
<!-- Pure-managed FLAC encoder (Flake). Same encoder CUETools uses; no native DLL,
no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without
sample-data loss. -->
<PackageReference Include="CUETools.Codecs.FLAKE" Version="1.0.5" />
</ItemGroup>
<ItemGroup>
@@ -35,6 +46,16 @@
<Link>disconnect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Recording start/stop cues. Filenames contain a space — preserve on copy so the
load-by-filename path in TryLoadCueSound finds them exactly as written. -->
<Content Include="..\..\record start.wav">
<Link>record start.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\record stop.wav">
<Link>record stop.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
the published copy whenever the source is newer; manually-edited copies inside