Bump to v2.1.0: UPnP, read-only profile lock, sleep/hibernate audio fix

Headline features:
* Automatic router port opening (UPnP / NAT-PMP / PCP). Opt-in via
  Preferences; surfaces external address + carrier-grade NAT detection.
* Lock profile (read-only). New File-menu tick that makes a profile
  load-only — session changes don't persist, no save prompt on close.
  Unblocks unattended shutdowns (NVDA gone, remote dropped, hibernate)
  where the existing save prompt could deadlock.
* Check for updates on startup (default on) + brief countdown notice
  before silent updates install, so a launch-time update doesn't make
  the app silently vanish.
* "Cue sounds" -> "Audio cue sounds" label clarification.

Bug fixes:
* No sound after the computer wakes from sleep. PowerResumeHandler
  rebuilds the audio backend automatically on resume; brief
  "Reconnecting to audio driver" splash during the rebuild.
* Receiver audio silent after waking from hibernate. RefreshAudioDeviceLists
  now treats a transient ASIO probe failure (returns -1/-1 because the
  driver is mid-teardown / mid-reinit) as "retry next tick" instead of
  clearing the user's tick selection.

Diagnostic-only changes (gated on the existing Enable-logs checkbox,
zero cost when off):
* AudioStepProbe split into cross-buffer vs within-buffer maxes so log
  inspection can tell a real-content sharp transient apart from a
  pipeline-boundary glitch. Plumbed through every probe owner.
* New rxNetGapMs + gc0/gc1/gc2 delta columns in the diag log to split
  receive-side jitter into network-layer vs managed-runtime causes.

Files touched: RELEASE_NOTES.md + readme.html + 24 source files across
RemSound.Core / RemSound.Sender / RemSound.Receiver / RemSound.App.
Three new app files: PowerResumeHandler, RouterPortMapper,
UpdateInstallNoticeDialog.

