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

New user-facing features:

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

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

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

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

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

Diagnostics (only active with Enable logs ticked):

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

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

* Clipped-sample delta in the diag log.

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

UI changes:

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 12:40:01 +01:00
co-authored by Claude Opus 4.7
parent a0fe8070ed
commit c59e413c1f
27 changed files with 3348 additions and 350 deletions
+359 -14
View File
@@ -37,6 +37,11 @@ public sealed class MainForm : Form
private readonly System.Windows.Forms.Timer updateCheckTimer = new();
private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController;
private readonly RecordingController recordingController;
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip
// the visible text + accessibility name between "Start recording" and "Stop recording"
// without rebuilding the menu.
private ToolStripMenuItem? startStopRecordingMenuItem;
// --- Main form controls ---
// Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises
@@ -175,6 +180,13 @@ public sealed class MainForm : Form
// checkbox doing almost the same thing in a less convenient one-shot shape.
private readonly AccessibleCheckBox continuousTuneBox = new() { Text = "Continuous auto-tune latency", AutoSize = true };
private readonly ComboBox continuousIntervalBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 90, AccessibleName = "Auto-tune latency interval (Alt+I)" };
// Label for continuousIntervalBox. Held as a field (rather than a local in
// BuildAudioReceiveGroupContents) so UpdateBothIndependentVisibility can rewrite the
// text and mnemonic when the user flips audio mode — the interval governs both lanes'
// auto-tune ticks in BothIndependent, and the label needs to say so. Initialised in
// BuildAudioReceiveGroupContents alongside the other receive-side controls; visibility
// is shared with the WASAPI row (always shown when the row is shown).
private Label? continuousIntervalLabel;
// BothIndependent-mode companion controls. Created up front so SelectedIndexChanged
// handlers can be wired alongside the originals; they live in their own TableLayoutPanel
// row that toggles Visible=true only when the audio mode is BothIndependent. The labels
@@ -284,6 +296,14 @@ public sealed class MainForm : Form
private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase);
private System.Media.SoundPlayer? connectSound;
private System.Media.SoundPlayer? disconnectSound;
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
// same path as connect/disconnect. They don't pass through our recording taps (those
// sit on the internal sender mix bus and receiver render path), so they don't appear
// in normal recordings. A user who has a WASAPI loopback of the same output device as
// a capture source would still get them, but that's their loopback configuration, not
// anything the recorder is doing.
private System.Media.SoundPlayer? recordStartSound;
private System.Media.SoundPlayer? recordStopSound;
// Labels for the three send/receive device lists, captured at layout time so they can be
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
// (driver channel pairs). null until BuildLayout has run.
@@ -344,6 +364,30 @@ public sealed class MainForm : Form
private bool firstSenderPacketLogged;
private bool firstReceiverPacketLogged;
// Previous-tick values for the per-second deltas surfaced in the diag log line. Each is
// the receiver-side cumulative counter snapshot at the previous SnapshotLogIfDue tick;
// subtracting from the current value gives "how many fired this second". Only read when
// DiagnosticsGate.Enabled (i.e. logs on); otherwise SnapshotLogIfDue early-outs before
// touching these.
private long prevDiagDriftDrops;
private long prevDiagDriftReps;
private long prevDiagConceal;
private long prevDiagShortRead;
private long prevDiagTrimFires;
// Wire-level packet-sequence tracking deltas. Detects packet reordering, loss, or
// duplication on the UDP path between sender and receiver. On a healthy LAN all three
// failure counters should stay at zero; any non-zero delta in the diag log is a smoking
// gun for transport-layer-induced pops.
private long prevDiagWireInOrder;
private long prevDiagWireMissed;
private long prevDiagWireReordered;
private long prevDiagWireDuplicated;
// Per-second delta for the sender's hard-clamp clipping counter. A non-zero clipΔ means
// the mix bus was producing samples whose magnitude exceeded 1.0 and got clamped. Clipping
// itself doesn't create steps but is a signal that the input is hot enough that something
// could be saturating.
private long prevDiagClippedSamples;
// Profile system (2026-05-02). The active profile (if any) was selected at app start and
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
// below). Control-level state (device ticks, send/receive checkboxes, audio port, volume
@@ -481,6 +525,13 @@ public sealed class MainForm : Form
() => receiveAudioCheckbox.Checked = true,
Close);
recordingController = new RecordingController(
sender,
receiver,
settings,
msg => logFile.Event($"recorder: {msg}"));
recordingController.RecordingStateChanged += UpdateStartStopRecordingMenuLabel;
// --- Set accessibility names ---
// For these four controls the keyboard shortcut is included explicitly in both the
// visible label (set in BuildLayout) and the AccessibleName, instead of relying on the
@@ -694,6 +745,8 @@ public sealed class MainForm : Form
// Files are deployed alongside the .exe (see RemSound.App.csproj Content rules).
TryLoadCueSound("connect.wav", out connectSound);
TryLoadCueSound("disconnect.wav", out disconnectSound);
TryLoadCueSound("record start.wav", out recordStartSound);
TryLoadCueSound("record stop.wav", out recordStopSound);
LoadAudioDevices();
// Apply persisted ASIO mode from settings — switches sender/receiver backends so the
@@ -1065,11 +1118,123 @@ public sealed class MainForm : Form
aboutItem,
});
var recordMenu = BuildRecordMenu();
menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
menu.Items.Add(helpMenu);
return menu;
}
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
/// settings dialog, open the configured folder, and change the configured folder.
/// Ctrl+R is the global toggle so the user can start/stop without going through the
/// menu. Profile-dirty flag is set when the user changes the folder or the settings
/// inside the sub-dialog because both live on the profile.</summary>
private ToolStripMenuItem BuildRecordMenu()
{
// Record menu uses Alt+O (Rec&ord) rather than Alt+R. The form's "Receive audio
// (Alt+R)" checkbox lives on the main canvas alongside the menu bar and Alt+R was
// ambiguous between the two. Alt+O is unused elsewhere on the menu bar (File / Help
// / Record) and reads as "Recording" naturally enough for the mnemonic to stick.
var recordMenu = new ToolStripMenuItem("Rec&ord") { AccessibleName = "Record menu" };
startStopRecordingMenuItem = new ToolStripMenuItem("&Start recording")
{
ShortcutKeys = Keys.Control | Keys.R,
AccessibleName = "Start recording",
};
startStopRecordingMenuItem.Click += (_, _) => ToggleRecording();
var settingsItem = new ToolStripMenuItem("Recording se&ttings...")
{
AccessibleName = "Recording settings",
};
settingsItem.Click += (_, _) => OpenRecordingSettingsDialog();
var openFolderItem = new ToolStripMenuItem("&Open current recordings folder")
{
AccessibleName = "Open current recordings folder",
};
openFolderItem.Click += (_, _) => recordingController.OpenCurrentFolder(this);
var changeFolderItem = new ToolStripMenuItem("&Change recordings folder...")
{
AccessibleName = "Change recordings folder",
};
changeFolderItem.Click += (_, _) =>
{
if (recordingController.ChangeFolder(this)) MarkProfileDirty();
};
recordMenu.DropDownItems.AddRange(new ToolStripItem[]
{
startStopRecordingMenuItem,
new ToolStripSeparator(),
settingsItem,
new ToolStripSeparator(),
openFolderItem,
changeFolderItem,
});
return recordMenu;
}
/// <summary>Toggle the recording state. Single source of truth for both Ctrl+R and the
/// menu-item click — both paths route through here so the start/stop transition is
/// handled consistently. The state-change event fires UpdateStartStopRecordingMenuLabel
/// which rewrites the menu item text.</summary>
private void ToggleRecording()
{
if (recordingController.IsRecording)
{
// Stop the recorder FIRST, then play the cue. SoundPlayer goes through the
// default Windows output device — separate from the internal taps the recorder
// listens on — so the cue isn't in the file regardless of ordering, but
// stopping first means a user with a WASAPI-loopback-of-default-output capture
// source won't catch the tail of the cue either.
recordingController.Stop();
if (settings.LoadEnableRecordStopCue()) recordStopSound?.Play();
}
else
{
// Symmetric: play the start cue BEFORE the recorder turns on, for the same
// loopback-courtesy reason. The cue is short (~0.4 s), so any subjective lag
// between "I pressed Ctrl+R" and "audio starts being captured" is well under
// the cue itself.
if (settings.LoadEnableRecordStartCue()) recordStartSound?.Play();
recordingController.Start();
}
}
/// <summary>Reflect the recording state in the menu item label. NVDA reads the text +
/// AccessibleName, both flipped here so users on screen readers hear the new state
/// straight away. Marshalled to the UI thread because the recorder's finish callback
/// can fire from its writer thread when Stop() is called from there.</summary>
private void UpdateStartStopRecordingMenuLabel(bool nowRecording)
{
void Apply()
{
if (startStopRecordingMenuItem is null) return;
startStopRecordingMenuItem.Text = nowRecording ? "&Stop recording" : "&Start recording";
startStopRecordingMenuItem.AccessibleName = nowRecording ? "Stop recording" : "Start recording";
}
if (InvokeRequired) BeginInvoke(Apply);
else Apply();
}
/// <summary>Open the recording settings dialog. On OK, write the settings back through
/// <see cref="RemSoundSettingsStore"/> and flag the profile dirty if anything changed.
/// The dialog reads its initial state from the same store, so settings persist across
/// re-opens until the user explicitly saves the profile.</summary>
private void OpenRecordingSettingsDialog()
{
using var dialog = new RecordingSettingsDialog(settings.LoadRecordingSettings());
if (dialog.ShowDialog(this) != DialogResult.OK) return;
settings.SaveRecordingSettings(dialog.Result);
if (dialog.ChangedAnything) MarkProfileDirty();
}
/// <summary>Show a file-picker rooted at the profiles folder; on selection, schedule a
/// switch to that profile (same close-and-relaunch flow as the old Switch button).</summary>
private void OpenProfileFromPicker()
@@ -1749,8 +1914,17 @@ public sealed class MainForm : Form
continuousIntervalBox.Items.Clear();
continuousIntervalBox.Items.AddRange(new object[] { "3 seconds", "5 seconds", "10 seconds", "15 seconds", "30 seconds" });
continuousIntervalBox.SelectedIndex = continuousTuneIntervalSec switch { 3 => 0, 5 => 1, 15 => 3, 30 => 4, _ => 2 };
continuousIntervalBox.Enabled = continuousTuneEnabled;
var continuousIntervalLabel = new Label { Text = "Auto-tune latency interval (Alt+&I)", AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) };
// Enable the interval combo whenever EITHER lane's auto-tune is on — the single
// interval value governs both lanes' tick rates (see comment at row-1 docstring).
// Previously this only followed the WASAPI checkbox, which made the combo grey out
// in BothIndependent mode when only ASIO auto-tune was ticked, even though the
// timer was running and the interval was being honoured for the ASIO lane.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
// Label text is set by UpdateBothIndependentVisibility — it differs between classic
// modes (single lane → "Auto-tune latency interval") and BothIndependent
// (two lanes → "Auto-tune interval (WASAPI + ASIO)") to make explicit that the same
// dropdown drives both lanes' tick cadence in the latter case.
continuousIntervalLabel = new Label { AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) };
var delayContainer = new FlowLayoutPanel
{
AutoSize = true,
@@ -1769,7 +1943,7 @@ public sealed class MainForm : Form
{
continuousTuneEnabled = continuousTuneBox.Checked;
settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled);
continuousIntervalBox.Enabled = continuousTuneEnabled;
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer();
MarkProfileDirty();
};
@@ -2694,6 +2868,11 @@ public sealed class MainForm : Form
continuousTuneAsioBox.CheckedChanged += (_, _) =>
{
settings.SaveContinuousAutoTuneAsioEnabled(continuousTuneAsioBox.Checked);
// The interval combo is shared between both lanes — keep it enabled whenever
// either lane's auto-tune is on. Without this, ticking ASIO auto-tune (in
// BothIndependent) left the interval combo greyed out and made the recheck
// cadence invisible to the user even though it was actively in effect.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
ApplyContinuousTuneTimer();
MarkProfileDirty();
};
@@ -2720,12 +2899,27 @@ public sealed class MainForm : Form
asioDelayContainer.Visible = inBothIndependent;
maxLatencyAsioBox.Visible = inBothIndependent;
continuousTuneAsioBox.Visible = inBothIndependent;
// Mode change may have changed which auto-tune flags count toward "any enabled":
// leaving BothIndependent drops the ASIO lane's checkbox from consideration, and
// entering it brings it back. Re-evaluate so the shared interval combo's Enabled
// state tracks reality after every mode flip.
continuousIntervalBox.Enabled = AnyAutoTuneEnabled();
if (inBothIndependent)
{
wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)";
maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)";
continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)";
continuousTuneBox.AccessibleName = "Continuous auto-tune WASAPI latency";
// The interval combo drives ticks for BOTH lanes' auto-tunes — each lane
// independently lands wherever its own algorithm decides (40 ms WASAPI / 20 ms
// ASIO is fine), but the cadence dropdown is shared. Make that explicit in the
// label so a user looking at the WASAPI row doesn't assume the interval only
// applies there.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune interval — WASAPI and ASIO (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune interval for WASAPI and ASIO (Alt+I)";
}
else
{
@@ -2733,6 +2927,12 @@ public sealed class MainForm : Form
maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)";
continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)";
continuousTuneBox.AccessibleName = "Continuous auto-tune latency";
// Classic mode — single lane, original label is unambiguous.
if (continuousIntervalLabel is not null)
{
continuousIntervalLabel.Text = "Auto-tune latency interval (Alt+&I)";
}
continuousIntervalBox.AccessibleName = "Auto-tune latency interval (Alt+I)";
}
}
@@ -3410,6 +3610,26 @@ public sealed class MainForm : Form
// music; informational only).
var driftDrops = receiver.DriftDropFrames;
var driftReps = receiver.DriftRepeatFrames;
// Per-second deltas for the same counters — easier to read at a glance than
// ever-growing cumulative numbers. driftDropΔ + driftRepΔ tell us how fast
// the corrector is firing right now. concealΔ tells us how many real underruns
// fired this second (audible). shortReadΔ tracks the now-silent partial-read
// events for clock-phase diagnostics. Trim fires + delta gives us "is the
// click-trim safety net firing".
var concealNow = receiver.ConcealmentFires;
var shortReadNow = receiver.ShortReadFires;
var driftDropDelta = driftDrops - prevDiagDriftDrops; prevDiagDriftDrops = driftDrops;
var driftRepDelta = driftReps - prevDiagDriftReps; prevDiagDriftReps = driftReps;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state (not deltas) — current LP-filtered drift error and accumulator
// value. Both let us see "where the corrector thinks the buffer is" between
// explicit drop/repeat events. filtErr negative = buffer running below target
// on average; positive = above. driftAcc near 0 = corrector idle; near ±1 =
// about to fire.
var filteredErrorFrames = receiver.FilteredDriftErrorFrames;
var driftAccumulator = receiver.DriftAccumulator;
// 2026-05-11 added timing-split metrics:
// emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send)
// sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only)
@@ -3426,12 +3646,57 @@ public sealed class MainForm : Form
// samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero =
// the input ALREADY has discontinuities (capture-side issue).
// stepPostDec = receiver's float buffer just after PCM/Opus decode. If this
// is significantly larger than stepPreEnc, the wire codec
// roundtrip introduced steps.
// stepPostRing = receiver's float buffer just out of the ring (before
// resampler). Roughly equal to stepPostDec in steady state;
// bigger here means the ring buffer is fishy.
// stepPostRsm = receiver's float buffer just out of the resampler. Bigger
// here than stepPostRing fingers the resampler integration.
// sampleStepMax= the final output buffer (after volume + limiter), the
// legacy spot the diag already tracked.
// Per-lane pre-encode probes (2026-05-15) — split so BothIndependent mode
// can show which lane is producing the discontinuity, free of the cross-
// stream artefact that the old shared probe registered when both lanes'
// callbacks interleaved into one probe's lastL/R carry.
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDec = receiver.TakeMaxPostDecodeStep();
var stepPostRing = receiver.TakeMaxPostRingReadStep();
var stepPostRsm = receiver.TakeMaxPostResamplerStep();
// Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
// wireDupΔ are the smoking-gun counters — any non-zero value here means the
// UDP path between sender and receiver dropped, reordered, or duplicated
// packets, and that on the PCM path translates directly into audible pops.
var wireInOrderNow = receiver.WireInOrderCount;
var wireMissedNow = receiver.WireMissedCount;
var wireReorderedNow = receiver.WireReorderedCount;
var wireDuplicatedNow = receiver.WireDuplicatedCount;
var wireInOrderDelta = wireInOrderNow - prevDiagWireInOrder; prevDiagWireInOrder = wireInOrderNow;
var wireMissedDelta = wireMissedNow - prevDiagWireMissed; prevDiagWireMissed = wireMissedNow;
var wireReorderedDelta = wireReorderedNow - prevDiagWireReordered; prevDiagWireReordered = wireReorderedNow;
var wireDuplicatedDelta = wireDuplicatedNow - prevDiagWireDuplicated; prevDiagWireDuplicated = wireDuplicatedNow;
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " +
$"trimB={trimBytes} trimN={trimFires} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftRep={driftReps} " +
$"sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
}
else if (sender.IsRunning)
@@ -3440,9 +3705,24 @@ public sealed class MainForm : Form
// sendCbGapMs is visible — that's the most important metric on a send-only box,
// since it tells us whether THIS machine's capture path is stalling. Without
// this branch, send-only sessions logged zero diag info.
// stepPreEnc included so the send-only machine's pre-encode discontinuity
// probe is visible — needed for the laptop→desktop direction where the laptop
// is the source and we want to see if the audio coming OUT of the capture
// already has steps before it touches the wire.
var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs();
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
// Per-lane pre-encode probes — see the full-diag comment above for the
// rationale (per-lane fixes the cross-stream artefact in BothIndependent).
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
// Raw-capture step: now per-backend (each backend owns its own probe). The
// accessor returns max across all backends. PushModeWasapiBackend has been
// wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0.
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
}
// Synthesised end-to-end one-way latency estimate. Sums:
@@ -3521,13 +3801,60 @@ public sealed class MainForm : Form
/// </summary>
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// No ProcessCmdKey overrides currently — base class handles everything. The previous
// Alt+M tab-local gating became unnecessary once the Audio mode listbox was retired
// (2026-05-11); minimise to tray is reachable via Alt+F → M (File menu mnemonic) or
// the configurable "Show or hide window" global hotkey (default Ctrl+Shift+F10).
// Defensive gate for the global menu shortcuts that change state (Ctrl+R = toggle
// recording, Ctrl+S = save profile). The default WinForms behaviour fires these
// shortcuts any time the form has keyboard focus — which technically includes the
// case where another tool (NVDA Remote in send-keys mode, an automation script,
// etc.) calls SetForegroundWindow on us and then SendInput a keystroke a few
// milliseconds later. The form receives focus + the keystroke arrives + the menu
// shortcut fires, all without the user touching anything.
//
// The gate adds two extra requirements before we let these shortcuts run:
// 1. The OS-level foreground window must be us. Same check the base class
// effectively makes, but explicit so the intent is documented.
// 2. At least RecentActivationGuardMs must have elapsed since we last became
// activated. Programmatic SetForegroundWindow + SendInput typically runs in
// under 50 ms; a human Alt+Tabbing in then pressing Ctrl+R can't physically
// do it inside 250 ms.
// If the gate fails we consume the keystroke (return true) so the menu shortcut
// doesn't fire, log a diagnostic, and silently ignore it. The user can still drive
// the same actions via the Alt+R / Alt+F menu chord which inherently requires the
// multi-step menu-open interaction and isn't vulnerable to drive-by injection.
if (keyData == (Keys.Control | Keys.R) || keyData == (Keys.Control | Keys.S))
{
if (!IsWindowAvailableForGatedShortcut())
{
logFile.Event($"shortcut ignored (window not in interactive state): {keyData}");
return true; // consumed; don't let MenuStrip see it
}
}
return base.ProcessCmdKey(ref msg, keyData);
}
// UTC time the form last became activated. Compared against UtcNow when a gated
// shortcut fires to reject keystrokes that arrive within the RecentActivationGuardMs
// window after a window-activation — the signature of a drive-by injection.
private DateTime lastActivatedAtUtc = DateTime.MinValue;
private const int RecentActivationGuardMs = 250;
protected override void OnActivated(EventArgs e)
{
lastActivatedAtUtc = DateTime.UtcNow;
base.OnActivated(e);
}
/// <summary>Defensive gate for global menu shortcuts that change state. See the comment
/// in <see cref="ProcessCmdKey"/> for the full rationale.</summary>
private bool IsWindowAvailableForGatedShortcut()
{
if (!Visible || WindowState == FormWindowState.Minimized) return false;
if ((DateTime.UtcNow - lastActivatedAtUtc).TotalMilliseconds < RecentActivationGuardMs) return false;
return GetForegroundWindow() == Handle;
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
// ===================== Profile system =====================
@@ -3973,13 +4300,13 @@ public sealed class MainForm : Form
previousPeerHealthStates.TryGetValue(key, out var prior);
if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy)
{
if (!settings.LoadMuteConnectionCues()) connectSound?.Play();
if (settings.LoadEnableConnectCue()) connectSound?.Play();
logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)");
}
else if (ph.State == PeerHealthState.Unreachable
&& (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale))
{
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play();
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)");
}
previousPeerHealthStates[key] = ph.State;
@@ -3991,7 +4318,7 @@ public sealed class MainForm : Form
{
if (previousPeerHealthStates[key] == PeerHealthState.Healthy)
{
if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play();
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)");
}
previousPeerHealthStates.Remove(key);
@@ -4229,6 +4556,18 @@ public sealed class MainForm : Form
/// Mixed flag; in BothIndependent either WASAPI or ASIO being on is enough to keep the
/// timer running. The per-route filtering inside the tick gates which sliders actually
/// move.</summary>
/// <summary>True if either lane's continuous auto-tune is enabled. Used by the shared
/// interval combo's Enabled state — the combo governs both lanes' tick rates, so it
/// should be usable as long as at least one lane wants ticking. Reading from the live
/// checkbox states keeps this consistent with the lane's checkbox even before the
/// CheckedChanged handlers have updated the persisted setting.</summary>
private bool AnyAutoTuneEnabled()
{
var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent;
var asioOn = inBothIndependent && continuousTuneAsioBox.Checked;
return continuousTuneEnabled || asioOn;
}
private void ApplyContinuousTuneTimer()
{
continuousTuneTimer.Stop();
@@ -4704,6 +5043,12 @@ public sealed class MainForm : Form
}
}
// Stop any active recording before the engines tear down. The recorder will flush
// its queue and close the file cleanly. Done here (rather than in Dispose) because
// we want the on-disk file finalised before the form closes, so opening the
// recordings folder right after exit shows the file at its full size.
try { recordingController.Stop(); } catch { /* recording cleanup is best-effort */ }
base.OnFormClosing(e);
}
}