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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Per-stage sample-step "discontinuity detector" for diagnosing where in the audio pipeline
|
||||
/// pops are being introduced. A single-sample step magnitude is the absolute difference
|
||||
/// between consecutive samples of the same channel; a typical "click" in real audio shows
|
||||
/// up as a step well above what naturally occurs in music or speech content.
|
||||
///
|
||||
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/>
|
||||
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to
|
||||
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender
|
||||
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler,
|
||||
/// final output — reveals which stage introduces the click.
|
||||
///
|
||||
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
|
||||
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
|
||||
/// Buffers are scanned cheaply — one subtract + abs + compare per sample — and the whole
|
||||
/// scan is gated by <see cref="DiagnosticsGate.Enabled"/> so it pays nothing in production
|
||||
/// when logging is off.
|
||||
/// </summary>
|
||||
public sealed class AudioStepProbe
|
||||
{
|
||||
private long maxStepBits;
|
||||
// Remember the last sample on each channel so the next scan can compute the cross-buffer
|
||||
// step. Without this we'd miss any discontinuity at the buffer boundary (the most
|
||||
// suspicious place — that's where copies, format conversions and resampler hand-offs
|
||||
// happen).
|
||||
private float lastL;
|
||||
private float lastR;
|
||||
private bool hasLast;
|
||||
|
||||
/// <summary>Scan a single-channel slice of an interleaved multi-channel float buffer and
|
||||
/// update the max step magnitude. <paramref name="channelCount"/> is the total number of
|
||||
/// interleaved channels; <paramref name="channelIndex"/> picks which one to scan. Used by
|
||||
/// the ASIO capture probe to look at raw driver-delivered samples on individual channels
|
||||
/// before any mixing or clamping happens. Cheap; safe to call from any thread; no-op when
|
||||
/// diagnostics are disabled.</summary>
|
||||
public void ScanInterleavedChannel(ReadOnlySpan<float> interleavedFloats, int channelCount, int channelIndex)
|
||||
{
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (interleavedFloats.IsEmpty) return;
|
||||
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
|
||||
var max = ReadMax();
|
||||
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
|
||||
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
|
||||
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
|
||||
// glitches.)
|
||||
var prev = lastL;
|
||||
var seedFromPrev = hasLast;
|
||||
var samples = interleavedFloats.Length / channelCount;
|
||||
for (var i = 0; i < samples; i++)
|
||||
{
|
||||
var s = interleavedFloats[i * channelCount + channelIndex];
|
||||
if (i == 0 && !seedFromPrev) prev = s;
|
||||
var step = s - prev;
|
||||
if (step < 0f) step = -step;
|
||||
if (step > max) max = step;
|
||||
prev = s;
|
||||
}
|
||||
lastL = prev;
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
}
|
||||
|
||||
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
|
||||
/// to call from any thread. No-op if diagnostics are disabled.</summary>
|
||||
public void ScanStereo(ReadOnlySpan<float> stereoFloats)
|
||||
{
|
||||
if (!DiagnosticsGate.Enabled) return;
|
||||
if (stereoFloats.IsEmpty) return;
|
||||
var max = ReadMax();
|
||||
var prevL = lastL;
|
||||
var prevR = lastR;
|
||||
var seedFromPrev = hasLast;
|
||||
// Pair walk. For samples after the first, compare to the previous sample of the
|
||||
// same channel from THIS buffer. For the first pair, compare to the saved
|
||||
// last-sample-from-the-previous-buffer if available.
|
||||
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
|
||||
{
|
||||
var l = stereoFloats[i];
|
||||
var r = stereoFloats[i + 1];
|
||||
float stepL, stepR;
|
||||
if (i == 0)
|
||||
{
|
||||
if (!seedFromPrev) { prevL = l; prevR = r; }
|
||||
stepL = l - prevL;
|
||||
stepR = r - prevR;
|
||||
}
|
||||
else
|
||||
{
|
||||
stepL = l - stereoFloats[i - 2];
|
||||
stepR = r - stereoFloats[i - 1];
|
||||
}
|
||||
var absL = stepL < 0f ? -stepL : stepL;
|
||||
var absR = stepR < 0f ? -stepR : stepR;
|
||||
if (absL > max) max = absL;
|
||||
if (absR > max) max = absR;
|
||||
}
|
||||
// Save the last sample of this buffer for the next scan.
|
||||
var lastIdx = stereoFloats.Length - 2;
|
||||
lastL = stereoFloats[lastIdx];
|
||||
lastR = stereoFloats[lastIdx + 1];
|
||||
hasLast = true;
|
||||
WriteMaxIfGreater(max);
|
||||
}
|
||||
|
||||
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as
|
||||
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
|
||||
/// step, which is a 6 dB jump and definitely audible).</summary>
|
||||
public float TakeMax()
|
||||
{
|
||||
var bits = Interlocked.Exchange(ref maxStepBits, 0);
|
||||
return BitConverter.Int32BitsToSingle((int)bits);
|
||||
}
|
||||
|
||||
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
|
||||
|
||||
private void WriteMaxIfGreater(float candidate)
|
||||
{
|
||||
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
|
||||
long current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maxStepBits);
|
||||
var currentValue = BitConverter.Int32BitsToSingle((int)current);
|
||||
if (candidate <= currentValue) return;
|
||||
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,23 @@ public sealed class Profile
|
||||
/// profile (not in AppConfig) because the right answer genuinely differs between
|
||||
/// profiles.</summary>
|
||||
public bool PriorityMode { get; set; }
|
||||
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.</summary>
|
||||
/// <summary>Legacy combined "mute connect/disconnect sounds" toggle. True suppresses
|
||||
/// both connect AND disconnect cues. Superseded 2026-05-15 by the four individual
|
||||
/// <c>Enable*Cue</c> flags below — the new flags take precedence when set. This field
|
||||
/// is preserved on the profile for backward compatibility with older builds that don't
|
||||
/// know about the per-cue flags; on first load the per-cue flags inherit from this
|
||||
/// (true → connect+disconnect cues disabled).</summary>
|
||||
public bool MuteConnectionCues { get; set; }
|
||||
|
||||
/// <summary>Per-cue enable flags. Nullable so a missing entry in an older profile JSON
|
||||
/// falls back to the legacy <see cref="MuteConnectionCues"/> migration path; once the
|
||||
/// user touches the new UI we write a concrete <c>true</c>/<c>false</c> and the legacy
|
||||
/// field stops mattering. Defaults to "play the sound" (true) for both cases — the
|
||||
/// audio cues are part of the normal user feedback loop, not opt-in. 2026-05-15.</summary>
|
||||
public bool? EnableConnectCue { get; set; }
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public int MaxLatencyMs { get; set; } = 80;
|
||||
public int Smoothness { get; set; } = 3;
|
||||
public bool ContinuousAutoTuneEnabled { get; set; }
|
||||
@@ -95,6 +110,15 @@ public sealed class Profile
|
||||
set => ConcealmentArtifactRaw = (int)value;
|
||||
}
|
||||
|
||||
// === Recording ===
|
||||
/// <summary>Recording source / format / attributes. The whole settings object is saved
|
||||
/// per profile so different profiles can record different things (a "long session"
|
||||
/// profile might record everything to MP3, a "monitoring" profile might not record at
|
||||
/// all but keep the dialog defaults sensible). The recording isn't running until the
|
||||
/// user explicitly triggers it via the Record menu; this just holds the configuration
|
||||
/// the recorder picks up when it starts.</summary>
|
||||
public RecordingSettings RecordingSettings { get; set; } = new();
|
||||
|
||||
// === Peers ===
|
||||
public List<string> RememberedPeers { get; set; } = [];
|
||||
/// <summary>Peer addresses (IP or host[:port]) the user had ticked in the connected
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>What audio gets captured by the recorder. Selected in the Recording settings
|
||||
/// dialog and saved per profile. Defaults to <see cref="ReceivedOnly"/> which is the most
|
||||
/// common "I want a copy of what my collaborator just played me" case.</summary>
|
||||
public enum RecordingSource
|
||||
{
|
||||
/// <summary>Record only audio coming from connected peers (everything that would play
|
||||
/// out of the receiver's render path).</summary>
|
||||
ReceivedOnly = 0,
|
||||
/// <summary>Record only audio captured locally (everything this machine is sending to
|
||||
/// peers — your own mics / loopback / ASIO inputs).</summary>
|
||||
SentOnly = 1,
|
||||
/// <summary>Record the sum of received + sent audio, soft-mixed and limiter-protected
|
||||
/// just like the playback path. Useful for capturing a complete two-way exchange in a
|
||||
/// single file.</summary>
|
||||
Both = 2,
|
||||
}
|
||||
|
||||
/// <summary>Output container format the recorder writes to disk. The format dictates the
|
||||
/// shape of <see cref="RecordingSettings.AudioAttributes"/> — uncompressed formats take
|
||||
/// bit-depth, compressed formats take a bitrate, mono/stereo applies to all of them.</summary>
|
||||
public enum RecordingFileFormat
|
||||
{
|
||||
/// <summary>RIFF WAVE, PCM. Lossless, large. Writer: in-process custom WAV writer with
|
||||
/// periodic header re-patching so a mid-session crash leaves a playable file.</summary>
|
||||
Wav = 0,
|
||||
/// <summary>MPEG Layer III. Lossy, small. Writer: NAudio.Lame (LAME library).</summary>
|
||||
Mp3 = 1,
|
||||
/// <summary>Ogg container with Opus codec. Lossy, very small at sane bitrates.
|
||||
/// Reuses the Concentus Opus encoder the wire path uses, wrapped in an Ogg container
|
||||
/// via the Concentus.Oggfile NuGet.</summary>
|
||||
Ogg = 2,
|
||||
/// <summary>FLAC — lossless, typically ~50 % the size of equivalent WAV.
|
||||
/// Writer: CUETools.Codecs.FLAKE — pure-managed FLAC encoder, no native DLL.</summary>
|
||||
Flac = 3,
|
||||
}
|
||||
|
||||
/// <summary>Channel layout for the recording — independent of the format. Stereo preserves
|
||||
/// the L/R as captured; Mono downmixes to (L + R) / 2 with a 3 dB headroom safety knock so
|
||||
/// fully-correlated content doesn't clip.</summary>
|
||||
public enum RecordingChannelMode
|
||||
{
|
||||
Stereo = 0,
|
||||
Mono = 1,
|
||||
}
|
||||
|
||||
/// <summary>All the user-selectable knobs for a recording. Stored on <see cref="Profile"/>.
|
||||
///
|
||||
/// The <see cref="AudioAttributes"/> field is a flat int that means different things per
|
||||
/// format — <see cref="WavBitsPerSample"/> for WAV, <see cref="Mp3BitrateKbps"/> for MP3
|
||||
/// — kept as one slot rather than a separate field per format because (a) only one is
|
||||
/// active at a time and (b) it keeps the profile JSON narrow. The format enum decides
|
||||
/// which interpretation applies.
|
||||
///
|
||||
/// Path policy: <see cref="Folder"/> is stored verbatim. When empty, recordings go to
|
||||
/// the default location (<c><exe>\recordings\<machine>\</c>). When set, it
|
||||
/// IS the folder — no per-machine subfolder is appended. Each recording session creates
|
||||
/// a new file inside the folder, named with a UTC timestamp and the format extension.
|
||||
/// </summary>
|
||||
public sealed class RecordingSettings
|
||||
{
|
||||
public RecordingSource Source { get; set; } = RecordingSource.ReceivedOnly;
|
||||
public RecordingFileFormat FileFormat { get; set; } = RecordingFileFormat.Wav;
|
||||
public RecordingChannelMode ChannelMode { get; set; } = RecordingChannelMode.Stereo;
|
||||
|
||||
/// <summary>WAV bit depth. 16 / 24 / 32. 32 means IEEE float; 16 and 24 are signed PCM.
|
||||
/// Defaults to 24 which matches RemSound's on-wire PCM bit depth — no extra quantisation
|
||||
/// happens on the way to disk. Ignored when <see cref="FileFormat"/> isn't WAV.</summary>
|
||||
public int WavBitsPerSample { get; set; } = 24;
|
||||
|
||||
/// <summary>MP3 CBR bitrate in kbps. Common values: 128, 192, 256, 320. 320 is the
|
||||
/// LAME maximum and the default here — the recording feature is for archival of audio
|
||||
/// you cared enough to send over the network, not for a podcast feed, so the bias is
|
||||
/// toward "make the file slightly bigger for an audibly cleaner result". Ignored when
|
||||
/// <see cref="FileFormat"/> isn't MP3.</summary>
|
||||
public int Mp3BitrateKbps { get; set; } = 320;
|
||||
|
||||
/// <summary>OGG-Opus VBR target bitrate in kbps. Opus' practical sweet spot for music
|
||||
/// is 96–256 kbps; below 96 starts to introduce audible artefacts on dense material,
|
||||
/// above 256 is diminishing returns. Default 192 — same compromise as the MP3 default
|
||||
/// "noticeably-larger file for noticeably-cleaner result". Ignored when
|
||||
/// <see cref="FileFormat"/> isn't Ogg.</summary>
|
||||
public int OggOpusBitrateKbps { get; set; } = 192;
|
||||
|
||||
/// <summary>FLAC bit depth. 16 or 24 — FLAC is integer-PCM only, no 32-bit float, so
|
||||
/// the WAV "32-bit float" option doesn't carry over. 24-bit matches the wire PCM bit
|
||||
/// depth and is the default. Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
|
||||
public int FlacBitsPerSample { get; set; } = 24;
|
||||
|
||||
/// <summary>FLAC compression level, 0–8. Higher = smaller file, more CPU during encode;
|
||||
/// all levels are losslessly identical on decode. Reference encoder default is 5; we
|
||||
/// match that — the encode is comfortably real-time at level 5 on any modern CPU.
|
||||
/// Ignored when <see cref="FileFormat"/> isn't FLAC.</summary>
|
||||
public int FlacCompressionLevel { get; set; } = 5;
|
||||
|
||||
/// <summary>Absolute path to the folder recordings get written into. Empty / null
|
||||
/// means "use the default <c><exe>\recordings\<machine>\</c>". Persisted
|
||||
/// verbatim — if a saved profile points at a folder that doesn't exist on the loading
|
||||
/// machine, the recorder falls back to the default and notes it in the diagnostics.</summary>
|
||||
public string? Folder { get; set; }
|
||||
|
||||
public RecordingSettings Clone() => new()
|
||||
{
|
||||
Source = Source,
|
||||
FileFormat = FileFormat,
|
||||
ChannelMode = ChannelMode,
|
||||
WavBitsPerSample = WavBitsPerSample,
|
||||
Mp3BitrateKbps = Mp3BitrateKbps,
|
||||
OggOpusBitrateKbps = OggOpusBitrateKbps,
|
||||
FlacBitsPerSample = FlacBitsPerSample,
|
||||
FlacCompressionLevel = FlacCompressionLevel,
|
||||
Folder = Folder,
|
||||
};
|
||||
|
||||
/// <summary>Default folder path used when <see cref="Folder"/> is blank. Computed at
|
||||
/// call time (not cached) so a launch from a different exe directory picks up that
|
||||
/// directory rather than the first-load one. The per-machine subfolder lets two
|
||||
/// machines sharing a Dropbox-backed RemSound install keep their recordings tidily
|
||||
/// separated by sender identity.</summary>
|
||||
public static string DefaultFolder() =>
|
||||
Path.Combine(AppContext.BaseDirectory, "recordings", Environment.MachineName);
|
||||
|
||||
/// <summary>Returns the resolved folder this profile would record into right now —
|
||||
/// either the explicit <see cref="Folder"/> if set, or <see cref="DefaultFolder"/>.
|
||||
/// Does not create the folder on disk.</summary>
|
||||
public string ResolvedFolder() =>
|
||||
string.IsNullOrWhiteSpace(Folder) ? DefaultFolder() : Folder!;
|
||||
}
|
||||
@@ -313,6 +313,79 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
// === Per-cue enable flags (2026-05-15) ===
|
||||
// Each cue sound has its own enable toggle, surfaced in the Preferences dialog as a
|
||||
// CheckedListBox. The legacy MuteConnectionCues above used to gate both connect AND
|
||||
// disconnect — when the new flags are absent (null cache + null profile), the load
|
||||
// helpers fall back to the legacy value as a migration step. Once the user touches
|
||||
// any per-cue toggle, that flag's load returns the explicit value directly and the
|
||||
// legacy field becomes irrelevant for that cue.
|
||||
|
||||
public bool LoadEnableConnectCue()
|
||||
{
|
||||
var s = Load();
|
||||
if (s?.EnableConnectCue is bool v) return v;
|
||||
// Legacy fallback: an older profile with MuteConnectionCues=true was muting both
|
||||
// connect AND disconnect at once. Honour that intent on first load.
|
||||
if (s?.MuteConnectionCues == true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SaveEnableConnectCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableConnectCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableDisconnectCue()
|
||||
{
|
||||
var s = Load();
|
||||
if (s?.EnableDisconnectCue is bool v) return v;
|
||||
if (s?.MuteConnectionCues == true) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SaveEnableDisconnectCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableDisconnectCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableRecordStartCue() =>
|
||||
Try(() => Load()?.EnableRecordStartCue) ?? true;
|
||||
|
||||
public void SaveEnableRecordStartCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableRecordStartCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public bool LoadEnableRecordStopCue() =>
|
||||
Try(() => Load()?.EnableRecordStopCue) ?? true;
|
||||
|
||||
public void SaveEnableRecordStopCue(bool value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.EnableRecordStopCue = value;
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>The whole recording-settings bag for the current profile. Loaded as a
|
||||
/// CLONE so callers can mutate the returned object without inadvertently writing
|
||||
/// back to the cache. Save flushes the object atomically.</summary>
|
||||
public RecordingSettings LoadRecordingSettings() =>
|
||||
(Try(() => Load()?.RecordingSettings) ?? new RecordingSettings()).Clone();
|
||||
|
||||
public void SaveRecordingSettings(RecordingSettings value)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.RecordingSettings = value?.Clone() ?? new RecordingSettings();
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>How aggressively the receiver pulls the playout queue back to the user's
|
||||
/// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change,
|
||||
/// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle
|
||||
@@ -418,6 +491,11 @@ public sealed class RemSoundSettingsStore
|
||||
Smoothness = profile.Smoothness,
|
||||
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
|
||||
MuteConnectionCues = profile.MuteConnectionCues,
|
||||
EnableConnectCue = profile.EnableConnectCue,
|
||||
EnableDisconnectCue = profile.EnableDisconnectCue,
|
||||
EnableRecordStartCue = profile.EnableRecordStartCue,
|
||||
EnableRecordStopCue = profile.EnableRecordStopCue,
|
||||
RecordingSettings = profile.RecordingSettings?.Clone() ?? new RecordingSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -459,6 +537,14 @@ public sealed class RemSoundSettingsStore
|
||||
if (s.Smoothness is int sm) profile.Smoothness = sm;
|
||||
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
|
||||
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
|
||||
// Per-cue enable flags — copy through verbatim (nullable on both sides, so an
|
||||
// unset flag in the cache stays unset on the profile, letting the legacy
|
||||
// MuteConnectionCues path govern that cue on the next load).
|
||||
profile.EnableConnectCue = s.EnableConnectCue;
|
||||
profile.EnableDisconnectCue = s.EnableDisconnectCue;
|
||||
profile.EnableRecordStartCue = s.EnableRecordStartCue;
|
||||
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
||||
if (s.RecordingSettings is RecordingSettings rs) profile.RecordingSettings = rs.Clone();
|
||||
}
|
||||
|
||||
private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new()
|
||||
@@ -513,6 +599,13 @@ public sealed class RemSoundSettingsStore
|
||||
public int? Smoothness { get; set; }
|
||||
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
|
||||
public bool? MuteConnectionCues { get; set; }
|
||||
// Per-cue enable flags (2026-05-15). Nullable so an absent value in the loaded
|
||||
// profile falls back to the legacy MuteConnectionCues migration path.
|
||||
public bool? EnableConnectCue { get; set; }
|
||||
public bool? EnableDisconnectCue { get; set; }
|
||||
public bool? EnableRecordStartCue { get; set; }
|
||||
public bool? EnableRecordStopCue { get; set; }
|
||||
public RecordingSettings? RecordingSettings { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HotkeySetting
|
||||
|
||||
@@ -133,6 +133,19 @@ public sealed class AudioReceiver : IDisposable
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
|
||||
playoutEngine.SetConcealmentArtifact(artifact);
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked when the engine produces fully-processed mixed received
|
||||
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
|
||||
/// float, lives on the render thread — copy or consume quickly. Used by the recorder
|
||||
/// to capture "what we heard". Setter mirrors directly onto <see cref="PlayoutEngine"/>;
|
||||
/// null clears the tap.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnReceivedSamples
|
||||
{
|
||||
get => playoutEngine.OnReceivedSamples;
|
||||
set => playoutEngine.OnReceivedSamples = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set
|
||||
/// to block all (the user has selected no peers); pass null to disable filtering and accept
|
||||
@@ -268,6 +281,24 @@ public sealed class AudioReceiver : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Take the worst post-decode single-sample step magnitude across all active
|
||||
/// stream sessions since the last call, resetting each session's probe. Used by the
|
||||
/// diag log to pinpoint where in the pipeline audio discontinuities are being
|
||||
/// introduced.</summary>
|
||||
public float TakeMaxPostDecodeStep()
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
var max = 0f;
|
||||
foreach (var s in sessions.Values)
|
||||
{
|
||||
var v = s.TakeMaxPostDecodeStep();
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
|
||||
public long PcmFrameDiscardedPartials
|
||||
{
|
||||
get
|
||||
@@ -331,6 +362,26 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
|
||||
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
|
||||
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
|
||||
/// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible
|
||||
/// underrun events that trigger noise-burst concealment + fade-in. Separated from
|
||||
/// <see cref="Underruns"/> (which conflates full and partial short reads) so the diag
|
||||
/// log can show "real underruns this second" distinct from "partial near-misses".</summary>
|
||||
public long ConcealmentFires => playoutEngine.AggregateConcealmentFires;
|
||||
/// <summary>Cumulative count of sub-frame partial reads (0 < framesRead < requested).
|
||||
/// Inaudible since the 2026-05-14 concealment fix but tracked so we can see clock
|
||||
/// in-phase patterns.</summary>
|
||||
public long ShortReadFires => playoutEngine.AggregateShortReadFires;
|
||||
/// <summary>Live LP-filtered drift error of the primary active session (stereo frames,
|
||||
/// signed). Negative = buffer running below target on average; positive = above.</summary>
|
||||
public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames;
|
||||
/// <summary>Live drift integrator accumulator of the primary session. Crosses ±1 to fire
|
||||
/// a drop / repeat correction.</summary>
|
||||
public double DriftAccumulator => playoutEngine.PrimaryDriftAccumulator;
|
||||
/// <summary>Take the worst single-sample step out of the ring buffer (after decode +
|
||||
/// SessionPlayout.Write, before resampler) since the last call.</summary>
|
||||
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
|
||||
/// <summary>Take the worst single-sample step out of the resampler since the last call.</summary>
|
||||
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
|
||||
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
|
||||
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
|
||||
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
|
||||
@@ -371,6 +422,71 @@ public sealed class AudioReceiver : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// === Wire-level packet sequence diagnostics ===
|
||||
// Each audio packet carries a per-session sequence number from the sender. Tracking it
|
||||
// at receipt tells us whether the network or NIC stack between sender and receiver is
|
||||
// reordering, dropping, or duplicating packets — any of which would manifest as audible
|
||||
// pops on the PCM path. On a healthy LAN all four counters should grow as
|
||||
// WireInOrder == packets, all others == 0. A non-zero Missed / Reordered / Duplicated
|
||||
// points straight at transport pathology and rules out codec / playout / hardware as
|
||||
// pop sources.
|
||||
|
||||
/// <summary>Cumulative count of audio packets that arrived with the expected wire sequence.</summary>
|
||||
public long WireInOrderCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.WireInOrderCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of packets that the wire claims went missing (forward gaps).</summary>
|
||||
public long WireMissedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.WireMissedCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of packets that arrived out-of-order (later sequence first, then earlier).</summary>
|
||||
public long WireReorderedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.WireReorderedCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of duplicate-sequence packets (the same wire seq delivered twice).</summary>
|
||||
public long WireDuplicatedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.WireDuplicatedCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; }
|
||||
public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; }
|
||||
|
||||
|
||||
@@ -159,6 +159,25 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked every time the engine produces a buffer of mixed received
|
||||
/// audio (after volume/mute/limiter, before pack-to-bytes). The span is 48 kHz
|
||||
/// interleaved stereo float and lives on the render thread; copy or process quickly.
|
||||
/// Used by the recorder to capture "what we heard". Wired in App; null = no tap.
|
||||
///
|
||||
/// In BothIndependent the callback fires once per lane Read (so the recorder sees two
|
||||
/// streams paced at the per-lane render rates) — the recorder consolidates them on its
|
||||
/// background thread. In classic modes only the main Read path fires.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnReceivedSamples { get; set; }
|
||||
|
||||
private void DispatchReceivedSamples(ReadOnlyMemory<float> samples)
|
||||
{
|
||||
var cb = OnReceivedSamples;
|
||||
if (cb is null) return;
|
||||
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only
|
||||
/// used in BothIndependent mode where the WASAPI render backend reads its own lane
|
||||
@@ -378,6 +397,93 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of full-empty reads (framesRead == 0) across all sessions.
|
||||
/// These are the audible underrun events that trigger noise-burst concealment + fade-in
|
||||
/// on the next read. Split out from <see cref="AggregateUnderruns"/> (which conflates
|
||||
/// full and partial short reads) so diagnostic code can tell "the buffer fully emptied"
|
||||
/// from "the buffer was a frame or two short of the request".</summary>
|
||||
public long AggregateConcealmentFires
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.ConcealmentFiresTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of sub-frame partial reads (0 < framesRead < requested)
|
||||
/// across all sessions. Since the 2026-05-14 concealment fix these are no longer audible
|
||||
/// events — the ring buffer's zero-fill tail is left in place rather than running cosine
|
||||
/// fade-in on the next read — but counting them is useful for diagnosis. A high rate
|
||||
/// indicates sender/receiver clocks are running so close in-phase that the ring buffer
|
||||
/// occasionally returns N-1 of N requested frames.</summary>
|
||||
public long AggregateShortReadFires
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.PartialReadFiresTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Live state — the LP-filtered drift error in stereo frames of the FIRST
|
||||
/// active session in the snapshot. Most setups have exactly one session, in which case
|
||||
/// this is exactly that session's value. Positive = buffer running above target on
|
||||
/// average; negative = buffer below target. 0 if no sessions exist.</summary>
|
||||
public double PrimaryFilteredDriftErrorFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
return snap.Length > 0 ? snap[0].FilteredDriftErrorFrames : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Live state — the drift integrator accumulator of the first active session.
|
||||
/// Crosses ±1 to fire a single-frame drop / repeat. Useful for "is the corrector about
|
||||
/// to fire?" diagnosis.</summary>
|
||||
public double PrimaryDriftAccumulator
|
||||
{
|
||||
get
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
return snap.Length > 0 ? snap[0].DriftAccumulator : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Worst single-sample step seen out of the ring buffer since the last call.
|
||||
/// Compared against the sender's pre-encode probe and the session's post-resampler
|
||||
/// probe, this locates where in the pipeline an audio discontinuity was introduced.
|
||||
/// Takes the max across all sessions and resets each.</summary>
|
||||
public float TakeMaxPostRingReadStep()
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
var max = 0f;
|
||||
foreach (var s in snap)
|
||||
{
|
||||
var v = s.TakeMaxPostRingReadStep();
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/// <summary>Worst single-sample step in the resampler output since the last call.
|
||||
/// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the
|
||||
/// finger at the resampler integration.</summary>
|
||||
public float TakeMaxPostResamplerStep()
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
var max = 0f;
|
||||
foreach (var s in snap)
|
||||
{
|
||||
var v = s.TakeMaxPostResamplerStep();
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
// === WASAPI render thread ===
|
||||
|
||||
/// <summary>
|
||||
@@ -478,6 +584,13 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
||||
|
||||
// Recording tap (per-lane). Mix is fully processed at this point — volume, mute and
|
||||
// limiter have all been applied — so the recorder sees exactly what the user is
|
||||
// about to hear from this lane. The tap fires regardless of whether the lane has an
|
||||
// attached output device; that way a recording set to "received only" still captures
|
||||
// audio in setups where the user has no WASAPI outputs ticked.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
}
|
||||
@@ -546,6 +659,13 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
||||
|
||||
// Recording tap (all-sessions path, classic modes / WasapiOnly). Same point in the
|
||||
// pipeline as the lane-routed tap above — fully processed mix, just before the
|
||||
// pack-to-bytes step. The recorder gets a clean copy of what the user is about
|
||||
// to hear.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
}
|
||||
@@ -606,6 +726,13 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
}
|
||||
|
||||
public int Read(byte[] buffer, int offset, int count) =>
|
||||
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false);
|
||||
// recordDiagnostics: true so the diag log line in MainForm gets buffer-level
|
||||
// and render-read samples in BothIndependent mode. Originally false to avoid
|
||||
// double-counting when both lanes ran concurrently, but in practice only one
|
||||
// lane has sessions at a time (the user's chosen capture path) — the other
|
||||
// lane's Read returns zero-mix and contributes nothing meaningful to the diag
|
||||
// numbers. Without this the diag line never fires in BothIndependent setups,
|
||||
// which is the user's normal mode of operation. 2026-05-14.
|
||||
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.Dsp;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
@@ -27,9 +29,6 @@ internal sealed class SessionPlayout : IDisposable
|
||||
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
|
||||
|
||||
private readonly AudioRingBuffer playout;
|
||||
// Scratch buffer used by the drift-correction crossfade path. Sized as needed inside
|
||||
// ReadFloats; persistent here so we don't reallocate per call.
|
||||
private float[] driftScratch = new float[8192];
|
||||
|
||||
private volatile bool playbackArmed;
|
||||
private volatile bool drainRequested;
|
||||
@@ -90,88 +89,163 @@ internal sealed class SessionPlayout : IDisposable
|
||||
// gets a different sequence — but we don't care about reproducibility, just character.
|
||||
private readonly Random concealRng = new(Random.Shared.Next());
|
||||
|
||||
// === Drift correction (Phase 2, 2026-05-06) ===
|
||||
// Continuous low-rate clock-drift correction. The receiver and sender each have their own
|
||||
// audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB
|
||||
// audio). Without correction, the playout buffer slowly drifts up (sender faster) or down
|
||||
// (sender slower) and eventually clicks via either overflow or underrun.
|
||||
// === Drift correction (Phase 4, 2026-05-14) — fixed-ratio resampler ===
|
||||
//
|
||||
// The previous design corrected via a continuously-modulated WdlResampler — which produced
|
||||
// sample-level corruption and was the source of all the per-sample artefacts we hunted for
|
||||
// weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern:
|
||||
// **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO
|
||||
// FRAME at a time when the integrator signals sustained drift.** A single-frame drop or
|
||||
// repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal
|
||||
// content, especially when timed by an integrator that fires only on sustained drift, not
|
||||
// on packet-arrival jitter.
|
||||
// Replaces the Phase-2/3 discrete-splice drift corrector. Sender and receiver each have
|
||||
// their own free-running audio crystal and the two rates differ by a few-tens to a few-
|
||||
// hundreds of ppm. Without compensation the receive ring buffer slowly drifts up
|
||||
// (sender faster) or down (sender slower) and eventually clicks via either overflow or
|
||||
// underrun.
|
||||
//
|
||||
// Mechanism per Read:
|
||||
// 1. Sample the current buffer level vs target.
|
||||
// 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator.
|
||||
// 3. If accumulator >= 1, drop one frame from the head of the playout buffer
|
||||
// (sender faster — we've consumed less than it produced; speed up consumption by
|
||||
// one frame). Decrement accumulator.
|
||||
// 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower —
|
||||
// stall consumption by one frame). Increment accumulator.
|
||||
// Discrete single-frame drop / repeat splices — even with cosine crossfade and adaptive-
|
||||
// gain integrator scheduling — are audible at any drift rate above roughly 1 correction
|
||||
// per second on tonal content. Ed reported them as a continuous train of "tiny pops" all
|
||||
// through this session. The instrumentation added 2026-05-14 confirmed the corrector
|
||||
// was firing at 4-13 repeats/sec for his Audient EVO4 ↔ EVO8 setup (~150-200 ppm drift).
|
||||
//
|
||||
// Behaviour by drift rate:
|
||||
// - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0,
|
||||
// no corrections fire. Silent.
|
||||
// - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator
|
||||
// grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio
|
||||
// dropped or repeated every ~4 seconds. Inaudible.
|
||||
// - Higher transient drift (e.g. system load briefly): integrator catches up within
|
||||
// seconds, brief burst of corrections, then settles. Still inaudible.
|
||||
// The fixed-ratio resampler approach is what every serious networked-audio implementation
|
||||
// (Jamulus, SonoBus, Dante) uses for the same problem:
|
||||
// 1. Measure the sender's effective sample rate by comparing bytes written to the ring
|
||||
// vs bytes output to the audio device over a long window (multi-second).
|
||||
// 2. Configure a WdlResampler with input rate = measured-sender-rate and output rate =
|
||||
// MixSampleRate. The resampler continuously stretches or compresses the incoming
|
||||
// stream by the necessary ppm.
|
||||
// 3. Update the resampler's input rate ONLY every DriftMeasurementWindowSec, smoothed
|
||||
// heavily. The earlier doomed attempt (May) modulated rates per-sample based on
|
||||
// instantaneous buffer level; that caused phase discontinuities and sample-level
|
||||
// artefacts. Slow updates avoid that entirely.
|
||||
//
|
||||
// The existing click-trim block above is kept as a safety net for catastrophic conditions
|
||||
// (large step changes that the slow integrator can't keep up with). At normal drift rates
|
||||
// the integrator never lets the buffer reach the click-trim threshold, so the trim should
|
||||
// effectively never fire in steady-state operation.
|
||||
private double driftAccumulatorFrames;
|
||||
// Pitch shift introduced by a fixed-ratio resampler at, say, 200 ppm is 0.02 % — far
|
||||
// below the ~5 % human pitch-discrimination threshold and even below tuning precision.
|
||||
// Genuinely inaudible.
|
||||
//
|
||||
// Safety net: the legacy click-trim block above stays in place and fires only at
|
||||
// catastrophic buffer levels (target + ~23 ms or 1 second worst-case cap). The discrete
|
||||
// splice corrector (drop / repeat with crossfade) is GONE — the resampler handles
|
||||
// steady-state drift smoothly. If the resampler somehow can't keep up (transient
|
||||
// catastrophe), the click-trim safety net fires once.
|
||||
//
|
||||
// The resampler. Output-driven (we want N output frames; ask the resampler how many
|
||||
// input frames it needs and feed those in from the ring buffer). interp=true with
|
||||
// filtercnt=0 selects WdlResampler's low-cost linear-interpolation mode — plenty good
|
||||
// enough for the ppm-scale rate corrections we apply. Higher filter modes would add
|
||||
// CPU cost for a sample quality difference well below audibility at these tiny ratios.
|
||||
// SetRates is called periodically from the audio thread (the only thread that touches
|
||||
// this resampler) so we don't need any cross-thread synchronisation around it.
|
||||
private readonly WdlResampler driftResampler;
|
||||
|
||||
// Drift measurement counters. bytesWrittenForDriftEst is incremented by the producer
|
||||
// thread on every Write; bytesReadOutputForDriftEst is incremented by the consumer
|
||||
// thread (us) on every successful ReadFloats. Their ratio over a multi-second window
|
||||
// is the sender's effective rate divided by the receiver's nominal rate — exactly the
|
||||
// ratio the resampler needs.
|
||||
private long bytesWrittenForDriftEst;
|
||||
private long bytesReadOutputForDriftEst;
|
||||
|
||||
// Window state. windowStartTicks = when the current measurement window started, ticks
|
||||
// 0 means "not yet armed for measurement". The values at window start are snapshotted
|
||||
// so we can compute the delta cleanly even if the counters wrap (long is 63-bit so wrap
|
||||
// is ~190 years at 48 kHz stereo float, but the math is still cleaner with snapshots).
|
||||
private long resamplerWindowStartTicks;
|
||||
private long resamplerWindowStartBytesWritten;
|
||||
private long resamplerWindowStartBytesOutput;
|
||||
|
||||
// Smoothed ratio currently applied to the resampler (1.0 = no resampling). Smoothing
|
||||
// is "first measurement = the measurement; subsequent = 70 % previous + 30 % new" so
|
||||
// a one-window outlier doesn't yank the rate. Settles within a few windows to the
|
||||
// true drift.
|
||||
private double smoothedRateRatio = 1.0;
|
||||
private bool resamplerActivelyTracking;
|
||||
private long resamplerUpdatesTotal;
|
||||
|
||||
// Scratch buffer for reading from the ring buffer in float form. Sized lazily based on
|
||||
// the largest input-frames request the resampler asks for; persists across calls so
|
||||
// we don't realloc on the hot path.
|
||||
private float[] resamplerInputScratch = new float[2048];
|
||||
|
||||
// Retained for backward compatibility with the diagnostic surface — the diag log line
|
||||
// still emits driftDrop / driftRep counters and the DriftAccumulator / FilteredError
|
||||
// accessors. In the Phase-4 design these are all just informational metrics that stay
|
||||
// at zero / track the same buffer-vs-target offset, but old log parsers don't break.
|
||||
// Explicit zero init so the compiler doesn't flag them as never-assigned when the
|
||||
// Phase-4 design no longer increments them anywhere.
|
||||
private long driftDropFramesTotal = 0;
|
||||
private long driftRepeatFramesTotal = 0;
|
||||
// Live state for the diag log — the current buffer-level offset from target, low-pass
|
||||
// filtered. Lets the diag line continue to surface "where the buffer is sitting".
|
||||
// Updated each Read; no longer drives any correction logic itself.
|
||||
private double filteredErrorFrames;
|
||||
private long prevDriftSampleTicks;
|
||||
private int pendingRepeatFrames;
|
||||
private long driftDropFramesTotal;
|
||||
private long driftRepeatFramesTotal;
|
||||
// Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous
|
||||
// gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running
|
||||
// USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with
|
||||
// single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades
|
||||
// click rate for buffer drift; combined with the crossfade-on-splice change, each
|
||||
// correction is also significantly less audible per event.
|
||||
//
|
||||
// At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm
|
||||
// drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec
|
||||
// steady-state — which combined with crossfaded splices should push perceived click rate
|
||||
// toward inaudible.
|
||||
//
|
||||
// 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady-
|
||||
// state clock-drift compensation but pathologically slow when the buffer is far from
|
||||
// target — e.g. after a slider raise the buffer sits below target and drift correction
|
||||
// takes minutes to fill it. Empirically observed in user testing as "every session sounds
|
||||
// different": the buffer wandered for tens of seconds at whatever level the initial
|
||||
// arming chaos left it at. Now the effective gain scales linearly with absolute error
|
||||
// beyond the small-error band, capped, so:
|
||||
// * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle)
|
||||
// * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale)
|
||||
// At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving
|
||||
// a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with
|
||||
// a barely-audible 0.2% rate offset during the fill.
|
||||
private const double DriftGain = 0.005;
|
||||
// Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz.
|
||||
private const double DriftSmallErrorFrames = 50;
|
||||
// Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch
|
||||
// (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content).
|
||||
private const double DriftMaxGainScale = 20.0;
|
||||
// Drift-measurement window for the fixed-ratio resampler. After this many seconds of
|
||||
// sustained streaming, we compute (bytes_written / bytes_output) over the window and
|
||||
// smooth-update the resampler's input rate. Long enough that brief network jitter or
|
||||
// GC pauses don't bias the measurement; short enough to track temperature-induced
|
||||
// crystal-rate changes (USB audio clocks can drift several ppm over minutes as the
|
||||
// device warms up). 10 sec is the canonical Jamulus / SonoBus value.
|
||||
private const double DriftMeasurementWindowSec = 10.0;
|
||||
// First-window length. Same as DriftMeasurementWindowSec for simplicity; could be
|
||||
// shortened to engage compensation faster after session start at the cost of a noisier
|
||||
// initial measurement.
|
||||
private const double DriftFirstWindowSec = 10.0;
|
||||
// Ratio smoothing weight. New measurement = 30 %; previous smoothed = 70 %. Tunes how
|
||||
// quickly the rate tracks vs how stable it is. The first measurement after session
|
||||
// start uses 100 % new (no previous value to weight).
|
||||
private const double DriftRatioSmoothingNew = 0.30;
|
||||
// Sanity-range clamp on the measured ratio. Real clock differences between USB audio
|
||||
// crystals are sub-1000 ppm (0.1 %); anything beyond ±5 % indicates a measurement
|
||||
// artefact (a buffer-fill burst, a transient, or a counter wrap). Reject those and
|
||||
// keep the previous ratio.
|
||||
private const double DriftRatioMin = 0.95;
|
||||
private const double DriftRatioMax = 1.05;
|
||||
// Low-pass filter time constant for the buffer-level-error display in the diag log.
|
||||
// Doesn't affect any correction logic in Phase 4 — purely informational.
|
||||
private const double DriftFilterTimeConstantSec = 2.0;
|
||||
// Number of stereo frames each side of a splice point that get blended when a drop or
|
||||
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
|
||||
// characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz —
|
||||
// shorter than a typical impulse response, so the smear doesn't blur transients audibly.
|
||||
private const int DriftCrossfadeFrames = 8;
|
||||
// Pending corrections (sample-aligned single-frame edits at the next Read).
|
||||
private int pendingDropFrames;
|
||||
// Public accessors for the diag log.
|
||||
// Public accessors for the diag log. Drop / repeat counters are retained for the diag
|
||||
// surface (the Phase-4 resampler doesn't increment them, so they stay flat at the
|
||||
// last value from any pre-Phase-4 fallback path — informationally that's "the splice
|
||||
// path didn't fire", which is what we want to see now).
|
||||
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
|
||||
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
|
||||
/// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the
|
||||
/// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running
|
||||
/// 200 ppm faster than receiver; 0.9998 = 200 ppm slower.</summary>
|
||||
public double DriftResamplerRatio => smoothedRateRatio;
|
||||
/// <summary>Number of times the resampler rate has been updated since session start.</summary>
|
||||
public long DriftResamplerUpdates => Interlocked.Read(ref resamplerUpdatesTotal);
|
||||
|
||||
// Per-stage discontinuity probes — the receiver-side instrumentation that, combined
|
||||
// with the sender's pre-encode probe and the StreamSession's post-decode probe,
|
||||
// localises exactly where in the pipeline a click is introduced. PostRingRead is
|
||||
// what came out of the ring buffer (after wire+decode+ring). PostResampler is what
|
||||
// came out of the resampler (after rate compensation).
|
||||
private readonly AudioStepProbe postRingReadStepProbe = new();
|
||||
private readonly AudioStepProbe postResamplerStepProbe = new();
|
||||
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax();
|
||||
public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax();
|
||||
|
||||
// Concealment vs partial-read counters split from the legacy "Underruns" — that one
|
||||
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
|
||||
// or framesRead<requested). For diagnosis we care about the split: full-empty reads
|
||||
// (concealmentFiresTotal) are audible events that trigger noise-burst + fade-in;
|
||||
// sub-frame partial reads (partialReadFiresTotal) used to be audible too but are
|
||||
// now silent after the 2026-05-14 fix that stops concealment from engaging on partials.
|
||||
private long concealmentFiresTotal;
|
||||
private long partialReadFiresTotal;
|
||||
public long ConcealmentFiresTotal => Interlocked.Read(ref concealmentFiresTotal);
|
||||
public long PartialReadFiresTotal => Interlocked.Read(ref partialReadFiresTotal);
|
||||
/// <summary>Live state — the LP-filtered drift error in stereo frames. Positive = buffer
|
||||
/// running above target on average (sender clock faster); negative = buffer below
|
||||
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
|
||||
public double FilteredDriftErrorFrames => filteredErrorFrames;
|
||||
/// <summary>Legacy diag accessor — the Phase-2 / Phase-3 integrator accumulator is no
|
||||
/// longer used in the Phase-4 resampler design. Always returns 0. Kept on the surface
|
||||
/// so MainForm's existing diag log line still compiles; can be removed once the diag
|
||||
/// columns are pruned.</summary>
|
||||
public double DriftAccumulator => 0.0;
|
||||
|
||||
public IPEndPoint Endpoint { get; }
|
||||
/// <summary>The stream ID this session was opened for. Sessions are keyed by
|
||||
@@ -216,6 +290,18 @@ internal sealed class SessionPlayout : IDisposable
|
||||
Endpoint = endpoint;
|
||||
StreamId = streamId;
|
||||
playout = new AudioRingBuffer(capacityBytes);
|
||||
|
||||
// Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation
|
||||
// mode — perfectly adequate for the sub-1000-ppm rate corrections we apply (the
|
||||
// higher-cost sinc modes would buy theoretical quality wins below human audibility).
|
||||
// sinc=false confirms we're not using the sinc-table mode. Output-driven feed: each
|
||||
// ResamplePrepare call asks the resampler "how many input frames do you need for N
|
||||
// output frames" and we satisfy from the ring buffer. SetRates(in, out) starts at
|
||||
// 1:1; we update with measured drift after the first window completes.
|
||||
driftResampler = new WdlResampler();
|
||||
driftResampler.SetMode(interp: true, filtercnt: 0, sinc: false);
|
||||
driftResampler.SetFeedMode(false);
|
||||
driftResampler.SetRates(MixSampleRate, MixSampleRate);
|
||||
}
|
||||
|
||||
public void Write(ReadOnlySpan<byte> source)
|
||||
@@ -223,6 +309,10 @@ internal sealed class SessionPlayout : IDisposable
|
||||
var ms = source.Length * 1000 / MixBytesPerSecond;
|
||||
if (ms > largestWriteMs) largestWriteMs = ms;
|
||||
playout.Write(source);
|
||||
// Track bytes written for the drift-resampler measurement window. Producer thread
|
||||
// updates this; consumer thread (audio thread in ReadFloats) reads it via
|
||||
// Interlocked.Read when sampling the window. Cumulative since session start.
|
||||
Interlocked.Add(ref bytesWrittenForDriftEst, source.Length);
|
||||
LastWriteUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -276,10 +366,21 @@ internal sealed class SessionPlayout : IDisposable
|
||||
consecutiveEmptyReads = 0;
|
||||
lastConcealSampleL = 0f;
|
||||
lastConcealSampleR = 0f;
|
||||
driftAccumulatorFrames = 0;
|
||||
filteredErrorFrames = 0;
|
||||
prevDriftSampleTicks = 0;
|
||||
pendingDropFrames = 0;
|
||||
pendingRepeatFrames = 0;
|
||||
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
|
||||
// resampler clears its internal filter delay line so a fresh session doesn't
|
||||
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
|
||||
// from scratch.
|
||||
bytesWrittenForDriftEst = 0;
|
||||
bytesReadOutputForDriftEst = 0;
|
||||
resamplerWindowStartTicks = 0;
|
||||
resamplerWindowStartBytesWritten = 0;
|
||||
resamplerWindowStartBytesOutput = 0;
|
||||
smoothedRateRatio = 1.0;
|
||||
resamplerActivelyTracking = false;
|
||||
driftResampler.Reset();
|
||||
driftResampler.SetRates(MixSampleRate, MixSampleRate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -424,269 +525,255 @@ internal sealed class SessionPlayout : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// === Drift correction (Phase 2) ===
|
||||
// === Drift compensation (Phase 4) — fixed-ratio resampler ===
|
||||
//
|
||||
// Continuously integrate buffer-level error and drop / repeat single frames at low
|
||||
// rate to keep buffer aligned with target despite clock-drift between sender and
|
||||
// receiver crystals. Replaces the continuous adaptive resampling that produced
|
||||
// sample-level artefacts (analysed 2026-05-06). See the field-block comment above
|
||||
// for the design rationale.
|
||||
// 1. Update the LP-filtered buffer-level error for the diag log (informational only).
|
||||
// 2. Update the resampler's rate ratio if the measurement window has elapsed.
|
||||
// 3. Read through the resampler into the caller's output span.
|
||||
//
|
||||
// SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already
|
||||
// be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g.
|
||||
// one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on
|
||||
// the capture side so the wire format is consistent — but if a future change emits
|
||||
// at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate /
|
||||
// 48000, computed once, never modulated). The continuous-modulation pattern was the
|
||||
// bug; a fixed ratio is fine.
|
||||
var driftTicks = Stopwatch.GetTimestamp();
|
||||
// The buffer-level error LP filter no longer drives any correction — that job is
|
||||
// now the resampler's. It's kept purely as a diag display so the log shows where
|
||||
// the buffer is sitting.
|
||||
var nowTicks = Stopwatch.GetTimestamp();
|
||||
var driftTargetBytes = MillisecondsToBytes(targetLatencyMs);
|
||||
if (prevDriftSampleTicks != 0)
|
||||
{
|
||||
var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
|
||||
var dtSec = (nowTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
|
||||
var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame;
|
||||
// Adaptive gain: baseline at small errors (gentle steady-state compensation for
|
||||
// clock drift) but accelerated at large errors (fast convergence after a slider
|
||||
// raise or initial arming overshoot). Without this, the buffer can sit at any
|
||||
// level between 0 and target+jitter for tens of seconds — making sessions feel
|
||||
// randomly different. With this, the buffer reliably reaches target within a few
|
||||
// seconds of any disturbance.
|
||||
var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames;
|
||||
var gainScale = absErrorFrames <= DriftSmallErrorFrames
|
||||
? 1.0
|
||||
: Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale);
|
||||
driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale;
|
||||
// Clamp to prevent runaway in pathological conditions (e.g. session pause).
|
||||
if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0;
|
||||
else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0;
|
||||
var filterAlpha = dtSec / (DriftFilterTimeConstantSec + dtSec);
|
||||
filteredErrorFrames = (1.0 - filterAlpha) * filteredErrorFrames + filterAlpha * errorFrames;
|
||||
}
|
||||
prevDriftSampleTicks = driftTicks;
|
||||
prevDriftSampleTicks = nowTicks;
|
||||
|
||||
// Queue at most one correction per Read so corrections spread evenly rather than burst.
|
||||
if (driftAccumulatorFrames >= 1.0)
|
||||
{
|
||||
pendingDropFrames++;
|
||||
driftAccumulatorFrames -= 1.0;
|
||||
}
|
||||
else if (driftAccumulatorFrames <= -1.0)
|
||||
{
|
||||
pendingRepeatFrames++;
|
||||
driftAccumulatorFrames += 1.0;
|
||||
}
|
||||
UpdateDriftResamplerRateIfDue(nowTicks);
|
||||
|
||||
// === Read with optional crossfaded drop / repeat ===
|
||||
//
|
||||
// The trick to audibly-clean drift correction: don't perform the splice as a hard
|
||||
// cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then
|
||||
// CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine
|
||||
// window blends the audio either side of the splice into a smooth smear instead of
|
||||
// a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than
|
||||
// any audible transient and far less perceptible than the original sample-level
|
||||
// discontinuity.
|
||||
//
|
||||
// Splice position: middle of the output buffer. Could choose a low-amplitude moment
|
||||
// for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on
|
||||
// typical content and keeps the code simple.
|
||||
var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
|
||||
var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
|
||||
// Don't try to do both in the same Read; they'd cancel anyway.
|
||||
if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; }
|
||||
|
||||
if (dropThisCall > 0)
|
||||
{
|
||||
// Read outFrames + 1 frames into the output span by reading the first half,
|
||||
// skipping the splice with crossfade, then reading the second half. We need
|
||||
// a small extra-sample scratch for the splice. Reuse driftScratch as
|
||||
// temp storage (it's already managed and grows with outFrames).
|
||||
var extraFloats = (outFrames + 1) * MixChannels;
|
||||
if (driftScratch.Length < extraFloats)
|
||||
{
|
||||
driftScratch = new float[extraFloats];
|
||||
}
|
||||
var temp = driftScratch.AsSpan(0, extraFloats);
|
||||
ReadInputWithConcealment(temp);
|
||||
// Crossfade the splice. Splice position = midpoint of the output frame.
|
||||
// Result: outFrames samples where one is "elided" via a cosine cross-blend.
|
||||
ApplyDropCrossfade(temp, output, outFrames);
|
||||
pendingDropFrames--;
|
||||
Interlocked.Increment(ref driftDropFramesTotal);
|
||||
}
|
||||
else if (repeatThisCall > 0)
|
||||
{
|
||||
// Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded
|
||||
// insertion at the splice point.
|
||||
var shortFloats = (outFrames - 1) * MixChannels;
|
||||
if (driftScratch.Length < shortFloats)
|
||||
{
|
||||
driftScratch = new float[shortFloats];
|
||||
}
|
||||
var temp = driftScratch.AsSpan(0, shortFloats);
|
||||
ReadInputWithConcealment(temp);
|
||||
ApplyRepeatCrossfade(temp, output, outFrames);
|
||||
pendingRepeatFrames--;
|
||||
Interlocked.Increment(ref driftRepeatFramesTotal);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadInputWithConcealment(output);
|
||||
}
|
||||
// Read through the resampler and apply concealment on full underruns.
|
||||
ReadThroughResampler(output, outFrames);
|
||||
return outFrames;
|
||||
}
|
||||
|
||||
/// <summary>Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames
|
||||
/// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames
|
||||
/// samples on each side.</summary>
|
||||
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
|
||||
/// <summary>
|
||||
/// If the current drift-measurement window has expired, compute the new sender-to-
|
||||
/// receiver rate ratio from the bytes-written and bytes-output counters, smooth it
|
||||
/// into the live ratio, and push it to the resampler. Called from the audio thread
|
||||
/// on every ReadFloats. No-op if the window hasn't elapsed yet.
|
||||
/// </summary>
|
||||
private void UpdateDriftResamplerRateIfDue(long nowTicks)
|
||||
{
|
||||
// Splice at midpoint of output frames. The "skipped" sample in temp lives at index
|
||||
// spliceIdx; either side of it gets cross-blended.
|
||||
var spliceIdx = outFrames / 2;
|
||||
var window = DriftCrossfadeFrames;
|
||||
var halfWindow = window / 2;
|
||||
|
||||
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
|
||||
var preEnd = spliceIdx - halfWindow;
|
||||
if (preEnd > 0)
|
||||
if (resamplerWindowStartTicks == 0)
|
||||
{
|
||||
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
|
||||
// First call — anchor the measurement window. Defer the first rate update by
|
||||
// DriftFirstWindowSec so we get a stable initial measurement rather than one
|
||||
// based on the first few writes (which can be bursty during session arming).
|
||||
resamplerWindowStartTicks = nowTicks;
|
||||
resamplerWindowStartBytesWritten = Interlocked.Read(ref bytesWrittenForDriftEst);
|
||||
resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
|
||||
return;
|
||||
}
|
||||
|
||||
// Window: cosine crossfade. As we walk through `window` output frames, blend from
|
||||
// temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the
|
||||
// "after-skip" sample). The blend mixes consecutive temp positions so the splice
|
||||
// is spread out smoothly.
|
||||
for (var k = 0; k < window; k++)
|
||||
var windowDuration = resamplerActivelyTracking ? DriftMeasurementWindowSec : DriftFirstWindowSec;
|
||||
var elapsedSec = (nowTicks - resamplerWindowStartTicks) / (double)Stopwatch.Frequency;
|
||||
if (elapsedSec < windowDuration) return;
|
||||
|
||||
var bytesWrittenNow = Interlocked.Read(ref bytesWrittenForDriftEst);
|
||||
var bytesWrittenInWindow = bytesWrittenNow - resamplerWindowStartBytesWritten;
|
||||
var bytesOutputInWindow = bytesReadOutputForDriftEst - resamplerWindowStartBytesOutput;
|
||||
|
||||
if (bytesOutputInWindow > 0 && bytesWrittenInWindow > 0)
|
||||
{
|
||||
var t = (k + 1) / (double)(window + 1);
|
||||
// Cosine-shaped smooth fade from 0 to 1 across the window.
|
||||
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
|
||||
var fadeOut = 1f - fadeIn;
|
||||
var beforeIdx = (preEnd + k) * MixChannels;
|
||||
var afterIdx = (preEnd + 1 + k) * MixChannels;
|
||||
var dstIdx = (preEnd + k) * MixChannels;
|
||||
output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn;
|
||||
output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn;
|
||||
// ratio = bytes_sender_produced / bytes_receiver_consumed over the window.
|
||||
// Above 1.0 = sender clock faster than receiver. Below 1.0 = sender slower.
|
||||
// For Ed's hardware (sender slower than receiver) this should settle ~0.9998.
|
||||
var measuredRatio = (double)bytesWrittenInWindow / bytesOutputInWindow;
|
||||
if (measuredRatio >= DriftRatioMin && measuredRatio <= DriftRatioMax)
|
||||
{
|
||||
if (!resamplerActivelyTracking)
|
||||
{
|
||||
// First measurement — use directly. No previous value to weight.
|
||||
smoothedRateRatio = measuredRatio;
|
||||
resamplerActivelyTracking = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent — smooth so a one-window outlier doesn't yank the rate.
|
||||
smoothedRateRatio = (1.0 - DriftRatioSmoothingNew) * smoothedRateRatio + DriftRatioSmoothingNew * measuredRatio;
|
||||
}
|
||||
// Push to the resampler. SetRates(input_rate, output_rate). Input rate
|
||||
// = measured sender rate; output rate = the receiver's nominal MixSampleRate.
|
||||
// The resampler now stretches or compresses incoming audio by the ppm
|
||||
// necessary to keep the playout ring buffer level constant.
|
||||
driftResampler.SetRates(MixSampleRate * smoothedRateRatio, MixSampleRate);
|
||||
Interlocked.Increment(ref resamplerUpdatesTotal);
|
||||
}
|
||||
// If the measured ratio is outside the sanity window (>5 % off), reject it.
|
||||
// That happens transiently during session arming, slider raises, or sender
|
||||
// start-of-stream bursts. Keep the previous ratio rather than yanking.
|
||||
}
|
||||
|
||||
// Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames].
|
||||
// The "+1" on the source side is the elision: we skip one frame from temp.
|
||||
var postStartTemp = spliceIdx + halfWindow + 1;
|
||||
var postStartOut = spliceIdx + halfWindow;
|
||||
var postLen = outFrames - postStartOut;
|
||||
if (postLen > 0)
|
||||
{
|
||||
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
|
||||
.CopyTo(output.Slice(postStartOut * MixChannels));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames
|
||||
/// with one synthesised at the splice via a cosine blend that "stretches" temp by one
|
||||
/// frame.</summary>
|
||||
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
|
||||
{
|
||||
var spliceIdx = outFrames / 2;
|
||||
var window = DriftCrossfadeFrames;
|
||||
var halfWindow = window / 2;
|
||||
|
||||
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
|
||||
var preEnd = spliceIdx - halfWindow;
|
||||
if (preEnd > 0)
|
||||
{
|
||||
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
|
||||
}
|
||||
|
||||
// Window of (window + 1) output frames mapped to (window) temp frames. Cosine
|
||||
// crossfade synthesizes the extra frame: each output sample in the window is a
|
||||
// blend of two adjacent temp samples, with the blend weight progressing slower than
|
||||
// the index, effectively inserting a "smoothed" extra sample.
|
||||
for (var k = 0; k <= window; k++)
|
||||
{
|
||||
var t = k / (double)(window + 1);
|
||||
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
|
||||
var fadeOut = 1f - fadeIn;
|
||||
// Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k].
|
||||
// For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone.
|
||||
var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels;
|
||||
var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels;
|
||||
var dstIdx = (preEnd + k) * MixChannels;
|
||||
output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn;
|
||||
output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn;
|
||||
}
|
||||
|
||||
// Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames].
|
||||
var postStartTemp = spliceIdx + halfWindow;
|
||||
var postStartOut = spliceIdx + halfWindow + 1;
|
||||
var postLen = outFrames - postStartOut;
|
||||
if (postLen > 0)
|
||||
{
|
||||
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
|
||||
.CopyTo(output.Slice(postStartOut * MixChannels));
|
||||
}
|
||||
// Anchor the next window.
|
||||
resamplerWindowStartTicks = nowTicks;
|
||||
resamplerWindowStartBytesWritten = bytesWrittenNow;
|
||||
resamplerWindowStartBytesOutput = bytesReadOutputForDriftEst;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment.
|
||||
/// On a short read, replaces the silence-filled tail with a brief synthesised burst
|
||||
/// (character chosen by <see cref="SetConcealmentArtifact"/>) decaying to zero. On the
|
||||
/// next full read after a gap, applies a matching fade-in so the resumed audio doesn't
|
||||
/// start with a hard discontinuity. The result is a smooth attack-and-release at the
|
||||
/// edges of any gap — the human ear is much more forgiving of "dipped briefly then came
|
||||
/// back" than of "instant click into silence and instant click back".
|
||||
///
|
||||
/// Stereo-only (matches the rest of the audio path). Output flows through the mix bus
|
||||
/// and limiter as usual.
|
||||
/// Resampler-backed read. Asks the resampler how many input frames it needs to
|
||||
/// produce <paramref name="outFrames"/> output frames at the current rate ratio,
|
||||
/// reads that many from the playout ring, runs ResampleOut, and copies the result
|
||||
/// into <paramref name="output"/> with a safety clamp to [-1, 1]. Handles full-empty
|
||||
/// underruns with the existing concealment fade-out / fade-in machinery.
|
||||
/// </summary>
|
||||
private void ReadInputWithConcealment(Span<float> inSpan)
|
||||
private void ReadThroughResampler(Span<float> output, int outFrames)
|
||||
{
|
||||
var requestedFloats = inSpan.Length;
|
||||
var floatsRead = playout.ReadFloats(inSpan);
|
||||
var requestedFrames = requestedFloats / MixChannels;
|
||||
var framesRead = floatsRead / MixChannels;
|
||||
var outFloats = outFrames * MixChannels;
|
||||
var inputFramesNeeded = driftResampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
|
||||
lastInputFramesAvailable = inputFramesNeeded;
|
||||
if (inputFramesNeeded <= 0)
|
||||
{
|
||||
// Resampler doesn't need any input this call (its internal filter delay line
|
||||
// has enough). Just produce output from buffered state.
|
||||
ResampleOutAndCopy(output, outFrames);
|
||||
bytesReadOutputForDriftEst += outFloats * sizeof(float);
|
||||
return;
|
||||
}
|
||||
|
||||
var inputFloatsNeeded = inputFramesNeeded * MixChannels;
|
||||
|
||||
// Grow our scratch buffer if a larger request than ever before. After the first few
|
||||
// reads at session start, this stops being a fresh allocation.
|
||||
if (resamplerInputScratch.Length < inputFloatsNeeded)
|
||||
{
|
||||
resamplerInputScratch = new float[inputFloatsNeeded];
|
||||
}
|
||||
var ringBytes = MemoryMarshal.AsBytes(resamplerInputScratch.AsSpan(0, inputFloatsNeeded));
|
||||
var bytesGot = playout.Read(ringBytes);
|
||||
var floatsGot = bytesGot / sizeof(float);
|
||||
var framesGot = floatsGot / MixChannels;
|
||||
|
||||
// Pipeline-stage probe — scan what we got out of the ring buffer BEFORE the
|
||||
// resampler touches it. If this shows large steps, the artefact is being
|
||||
// introduced somewhere between the sender and here (wire, decode, ring buffer).
|
||||
// If this is clean but the post-resampler probe shows large steps, the resampler
|
||||
// is the source.
|
||||
if (floatsGot > 0)
|
||||
{
|
||||
postRingReadStepProbe.ScanStereo(resamplerInputScratch.AsSpan(0, floatsGot));
|
||||
}
|
||||
|
||||
// Copy whatever we got into the resampler's input buffer. AudioRingBuffer.Read
|
||||
// already zero-fills the tail of a short read, but we copy via the float view so
|
||||
// the resampler sees consistent float samples regardless of read shortfall.
|
||||
resamplerInputScratch.AsSpan(0, floatsGot).CopyTo(inBuf.AsSpan(inBufOff));
|
||||
if (floatsGot < inputFloatsNeeded)
|
||||
{
|
||||
inBuf.AsSpan(inBufOff + floatsGot, inputFloatsNeeded - floatsGot).Clear();
|
||||
}
|
||||
|
||||
// Diagnostic split — distinguish full-empty reads from partial short reads. Only
|
||||
// full-empty (framesGot == 0) triggers audible concealment treatment. Partial
|
||||
// reads happen when the ring has fewer than inputFramesNeeded frames but more
|
||||
// than zero; the zero-padded tail just produces silence at the resampler output
|
||||
// for that fraction.
|
||||
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
|
||||
if (framesGot == 0)
|
||||
{
|
||||
Interlocked.Increment(ref concealmentFiresTotal);
|
||||
consecutiveEmptyReads++;
|
||||
}
|
||||
else if (framesGot < inputFramesNeeded)
|
||||
{
|
||||
Interlocked.Increment(ref partialReadFiresTotal);
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
|
||||
// Run the resampler. Output goes into outputScratch (the resampler needs a float[]
|
||||
// not a Span<float>); we then copy with clamp to the caller's span.
|
||||
ResampleOutAndCopy(output, outFrames);
|
||||
bytesReadOutputForDriftEst += outFloats * sizeof(float);
|
||||
|
||||
if (framesRead < requestedFrames)
|
||||
// Concealment overlay on full-empty reads. The resampler will have produced
|
||||
// mostly-silence output for this call (we zero-padded its input); replace the
|
||||
// head of that silence with the chosen artifact so the user hears the "something
|
||||
// went wrong" cue rather than dead air, with a cosine fade-in on the next read
|
||||
// when real audio resumes.
|
||||
if (framesGot == 0)
|
||||
{
|
||||
// Don't synthesise concealment forever during a sustained empty-buffer state — the
|
||||
// sender has probably gone away. After N consecutive empty reads we just leave the
|
||||
// buffer's hard-zero in place; result is true silence rather than a "shshshsh"
|
||||
// tremolo as the noise/cosine artifact retriggers each render callback.
|
||||
consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0;
|
||||
if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties)
|
||||
{
|
||||
// AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the
|
||||
// head of that silence with the chosen artifact, then leave the rest at zero.
|
||||
var silenceFrameStart = framesRead;
|
||||
var silenceFrameCount = requestedFrames - framesRead;
|
||||
ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact);
|
||||
ApplyFadeOut(output, startFrame: 0, outFrames, artifact);
|
||||
}
|
||||
inUnderrunConcealment = true;
|
||||
}
|
||||
else if (inUnderrunConcealment)
|
||||
{
|
||||
// First full read after a gap. Fade the new audio in from zero so we don't
|
||||
// instantly jump back to whatever the new audio's amplitude is.
|
||||
ApplyFadeIn(inSpan, requestedFrames, artifact);
|
||||
ApplyFadeIn(output, outFrames, artifact);
|
||||
inUnderrunConcealment = false;
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
|
||||
// Remember the last real sample for the next fade-out. Use the last frame of actual
|
||||
// ring data, not anything we just synthesised. (Only meaningful if we read at least
|
||||
// one real frame this call — i.e. framesRead > 0.)
|
||||
if (framesRead > 0)
|
||||
// Track the last real sample for the next fade-out.
|
||||
if (framesGot > 0)
|
||||
{
|
||||
var lastIdx = (framesRead - 1) * MixChannels;
|
||||
lastConcealSampleL = inSpan[lastIdx];
|
||||
lastConcealSampleR = inSpan[lastIdx + 1];
|
||||
var lastIdx = (framesGot - 1) * MixChannels;
|
||||
lastConcealSampleL = resamplerInputScratch[lastIdx];
|
||||
lastConcealSampleR = resamplerInputScratch[lastIdx + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Run the resampler with already-supplied input and copy the result to the
|
||||
/// caller's span, clamping samples to [-1, 1] as a safety against any pathological
|
||||
/// resampler output. If the resampler returns fewer than requested output frames, the
|
||||
/// tail is zero-filled.</summary>
|
||||
private void ResampleOutAndCopy(Span<float> output, int outFrames)
|
||||
{
|
||||
var outFloats = outFrames * MixChannels;
|
||||
if (resamplerOutputScratch.Length < outFloats)
|
||||
{
|
||||
resamplerOutputScratch = new float[outFloats];
|
||||
}
|
||||
// ResampleOut consumes the input we wrote into the buffer obtained from
|
||||
// ResamplePrepare, plus any state it holds internally, and produces up to outFrames
|
||||
// output frames. Returns the actual count.
|
||||
var produced = driftResampler.ResampleOut(resamplerOutputScratch, 0, GetLastInputFramesAvailable(), outFrames, MixChannels);
|
||||
var producedFloats = produced * MixChannels;
|
||||
// Pipeline-stage probe — scan the resampler output before we clamp or copy. If this
|
||||
// shows steps that don't appear in the post-ring-read probe, the resampler itself
|
||||
// is the source.
|
||||
if (producedFloats > 0)
|
||||
{
|
||||
postResamplerStepProbe.ScanStereo(resamplerOutputScratch.AsSpan(0, producedFloats));
|
||||
}
|
||||
for (var i = 0; i < producedFloats; i++)
|
||||
{
|
||||
var v = resamplerOutputScratch[i];
|
||||
// Safety clamp. Real resampler output should never escape [-1, 1] from in-range
|
||||
// input, but a single bad sample / NaN would otherwise produce a loud audible
|
||||
// pop. Clamping is cheap insurance.
|
||||
if (v > 1f) v = 1f;
|
||||
else if (v < -1f) v = -1f;
|
||||
else if (float.IsNaN(v)) v = 0f;
|
||||
output[i] = v;
|
||||
}
|
||||
// Zero-fill if the resampler didn't produce as many frames as we asked for. Should
|
||||
// only happen in pathological cases (just after session start with empty filter
|
||||
// delay line, or after a Reset).
|
||||
if (produced < outFrames)
|
||||
{
|
||||
output.Slice(producedFloats, (outFrames - produced) * MixChannels).Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Resampler scratch — output buffer (input scratch is the resamplerInputScratch field).
|
||||
private float[] resamplerOutputScratch = new float[2048];
|
||||
// How many input frames we just supplied to the resampler this call. Used by
|
||||
// ResampleOutAndCopy to call ResampleOut with the right input count. Updated by
|
||||
// ReadThroughResampler before the ResampleOutAndCopy call.
|
||||
private int lastInputFramesAvailable;
|
||||
private int GetLastInputFramesAvailable() => lastInputFramesAvailable;
|
||||
|
||||
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
|
||||
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
|
||||
/// hard-zero in place.</summary>
|
||||
|
||||
@@ -39,6 +39,39 @@ internal sealed class StreamSession : IDisposable
|
||||
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
|
||||
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
|
||||
|
||||
// Post-decode discontinuity probe. Scans the float buffer right after Int24LEToFloat
|
||||
// (PCM) or short-to-float (Opus) so we can compare to the sender's pre-encode probe and
|
||||
// detect any wire-level or decode-level artefacts. Same buffer is then handed to the
|
||||
// session playout, so the post-ring-read probe in SessionPlayout sees the exact same
|
||||
// samples a moment later (after riding through the ring buffer).
|
||||
private readonly AudioStepProbe postDecodeStepProbe = new();
|
||||
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
|
||||
|
||||
// === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
|
||||
// Every audio packet carries a wire sequence number that monotonically increases per
|
||||
// session (audioSequence in SenderLane). The Opus path uses this for FEC recovery. The
|
||||
// PCM path historically ignored it entirely. Now we track it to detect:
|
||||
// * MISSING packets — sequence > expected (gap > 1 frames)
|
||||
// * REORDERED packets — sequence < expected (a packet arrived after a later one)
|
||||
// * DUPLICATE packets — sequence == previous (same packet delivered twice)
|
||||
// * IN-ORDER packets — sequence == expected
|
||||
//
|
||||
// Any of MISSING / REORDERED / DUPLICATE on a healthy LAN would point straight at a
|
||||
// transport-level issue (NIC offload bug, switch buffer overflow, RSS hash collision
|
||||
// causing packets to take different queues). MISSING on PCM = silent audio drop at
|
||||
// the packet boundary = audible click. REORDERED = the receiver processes audio in
|
||||
// the wrong order = audible click. DUPLICATE = same audio played twice in a row =
|
||||
// audible click.
|
||||
private uint? expectedNextWireSequence;
|
||||
private long wireInOrderTotal;
|
||||
private long wireMissedTotal; // sum of missing-packet counts (sequence > expected by N → +N)
|
||||
private long wireReorderedTotal; // count of times a sequence < expected arrived
|
||||
private long wireDuplicatedTotal; // count of times a sequence == previous arrived
|
||||
public long WireInOrderCount => Interlocked.Read(ref wireInOrderTotal);
|
||||
public long WireMissedCount => Interlocked.Read(ref wireMissedTotal);
|
||||
public long WireReorderedCount => Interlocked.Read(ref wireReorderedTotal);
|
||||
public long WireDuplicatedCount => Interlocked.Read(ref wireDuplicatedTotal);
|
||||
|
||||
public StreamSession(
|
||||
IPEndPoint endpoint,
|
||||
ushort streamId,
|
||||
@@ -74,6 +107,7 @@ internal sealed class StreamSession : IDisposable
|
||||
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
diagnostics.RecordPacketArrived();
|
||||
TrackWireSequence(sequence);
|
||||
return Codec switch
|
||||
{
|
||||
AudioTransportCodec.Pcm => HandlePcm(payload),
|
||||
@@ -82,6 +116,60 @@ internal sealed class StreamSession : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify each arriving packet against the expected next wire sequence:
|
||||
/// IN-ORDER (== expected), MISSING (> expected, diff sample frames), REORDERED (< expected
|
||||
/// but within a small sane window), DUPLICATE (== previous). On the very first packet we
|
||||
/// just seed expected and bail. On a wild jump (huge gap) we treat it as a re-sync rather
|
||||
/// than logging hundreds of thousands of "missing" packets — this can happen if the sender
|
||||
/// restarts mid-session or a router drops a long burst.
|
||||
/// All counters use Interlocked because the readers are on the UI thread.
|
||||
/// </summary>
|
||||
private void TrackWireSequence(uint sequence)
|
||||
{
|
||||
if (expectedNextWireSequence is not uint expected)
|
||||
{
|
||||
expectedNextWireSequence = sequence + 1U;
|
||||
Interlocked.Increment(ref wireInOrderTotal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequence == expected)
|
||||
{
|
||||
Interlocked.Increment(ref wireInOrderTotal);
|
||||
expectedNextWireSequence = sequence + 1U;
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat the gap as an unsigned forward gap. If it's small-ish (< 1M packets, well over
|
||||
// 10 minutes of audio at our packet rates) treat as forward MISSING. If it's huge,
|
||||
// assume sequence ran backwards (reorder or restart).
|
||||
uint forwardGap = sequence - expected;
|
||||
if (forwardGap < 1_000_000U)
|
||||
{
|
||||
// Forward jump → forwardGap packets we never saw at the expected slot.
|
||||
Interlocked.Add(ref wireMissedTotal, forwardGap);
|
||||
expectedNextWireSequence = sequence + 1U;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Backward jump. Distance behind expected:
|
||||
uint backwardDistance = expected - sequence;
|
||||
if (backwardDistance == 1U)
|
||||
{
|
||||
// sequence == previous (the one just before expected) → duplicate.
|
||||
Interlocked.Increment(ref wireDuplicatedTotal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Out-of-order arrival from further back.
|
||||
Interlocked.Increment(ref wireReorderedTotal);
|
||||
}
|
||||
// Do NOT roll expectedNextWireSequence backwards — that would re-count the
|
||||
// already-missing packets when the originally-expected packet arrives.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
|
||||
|
||||
// === PCM ===
|
||||
@@ -106,6 +194,12 @@ internal sealed class StreamSession : IDisposable
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
PcmPack.Int24LEToFloat(assembled, floatSpan);
|
||||
|
||||
// Discontinuity probe — what does the audio look like right after we decode it?
|
||||
// Compared to the sender's pre-encode probe, a higher value here would mean the
|
||||
// wire codec roundtrip introduced steps. Same probe is also useful as a baseline
|
||||
// for the post-ring-read probe in SessionPlayout.
|
||||
postDecodeStepProbe.ScanStereo(floatSpan);
|
||||
|
||||
sessionPlayout.Write(floatScratch);
|
||||
onFramesQueued(sampleCount / Format.Channels);
|
||||
return true;
|
||||
|
||||
@@ -37,6 +37,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
// open driver can keep running while routing changes between Mixed / AsioLane / no-op.
|
||||
// Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier).
|
||||
private volatile Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
// Raw-capture step probe — measures discontinuities in the ASIO buffer exactly as the
|
||||
// driver delivered it, BEFORE our code sums the selected channel pairs or clamps to ±1.0.
|
||||
// Each capture backend owns its own probe so BothIndependent mode (ASIO and WASAPI both
|
||||
// capturing) can be diagnosed without the probes contaminating each other's state.
|
||||
private readonly AudioStepProbe rawCaptureStepProbe = new();
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
public string DriverName => driverName;
|
||||
@@ -82,6 +87,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
public void SetCallback(Action<ReadOnlyMemory<float>> callback) =>
|
||||
onMixedSamples = callback;
|
||||
|
||||
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
|
||||
|
||||
public bool IsRunning => asio is not null;
|
||||
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
|
||||
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
|
||||
@@ -264,6 +271,23 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
|
||||
if (pairs.Count == 0) return;
|
||||
|
||||
// Diagnostic raw-capture probe — scans the FIRST active channel pair's L channel in
|
||||
// the as-delivered-by-the-driver interleaved buffer. Fires BEFORE the mix/sum/clamp
|
||||
// below so the probe sees the driver's data verbatim. If this probe goes non-zero
|
||||
// on big steps while the post-mix probe also does, the discontinuity is upstream of
|
||||
// our code (driver, USB transport, audio hardware). If it stays clean while the
|
||||
// post-mix probe goes non-zero, something in the mix/clamp loop is creating the step.
|
||||
if (frames > 0 && recordChannelCount > 0)
|
||||
{
|
||||
var firstPair = pairs[0];
|
||||
var lCh = firstPair * 2;
|
||||
if (lCh < recordChannelCount)
|
||||
{
|
||||
rawCaptureStepProbe.ScanInterleavedChannel(
|
||||
new ReadOnlySpan<float>(interleavedScratch, 0, written), recordChannelCount, lCh);
|
||||
}
|
||||
}
|
||||
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var srcBase = f * recordChannelCount;
|
||||
|
||||
@@ -125,6 +125,37 @@ public sealed class AudioSender : IDisposable
|
||||
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
|
||||
// Pre-encode discontinuity probe — per-lane (each <see cref="SenderLane"/> owns its own).
|
||||
// The aggregate accessor returns the max across both lanes since the last read; per-lane
|
||||
// accessors expose them individually so BothIndependent mode can tell which lane is
|
||||
// producing the artefact. Splitting the probe per-lane (2026-05-15) eliminates the
|
||||
// cross-stream synthetic-step artefact that appeared when both lanes shared one probe and
|
||||
// their interleaved callbacks fooled the cross-buffer step computation into recording a
|
||||
// "step" between two unrelated audio streams.
|
||||
public float TakeMaxSenderPreEncodeStep()
|
||||
{
|
||||
var a = defaultLane.TakeMaxPreEncodeStep();
|
||||
var b = asioLane.TakeMaxPreEncodeStep();
|
||||
return a > b ? a : b;
|
||||
}
|
||||
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
|
||||
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
|
||||
|
||||
// Raw capture-side step probe — now lives inside each <see cref="ICaptureBackend"/>
|
||||
// implementation so the ASIO path and the WASAPI path each measure their own buffers
|
||||
// independently. The aggregate just asks the backend for the max since last read; in
|
||||
// BothIndependent mode the composite backend forwards to both inners and returns the
|
||||
// larger value.
|
||||
public float TakeMaxSenderRawCaptureStep() => engine.TakeMaxRawCaptureStep();
|
||||
|
||||
// Snapshot the cumulative "hit the hard clamp" sample counter. The sender's mix path
|
||||
// clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path
|
||||
// can't represent or that the resampler would treat as garbage). Per-second delta tells
|
||||
// us whether the input signal is getting close enough to the rails that clipping is
|
||||
// active — clipping itself produces no step, but a flat-topped sample plateau plus a
|
||||
// following sharp drop can produce audible distortion that masquerades as a click.
|
||||
public long ClippedSampleCount => engine.ClippedSampleCount;
|
||||
|
||||
// === inbound dispatch (relay-mode) ===
|
||||
// The send socket is normally write-only, but in relay-mode the same socket is what
|
||||
// catches return packets — the relay forwards traffic into our NAT pinhole, which lives on
|
||||
@@ -144,6 +175,28 @@ public sealed class AudioSender : IDisposable
|
||||
/// </summary>
|
||||
public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked every time a SenderLane is about to encode a buffer of
|
||||
/// captured float audio. The span is 48 kHz interleaved stereo float, lives on the
|
||||
/// audio thread, and must be processed quickly or copied — the buffer is reused on
|
||||
/// the very next callback. The recorder uses this tap to capture "what we sent" with
|
||||
/// zero impact on the wire path (no allocation, no extra encoder pass). Null = no tap.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnSentSamples { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal helper for <see cref="SenderLane"/> to invoke <see cref="OnSentSamples"/>
|
||||
/// without paying a delegate-invocation cost when no tap is wired. Catches and drops
|
||||
/// any exception from the user callback — a misbehaving recorder must not crash the
|
||||
/// audio thread.
|
||||
/// </summary>
|
||||
internal void DispatchSentSamples(ReadOnlyMemory<float> samples)
|
||||
{
|
||||
var cb = OnSentSamples;
|
||||
if (cb is null) return;
|
||||
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
|
||||
}
|
||||
|
||||
public AudioSender()
|
||||
{
|
||||
udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
@@ -357,7 +410,6 @@ public sealed class AudioSender : IDisposable
|
||||
public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs();
|
||||
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
|
||||
public string? LastCaptureError => engine.FirstCaptureLastError;
|
||||
public long ClippedSampleCount => engine.ClippedSampleCount;
|
||||
public AudioTransportCodec Codec => codec;
|
||||
public int OpusFrameMilliseconds => opusFrameMs;
|
||||
|
||||
|
||||
@@ -119,6 +119,16 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Max raw-capture step across both inner backends since the last call. Has to
|
||||
/// drain BOTH probes (so neither sits accumulating forever after we read one) and return
|
||||
/// the larger value.</summary>
|
||||
public float TakeMaxRawCaptureStep()
|
||||
{
|
||||
var w = wasapi?.TakeMaxRawCaptureStep() ?? 0f;
|
||||
var a = asio?.TakeMaxRawCaptureStep() ?? 0f;
|
||||
return w > a ? w : a;
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
|
||||
@@ -45,6 +45,13 @@ internal interface ICaptureBackend : IDisposable
|
||||
/// support per-callback timing (e.g. trivial test backends) may return 0.</summary>
|
||||
int TakeMaxCallbackGapMs();
|
||||
|
||||
/// <summary>Worst single-sample step magnitude observed in the raw capture buffer since
|
||||
/// the last call; resets on read. Each backend owns its own probe instance so the
|
||||
/// cross-buffer step measurement doesn't get fooled by another backend's interleaved
|
||||
/// callbacks (which is what produced spurious 0.4-0.5 readings in BothIndependent mode
|
||||
/// before 2026-05-15). Backends that can't sensibly expose raw samples return 0.</summary>
|
||||
float TakeMaxRawCaptureStep();
|
||||
|
||||
void Start(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
|
||||
|
||||
@@ -111,6 +111,14 @@ internal sealed class MixingEngine : ICaptureBackend
|
||||
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>Multi-source pull-mode WASAPI doesn't yet feed the raw-capture probe — each
|
||||
/// <see cref="CaptureSource"/> chain (BufferedWaveProvider → ToSampleProvider →
|
||||
/// resampler → stereo-mixdown) would need a per-source probe to be useful, and during
|
||||
/// the 2026-05-15 instrumentation push the user's tests have all been single-source on
|
||||
/// <see cref="PushModeWasapiBackend"/> instead. Stays at zero here; if a future
|
||||
/// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.</summary>
|
||||
public float TakeMaxRawCaptureStep() => 0f;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the mix loop with the given initial source set. If already running, the existing
|
||||
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
|
||||
|
||||
@@ -67,6 +67,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
private long bytesCaptured;
|
||||
private long clippedSampleCount;
|
||||
|
||||
// Raw-capture step probe — scans the WASAPI source buffer as floats right after we
|
||||
// reinterpret the byte buffer, BEFORE resampling / stereo-mixdown / clamp. This is the
|
||||
// earliest float-form view of what the Windows audio engine handed us. Used together
|
||||
// with the per-lane pre-encode probe to localise where discontinuities enter on the
|
||||
// WASAPI path. Per-backend so BothIndependent doesn't cross-contaminate ASIO and WASAPI
|
||||
// probes' cross-buffer state.
|
||||
private readonly AudioStepProbe rawCaptureStepProbe = new();
|
||||
|
||||
// Resampling state — only allocated when source rate != MixSampleRate.
|
||||
private WdlResampler? resampler;
|
||||
private int sourceSampleRate;
|
||||
@@ -100,6 +108,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
/// where Ed has been hunting jitter.</summary>
|
||||
public int TakeMaxCallbackGapMs() => 0;
|
||||
|
||||
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
if (specs.Count == 0)
|
||||
@@ -247,6 +257,21 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded);
|
||||
var sourceFrames = sourceFloatCount / sourceChannels;
|
||||
|
||||
// Raw-capture probe — scans the L channel of the source buffer in the form
|
||||
// Windows handed it to us, before our resample / mixdown / clamp. Channel layout
|
||||
// for WASAPI loopback is interleaved [L,R,...] for stereo or a single channel for
|
||||
// mono; the probe walks every Nth sample where N=sourceChannels. If this probe
|
||||
// shows steps that the per-lane pre-encode probe doesn't, our downstream
|
||||
// processing is masking real source-side issues. If both show the same steps,
|
||||
// the discontinuity arrived from Windows / the device driver.
|
||||
if (sourceFrames > 0 && sourceChannels > 0)
|
||||
{
|
||||
rawCaptureStepProbe.ScanInterleavedChannel(
|
||||
new ReadOnlySpan<float>(sourceFloatScratch, 0, sourceFloatCount),
|
||||
sourceChannels,
|
||||
0);
|
||||
}
|
||||
|
||||
// 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the
|
||||
// pull from our callback. Approximate output frames = input * outRate / inRate.
|
||||
float[] working;
|
||||
|
||||
@@ -52,6 +52,17 @@ internal sealed class SenderLane
|
||||
private OpusEncoderState opusEncoder;
|
||||
private int opusFrameStereoSamples;
|
||||
|
||||
// Per-lane pre-encode discontinuity probe. Moved here from AudioSender (2026-05-15) so
|
||||
// each lane has its OWN probe state and the cross-buffer step measurement (which carries
|
||||
// lastL/lastR across calls) only sees samples from one continuous audio stream. With the
|
||||
// earlier shared-probe design, BothIndependent mode mixed two unrelated streams' samples
|
||||
// into the same probe's cross-buffer carry, producing synthetic "steps" of arbitrary
|
||||
// magnitude every time the two lanes' callbacks interleaved — making the diag log unable
|
||||
// to tell a real capture glitch from instrumentation aliasing. Per-lane separation fixes
|
||||
// that without changing what the probe measures.
|
||||
private readonly AudioStepProbe preEncodeStepProbe = new();
|
||||
public float TakeMaxPreEncodeStep() => preEncodeStepProbe.TakeMax();
|
||||
|
||||
// Which render route this lane announces in its format packets. The receiver reads the
|
||||
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
|
||||
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
|
||||
@@ -144,6 +155,20 @@ internal sealed class SenderLane
|
||||
var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
EnsureFormatPacketSent();
|
||||
|
||||
// Recording tap — the recorder gets the float audio about to be encoded. The lane
|
||||
// doesn't know whether the recorder is running; the dispatcher early-outs when no
|
||||
// callback is wired. Captured here (before encoding) so the recording is bit-clean
|
||||
// float, independent of which codec the wire is using.
|
||||
owner.DispatchSentSamples(stereoFloats);
|
||||
|
||||
// Discontinuity probe — what does the audio look like just before we encode it?
|
||||
// Compared to the receiver's per-stage probes, this tells us whether artefacts are
|
||||
// present at the sender side already (capture hardware glitch, mix-bus issue) or
|
||||
// introduced somewhere in the wire / decode / playout chain. Per-lane probe — see
|
||||
// <see cref="preEncodeStepProbe"/> field comment for why this isn't shared with the
|
||||
// other lane in BothIndependent.
|
||||
preEncodeStepProbe.ScanStereo(span);
|
||||
|
||||
switch (owner.Codec)
|
||||
{
|
||||
case AudioTransportCodec.Pcm:
|
||||
|
||||
Reference in New Issue
Block a user