Wire format and audio pipeline unchanged from v1.5 onward — v1.5
through v2.1 peers interoperate.
This commit is contained in:
Ednunp
2026-05-22 23:06:07 +01:00
parent 00cb4deef1
commit 79b28b6c02
27 changed files with 1980 additions and 125 deletions
+557 -21
View File
@@ -38,6 +38,17 @@ public sealed class MainForm : Form
private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController;
private readonly RecordingController recordingController;
// Hook into Windows sleep/resume so the audio backend gets rebuilt after wake. USB
// audio devices (ASIO / WASAPI) commonly come back in a degraded post-resume state
// where the pipeline runs but no sound actually comes out of the interface — restarting
// the backend on resume clears it. Subscribed in the constructor, disposed in FormClosing.
private readonly PowerResumeHandler powerResumeHandler;
// Optional UPnP / NAT-PMP / PCP router-port opener (Mono.Nat under the hood). Off by
// default; the user opts in via the "Automatically open my router for incoming
// connections" tick in Preferences (AppConfig.UpnpEnabled). Started lazily in Shown
// when the flag is on, restarted from OnSystemResume so a sleep-drop on the router's
// NAT table is recovered automatically, and stopped in FormClosing.
private readonly RouterPortMapper routerPortMapper;
// 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.
@@ -393,6 +404,14 @@ public sealed class MainForm : Form
// itself doesn't create steps but is a signal that the input is hot enough that something
// could be saturating.
private long prevDiagClippedSamples;
// Per-second GC delta. .NET tracks cumulative collection counts per generation; we
// remember the previous tick's values and emit gen-0 / gen-1 / gen-2 deltas in the diag
// log so a click-event correlation analysis can spot when a GC pause coincided with a
// receive-side arrival-gap spike. Gen-2 in particular implies a multi-millisecond stall
// that's a plausible click source. 2026-05-21.
private int prevDiagGc0Count;
private int prevDiagGc1Count;
private int prevDiagGc2Count;
// 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
@@ -403,6 +422,27 @@ public sealed class MainForm : Form
// re-launch the form under that profile."
private ProfileStore? profileStore;
private string? currentProfileTitle;
// True when the active profile has its ReadOnly flag set. Drives three behaviours:
// * The window title gets a " (read-only)" suffix so NVDA / sighted users see
// immediately that changes won't persist.
// * Ctrl+S / File → Save politely refuses (with a "use Save As instead" message).
// * OnFormClosing skips the unsaved-changes prompt entirely — that's the whole
// point of read-only mode, so a profile you live in and toggle send/receive
// on doesn't block shutdown with a dialog you can't reach (NVDA crashed, remote
// session dropped, machine hibernating).
// 2026-05-22 — Andre's request: he toggles send/receive on his default profile and
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
private bool currentProfileReadOnly;
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
// itself on every click; the CheckedChanged handler reads the new value and runs
// OnLockProfileToggled.
private ToolStripMenuItem? lockProfileMenuItem;
// Guards CheckedChanged on lockProfileMenuItem against the programmatic sync that
// happens on profile-load — without it, loading a profile that's read-only would
// re-fire the toggle handler and re-persist the flag pointlessly.
private bool suppressLockProfileToggleHandler;
/// <summary>Full filesystem path of the active profile's JSON file. Tracked separately
/// from <see cref="currentProfileTitle"/> because Save As (2026-05-10) lets the user
/// write a profile to an arbitrary path outside <see cref="ProfileStore.BaseDirectory"/>.
@@ -469,6 +509,10 @@ public sealed class MainForm : Form
catch { /* benign — recents tracking is a convenience, not load-critical */ }
}
pendingProfile = profile;
// Carry the profile's ReadOnly flag through to the in-memory tracking field. Blank
// template (profile == null) implicitly starts as not-read-only; users still have
// the menu toggle available if they want to lock the working state mid-session.
currentProfileReadOnly = profile?.ReadOnly ?? false;
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
// the in-memory settings cache BEFORE the rest of the constructor body reads from it.
// Control states (device ticks, checkboxes, volume) come later in OnShown.
@@ -901,6 +945,17 @@ public sealed class MainForm : Form
PushDiscoveryUnicastHints();
hotkeyController.Initialize(this);
// Hook system sleep/resume so we can rebuild the audio backend after wake (USB
// audio devices often come back wedged). The handler routes back through
// OnSystemResume on a background thread; that marshals to the UI thread.
powerResumeHandler = new PowerResumeHandler(OnSystemResume, msg => logFile.Event($"power: {msg}"));
// Build the UPnP router-port opener up-front but don't start it — Shown decides
// whether to invoke Start() based on AppConfig.UpnpEnabled. Constructing the field
// here (rather than lazily on tick) keeps the field non-null so the Preferences
// dialog can subscribe to StatusChanged without us juggling instance lifetimes.
routerPortMapper = new RouterPortMapper(msg => logFile.Event($"upnp: {msg}"));
FormClosing += (_, _) =>
{
statusTimer.Stop();
@@ -908,6 +963,8 @@ public sealed class MainForm : Form
continuousTuneTimer.Stop();
updateCheckTimer.Stop();
asioDriverChangeDebounce.Stop();
try { powerResumeHandler?.Dispose(); } catch { }
try { routerPortMapper?.Dispose(); } catch { }
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean
// these up on process exit anyway, but doing it explicitly releases the power
// request handle and matches our timeBeginPeriod with a timeEndPeriod.
@@ -971,6 +1028,41 @@ public sealed class MainForm : Form
{
BeginInvoke(() => trayController.Minimize());
}
// Kick off UPnP discovery if the user has the box ticked. Off by default; the
// mapper itself coalesces redundant Start() calls so a re-enter via Shown after
// a sleep cycle is harmless.
var startupCfg = AppConfig.Load();
if (startupCfg.UpnpEnabled)
{
try { routerPortMapper.Start(); }
catch (Exception ex) { logFile.Event($"upnp: start failed: {ex.GetType().Name}: {ex.Message}"); }
}
// Startup update check — separate from the periodic timer because users who
// launch RemSound, find an update, and stay running for less than the timer
// interval would otherwise miss the release entirely. Default on. The
// background-poll path handles both silent install and the user-prompt flow.
if (startupCfg.CheckForUpdatesOnStartup)
{
// Defer a few seconds so the network stack, audio engine, and any device
// hot-swap has settled before we touch GitHub. The visible cue (silent-
// install notice dialog) appears inside the check path, so a small delay
// is invisible to the user.
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(4)).ConfigureAwait(false);
if (IsDisposed) return;
BeginInvoke(new Action(CheckForUpdatesOnStartup));
}
catch (Exception ex)
{
logFile.Event($"updater: startup check scheduling failed: {ex.GetType().Name}: {ex.Message}");
}
});
}
};
statusTimer.Start();
@@ -1090,6 +1182,30 @@ public sealed class MainForm : Form
};
renameItem.Click += (_, _) => RenameCurrentProfile();
// Lock profile (read-only). When checked, the active profile is loaded for use but
// never written back: Save / Ctrl+S politely refuses (with a "use Save As" message)
// and FormClosing skips the unsaved-changes prompt entirely. Andre's request — he
// toggles send/receive on his default profile and doesn't want a save prompt
// blocking shutdown when his screen reader can't reach it. Off by default; the
// flag is per-profile (stored in the profile JSON) so different profiles can
// independently choose lock vs editable.
//
// CheckOnClick = true makes WinForms flip the .Checked state on every click and
// NVDA reads "Lock profile read-only, checked / not checked". The mnemonic Alt+F, L
// doesn't collide with any existing File-menu letter (O / R / S / A / M / N / X
// are in use).
lockProfileMenuItem = new ToolStripMenuItem("&Lock profile (read-only)")
{
AccessibleName = "Lock profile read-only",
CheckOnClick = true,
Checked = currentProfileReadOnly,
};
lockProfileMenuItem.CheckedChanged += (_, _) =>
{
if (suppressLockProfileToggleHandler) return;
OnLockProfileToggled(lockProfileMenuItem.Checked);
};
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
{
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
@@ -1115,6 +1231,7 @@ public sealed class MainForm : Form
saveItem,
saveAsItem,
renameItem,
lockProfileMenuItem,
new ToolStripSeparator(),
minimiseItem,
new ToolStripSeparator(),
@@ -1441,13 +1558,53 @@ public sealed class MainForm : Form
}
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
/// it; if we're on the blank template (no current profile), fall through to Save as.</summary>
/// it; if we're on the blank template (no current profile), fall through to Save as.
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
/// a one-time message explaining why nothing happened. The message is suppressible
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
private void SaveOrSaveAs()
{
if (currentProfileReadOnly)
{
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
{
ShowSaveBlockedByReadOnlyDialog();
}
return;
}
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
else UpdateExistingProfile();
}
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
private void ShowSaveBlockedByReadOnlyDialog()
{
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
var page = new TaskDialogPage
{
Caption = AppName,
Heading = "This profile is read-only",
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
Icon = TaskDialogIcon.Information,
Verification = verification,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(this, page);
if (verification.Checked)
{
var cfg = AppConfig.Load();
cfg.SaveOnReadOnlyMessageSuppressed = true;
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
}
}
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
/// template (nothing to rename). Renames update window title + active-profile state
/// in place — no reload required.</summary>
@@ -1550,7 +1707,26 @@ public sealed class MainForm : Form
},
writeLogsNow: () => logFile.Event("user requested write logs now"),
checkForUpdatesNow: () => CheckForUpdatesManually(),
onUpdateFrequencyChanged: ApplyUpdateCheckTimer);
onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
applyUpnpEnabled: enabled =>
{
// The persist already happened in the dialog; this callback only flips the
// live RouterPortMapper. Start kicks off discovery; Stop politely removes any
// existing mapping.
if (enabled)
{
try { routerPortMapper.Start(); }
catch (Exception ex) { logFile.Event($"upnp: start from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
}
else
{
try { routerPortMapper.Stop(); }
catch (Exception ex) { logFile.Event($"upnp: stop from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
}
},
getUpnpSnapshot: () => (routerPortMapper.Status, routerPortMapper.ExternalEndpoint, routerPortMapper.LastError),
subscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged += handler,
unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler);
dialog.ShowDialog(this);
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
}
@@ -1596,6 +1772,9 @@ public sealed class MainForm : Form
if (info is null) return;
if (AppConfig.Load().SilentlyInstallUpdates)
{
// Notice the user before the app vanishes and the helper takes over. Hidden from
// the periodic-poll path on the assumption the user knows they ticked "silently
// install"; the startup path is the noisy one (see CheckForUpdatesOnStartup).
await InstallUpdateAsync(info).ConfigureAwait(true);
return;
}
@@ -1607,6 +1786,79 @@ public sealed class MainForm : Form
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
/// <summary>Startup-poll path. Fired ~4 s after the main window finishes loading when
/// <see cref="AppConfig.CheckForUpdatesOnStartup"/> is true. Distinct from
/// <see cref="CheckForUpdatesInBackground"/> because the startup case is where the
/// "you launched the app and it's already installing an update" surprise is loudest —
/// silent install here is preceded by a brief notice dialog so the user sees the version
/// number and understands why the app is about to vanish. The non-silent path uses the
/// same MessageBox flow as the background and manual paths so the user-visible question
/// stays consistent.</summary>
private async void CheckForUpdatesOnStartup()
{
UpdateInfo? info;
try
{
info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
logFile.Event($"updater: startup check failed: {ex.GetType().Name}: {ex.Message}");
return;
}
try
{
var cfg = AppConfig.Load();
cfg.LastUpdateCheckUtc = DateTime.UtcNow;
cfg.Save();
}
catch { /* harmless */ }
if (info is null)
{
logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})");
return;
}
logFile.Event($"updater: startup check found {info.Tag}");
if (AppConfig.Load().SilentlyInstallUpdates)
{
// Heads-up the user before we exit and the helper takes over. The notice is its
// own dialog so NVDA reads "RemSound is installing version X" before focus moves;
// a MessageBox would force the user to dismiss it, which defeats the point of
// "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short
// countdown but lets the user pick Install now / Skip / Postpone before then.
using var notice = new UpdateInstallNoticeDialog(info);
var choice = notice.ShowDialog(this);
switch (choice)
{
case DialogResult.OK:
// "Install now" — same as the countdown elapsing.
await InstallUpdateAsync(info).ConfigureAwait(true);
break;
case DialogResult.Ignore:
// "Skip this version" — log and leave the user be; the next startup
// check will probably find the same version and ask again. We don't
// persist a skip list because release tempo is low enough that the
// user can dismiss once or twice without resenting it.
logFile.Event($"updater: user skipped {info.Tag} from startup notice");
break;
case DialogResult.Cancel:
default:
// "Postpone" / closed dialog — silent install at the next opportunity
// (timer tick or next launch).
logFile.Event($"updater: user postponed {info.Tag} from startup notice");
break;
}
return;
}
// Non-silent: same prompt the background poll uses.
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var pick = MessageBox.Show(this, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
/// <summary>Download the new release, stage it, spawn the install helper and exit. On
/// any failure shows a MessageBox and stays running — partial installs leave the app
/// untouched.</summary>
@@ -2816,6 +3068,20 @@ public sealed class MainForm : Form
IReadOnlyList<AudioDeviceChoice> wasapiInputs;
IReadOnlyList<AudioDeviceChoice> asioInputChoices = [];
IReadOnlyList<AudioDeviceChoice> asioOutputChoices = [];
// True if ASIO mode is on AND a driver is configured AND probing it just failed this
// tick. Used to skip the asio list sync below — without this guard, a transient probe
// failure (most commonly during hibernate entry or resume, when the USB stack is being
// torn down or rebuilt) silently clears the user's ASIO tick, and on the next refresh
// when the probe succeeds the list re-populates EMPTY of checks because the tick state
// was lost in the previous clear. Net symptom: receiver-side audio falls silent after
// resume even though all "audio backend re-initialised" log lines look fine.
// 2026-05-22 — traced to a real overnight repro: SNAP at 23:37:33 had ReceiveDevice
// = "ASIO 1/2"; SNAP at 23:37:34 (one second later, mid-hibernate-entry) had "(none)";
// resume at 06:32:06 then opened the audio backend but the asio receive list was empty
// so AsioRenderBackend.SetOutputDevices got an empty pairs list and silently returned
// without opening the AsioOut — the AsioLane sessions queued packets into a ring with
// no consumer (bufMs grew to 970+ ms, TrimDropBytes climbed into the millions).
var asioProbeAttemptedAndFailed = false;
try
{
wasapiOutputs = AudioDeviceCatalog.LoadOutputs();
@@ -2831,6 +3097,17 @@ public sealed class MainForm : Form
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames);
}
else
{
// Probe came back -1/-1 — driver is configured but can't enumerate right
// now. Treat as transient; preserve current list state and try again on
// the next tick. The legitimate "driver is genuinely gone" cases (user
// selected "(none)", or settings.LoadAsioDriverName() returned null/empty)
// take the outer-if's else branch and correctly produce an empty list
// that DOES sync (clearing the UI), so removing a driver from the system
// still wipes the ticks as expected.
asioProbeAttemptedAndFailed = true;
}
}
}
catch (Exception ex)
@@ -2842,8 +3119,22 @@ public sealed class MainForm : Form
var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature);
var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature);
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature);
var asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
var asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
bool asioSendChanged;
bool asioReceiveChanged;
if (asioProbeAttemptedAndFailed)
{
// Skip both asio list syncs. Crucially do NOT update the signature fields — leaving
// them unchanged means the NEXT successful probe will still see "signature differs"
// and re-sync the lists with the freshly-probed channel pairs, restoring tick state
// by DeviceId from whatever was preserved in the UI.
asioSendChanged = false;
asioReceiveChanged = false;
}
else
{
asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
}
if (sendOutputChanged || sendInputChanged || asioSendChanged)
{
@@ -3236,6 +3527,81 @@ public sealed class MainForm : Form
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
}
/// <summary>
/// Called by <see cref="PowerResumeHandler"/> on a background thread after the system has
/// woken from sleep / hibernate (plus a short USB-settle delay). Marshals onto the UI
/// thread and runs the audio-backend re-init. Swallows the form-already-torn-down race —
/// the handler can fire just as the app is being closed.
/// </summary>
private void OnSystemResume()
{
try
{
if (IsDisposed) return;
BeginInvoke(ReinitAudioBackendsForResume);
}
catch (ObjectDisposedException) { /* form torn down — nothing to do */ }
catch (InvalidOperationException) { /* handle not created yet — same */ }
}
/// <summary>
/// Runs on the UI thread. Closes and reopens the audio backend on both sides (receiver
/// render and sender capture) so any post-sleep wedged state in the USB audio drivers is
/// cleared. Shows the audio-driver splash on its own thread while the reset happens, so
/// the user sees "Reconnecting to audio driver…" instead of a frozen window.
///
/// Implementation note: the receiver's <see cref="RemSound.Receiver.AudioReceiver.SetAudioMode"/>
/// always tears down and rebuilds its render backend, which is exactly the reset we
/// want. The sender's <see cref="RemSound.Sender.AudioSender.SetAudioMode"/> persists
/// its ASIO driver across same-driver calls (to avoid an expensive reopen on every
/// device-tick change) — so we explicitly bounce the sender through <c>WasapiOnly</c>
/// first to force the ASIO driver to be disposed, then <see cref="ApplyAsioMode"/>
/// puts both sides back to the real configuration. The net effect is a full close-and-
/// reopen on both sides; same code path as a manual driver re-pick from the picker.
/// </summary>
private void ReinitAudioBackendsForResume()
{
if (IsDisposed) return;
var mode = settings.LoadAudioMode();
var driver = settings.LoadAsioDriverName();
logFile.Event($"power: re-initialising audio backend after system resume (mode={mode}, driver={driver ?? "(none)"})");
var splash = AsioLoadingSplash.StartIfAsioDriverName(driver, "Reconnecting to audio driver, please wait...");
try
{
// Force the sender's persistent ASIO driver to be disposed by bouncing through
// WasapiOnly. Skipped when there's no ASIO in the current mode — nothing to dispose.
if (mode != AudioMode.WasapiOnly && !string.IsNullOrWhiteSpace(driver))
{
try { sender.SetAudioMode(AudioMode.WasapiOnly, null); }
catch (Exception ex) { logFile.Event($"power: sender WasapiOnly bounce failed: {ex.GetType().Name}: {ex.Message}"); }
}
// ApplyAsioMode re-applies sender + receiver mode, refreshes device lists, and
// re-pushes the audio-runtime + receive-device configuration. The receiver's
// SetAudioMode call inside it does an unconditional render-backend rebuild; the
// sender's, post-bounce, recreates its persistent ASIO from scratch.
ApplyAsioMode();
logFile.Event("power: audio backend re-initialised");
// Re-poke the router. UPnP/NAT-PMP mappings often survive a sleep, but cheap
// routers and ISP-supplied combo boxes sometimes drop their NAT table — easier
// to just rediscover than to guess. Refresh() is a no-op if UPnP is off.
if (AppConfig.Load().UpnpEnabled)
{
try { routerPortMapper.Refresh(); }
catch (Exception ex) { logFile.Event($"upnp: refresh-on-resume failed: {ex.GetType().Name}: {ex.Message}"); }
}
}
catch (Exception ex)
{
logFile.Event($"power: audio backend re-init failed: {ex.GetType().Name}: {ex.Message}");
}
finally
{
splash?.Dismiss();
}
}
// ===================== Peers =====================
private void RefreshKnownPeers()
@@ -3896,11 +4262,32 @@ public sealed class MainForm : Form
var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs();
var rxDispatchMs = receiver.TakeMaxOnPacketMs();
// rxNetGapMs = worst inter-packet arrival gap at the user-space UDP socket.
// Distinct from maxGapMs (which is measured at the per-stream-session level
// after decode + assembly): this one is the raw "did ReceiveFrom return on
// time" timing, with no per-session bookkeeping in between. A spike here
// when the sender's sendCbGapMs is small fingers the OS/network path between
// sender and receiver — NIC IRQ servicing, scheduler not waking the receive
// thread, kernel batching, GC pause — rather than the sender stalling or
// RemSound's own decode/dispatch chain. 2026-05-21.
var rxNetGapMs = receiver.TakeMaxInterPacketGapMs();
// fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single
// active render lane should sit at ~0; non-zero says the FanOut is sitting on
// 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();
// GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the
// previous tick gives the per-second collection count per generation. Gen-0
// collections are cheap (microseconds); Gen-1 takes longer; Gen-2 / LOH can
// pause the runtime for many milliseconds, which is enough to explain a
// 3050 ms rxNetGapMs spike in isolation. Read directly here — GC.CollectionCount
// is essentially free, no need to gate further. 2026-05-21.
var gc0Now = GC.CollectionCount(0);
var gc1Now = GC.CollectionCount(1);
var gc2Now = GC.CollectionCount(2);
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
// 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 =
@@ -3919,15 +4306,37 @@ public sealed class MainForm : Form
// 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();
//
// 2026-05-21 — also surface the cross-buffer (boundary) vs within-buffer
// (content) split for every probe. A non-zero combined step combined with a
// near-zero within-buffer reading means the click is at a buffer / packet
// boundary (lost or duplicated sample, pipeline glitch); a non-zero
// within-buffer reading with a near-zero cross-buffer reading means it's a
// sharp transient inside one buffer (real audio content, system sound). All
// probe drains here go through the XB/WB pair and recompute the combined
// max from the split values — calling Take*Step() AND the split methods on
// the same probe in the same drain window would double-drain.
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDec = receiver.TakeMaxPostDecodeStep();
var stepPostRing = receiver.TakeMaxPostRingReadStep();
var stepPostRsm = receiver.TakeMaxPostResamplerStep();
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
var stepPostDecWB = receiver.TakeMaxPostDecodeStepWithinBuffer();
var stepPostDec = stepPostDecXB > stepPostDecWB ? stepPostDecXB : stepPostDecWB;
var stepPostRingXB = receiver.TakeMaxPostRingReadStepCrossBuffer();
var stepPostRingWB = receiver.TakeMaxPostRingReadStepWithinBuffer();
var stepPostRing = stepPostRingXB > stepPostRingWB ? stepPostRingXB : stepPostRingWB;
var stepPostRsmXB = receiver.TakeMaxPostResamplerStepCrossBuffer();
var stepPostRsmWB = receiver.TakeMaxPostResamplerStepWithinBuffer();
var stepPostRsm = stepPostRsmXB > stepPostRsmWB ? stepPostRsmXB : stepPostRsmWB;
// 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
@@ -3944,12 +4353,19 @@ public sealed class MainForm : Form
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} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} fanCacheMs={fanCacheMs} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"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} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"stepPostDecXB={stepPostDecXB:0.000} stepPostDecWB={stepPostDecWB:0.000} " +
$"stepPostRingXB={stepPostRingXB:0.000} stepPostRingWB={stepPostRingWB:0.000} " +
$"stepPostRsmXB={stepPostRsmXB:0.000} stepPostRsmWB={stepPostRsmWB: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}");
@@ -3968,16 +4384,42 @@ public sealed class MainForm : Form
var sendCallMs = sender.TakeMaxSendCallMs();
// 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();
// 2026-05-21: drain XB / WB separately so we can localise click events at
// the buffer boundary (cross-buffer) vs within-buffer (real content). The
// combined step is just the larger of the two for back-compat readers.
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
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 stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
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}");
// Per-second GC delta on the send-only side too. A send stall caused by a
// gen-2 pause on the SENDER would have a different signature in the SNAP
// log than one caused by a receive-side pause — they'd show up here even
// though no receiver activity is happening on this machine.
var gc0Now = GC.CollectionCount(0);
var gc1Now = GC.CollectionCount(1);
var gc2Now = GC.CollectionCount(2);
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
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} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
}
// Synthesised end-to-end one-way latency estimate. Sums:
@@ -4183,11 +4625,16 @@ public sealed class MainForm : Form
/// <summary>Window title shows the active profile name explicitly so the user knows what
/// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or
/// just "RemSound" (blank template).</summary>
private static string FormatWindowTitle(string? loadedTitle) =>
string.IsNullOrEmpty(loadedTitle)
? AppName
: $"{AppName} — Active profile: {loadedTitle}";
/// just "RemSound" (blank template). Read-only profiles get a " (read-only)" suffix so
/// NVDA announces the lock state on every title change and sighted users see it at a
/// glance — important context that "anything I change here won't be saved".</summary>
private string FormatWindowTitle(string? loadedTitle)
{
var readOnlySuffix = currentProfileReadOnly ? " (read-only)" : "";
return string.IsNullOrEmpty(loadedTitle)
? $"{AppName}{readOnlySuffix}"
: $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
}
/// <summary>Show/hide the Update button based on whether a profile is currently loaded.
/// Update only makes sense when there's an existing profile to overwrite; Save-as is
@@ -4261,6 +4708,18 @@ public sealed class MainForm : Form
currentProfileTitle = title;
currentProfilePath = path;
// Save As always produces an editable copy — even if the source profile was
// read-only. Anything else would be surprising: the user picked Save As
// specifically to fork, and they reasonably expect the fork to be editable
// without having to hunt for the menu toggle. The original (locked) profile on
// disk is untouched; this is purely about the new file and the in-memory state.
currentProfileReadOnly = false;
if (lockProfileMenuItem is not null)
{
suppressLockProfileToggleHandler = true;
try { lockProfileMenuItem.Checked = false; }
finally { suppressLockProfileToggleHandler = false; }
}
Text = FormatWindowTitle(title);
AccessibleName = Text;
UpdateProfileButtonsVisibility();
@@ -4377,6 +4836,74 @@ public sealed class MainForm : Form
unsavedChanges = true;
}
/// <summary>Handle the user ticking / unticking File → Lock profile (read-only). Updates
/// the in-memory flag, refreshes the window title's "(read-only)" suffix, and persists
/// the new value to the profile JSON on disk via <see cref="PersistReadOnlyFlagOnly"/>.
/// We MUST persist immediately because the very next user action might be the close
/// (the whole point of the feature is that close is unattended); waiting for an explicit
/// Save would defeat the point. 2026-05-22 — Andre's request.</summary>
private void OnLockProfileToggled(bool readOnly)
{
currentProfileReadOnly = readOnly;
Text = FormatWindowTitle(currentProfileTitle);
AccessibleName = Text;
PersistReadOnlyFlagOnly(readOnly);
AppendLogEntry($"profile read-only flag set to {readOnly} for \"{currentProfileTitle ?? "(blank template)"}\"");
}
/// <summary>Write JUST the ReadOnly flag back to the profile file on disk, without
/// touching any of the user's in-session edits. Used by <see cref="OnLockProfileToggled"/>
/// so toggling lock-state writes the flag immediately but leaves every other unsaved
/// change exactly as-is — without this carve-out, unlocking a profile that has unsaved
/// edits would either have to ignore them (losing user intent) or flush them (defeating
/// "the lock writes the lock, nothing else"). Approach: read the profile JSON, deserialise,
/// flip ONE field, re-serialise, write back. Blank-template case (no path) is a silent
/// no-op — there's no file to update, and the user's lock state lives in memory until
/// they Save As, at which point Save As builds a fresh Profile and writes whatever
/// flag the in-memory state has.</summary>
private void PersistReadOnlyFlagOnly(bool readOnly)
{
if (string.IsNullOrEmpty(currentProfilePath)) return;
if (!File.Exists(currentProfilePath)) return;
try
{
var json = File.ReadAllText(currentProfilePath);
var profile = JsonSerializer.Deserialize<Profile>(json);
if (profile is null) return;
if (profile.ReadOnly == readOnly) return; // no change, skip the rewrite
profile.ReadOnly = readOnly;
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson);
// Refresh the unsaved-changes baseline so any user edits made BEFORE the toggle
// remain "unsaved" (still pending a real Save) — the baseline tracks the saved
// profile JSON, and we just rewrote it on disk, so the diff has to be against
// the new file contents not the old ones. Without this, toggling lock on a
// dirty profile would suddenly "clean" the dirty flag from the close path's
// POV, even though the user's other edits still aren't persisted. The new
// baseline reflects the on-disk truth; the in-memory state still differs by
// those other edits, so unsavedChanges-style tracking still works.
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
catch { /* baseline refresh is best-effort */ }
}
catch (Exception ex)
{
// Don't bother the user with a MessageBox for a flag-write failure — they'd just
// see "couldn't persist the lock flag" with no actionable detail. Log and move
// on; the in-memory state already reflects the toggle, so the current session
// works correctly. Next launch the file's flag wins, but a single failed write
// is rare enough that it's not worth a dialog.
AppendLogEntry($"failed to persist read-only flag: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>Serialise an arbitrary <see cref="Profile"/> in the same shape
/// <see cref="SerializeCurrentStateAsProfile"/> uses for the dirty-diff. Lives here so
/// the lock-flag persistence path can refresh the baseline against the rewritten file
/// contents (a partial overwrite of the profile file) without flushing the user's
/// in-session edits. 2026-05-22.</summary>
private static string SerializeProfileForDirtyDiff(Profile profile) =>
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
/// <summary>Serializes the current control state as if the user had just clicked Save.
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
/// but doesn't write anywhere.</summary>
@@ -5250,7 +5777,16 @@ public sealed class MainForm : Form
// controlled close paths where the user has already confirmed their intent via the
// management dialog, and the MainForm gets reconstructed under the new profile
// immediately afterwards.
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch;
//
// Also skip the prompt when the active profile is read-only — the whole point of
// read-only mode (Andre's request, 2026-05-22) is that the user has explicitly
// declared "anything I changed this session is throwaway, don't save it and don't
// ask me about it". Without this branch the dirty-prompt would block shutdown on
// a profile where the user wants exactly the opposite: silent exit. Crucially this
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
// on a dialog the user can't reach.
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|| currentProfileReadOnly;
if (!skipPrompt && profileStore is not null && unsavedChanges)
{