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:
co-authored by
Claude Opus 4.7
parent
a0fe8070ed
commit
c59e413c1f
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user