using System.Net; using System.Net.Sockets; using System.Text.Json; using NAudio.CoreAudioApi; using RemSound.Core; using RemSound.Receiver; using RemSound.Sender; namespace RemSound.App; /// /// Main RemSound window. Designed for keyboard / NVDA use. /// /// UX shape (matches the older RSound app the user asked us to preserve): /// * Auto-connects on Shown — no Connect button. Discovery starts immediately. /// * "Connectivity and transport" button opens a settings + peers dialog. /// * Main form keeps just: mode (send/receive), receive device, volume, /// send capture devices (CheckedListBox + status label), other actions, status. /// * Every CheckedListBox has an adjacent status label that announces /// the focused item, its checked state, position, and "Press Space to toggle". /// * Knob changes flow live to the audio engine — no engine restarts. /// public sealed class MainForm : Form { private const string AppName = "RemSound"; // Engines and helpers private readonly PeerDiscoveryService discovery = new(); private readonly AudioSender sender = new(); private readonly AudioReceiver receiver = new(); private readonly RemSoundSettingsStore settings = new(AppName); private readonly RemSoundLog logFile = new(); private readonly RemSoundUpdater updater = new(); // Background timer that fires the periodic update-poll. Interval comes from // AppConfig.UpdateCheckFrequency; "Never" stops the timer entirely. Re-armed by // ApplyUpdateCheckTimer whenever the user changes the frequency in Preferences. private readonly System.Windows.Forms.Timer updateCheckTimer = new(); 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. private ToolStripMenuItem? startStopRecordingMenuItem; // Held so PopulateRecentProfilesMenu can clear + repopulate it on every DropDownOpening // (and once during construction so it's not empty before the first open). private ToolStripMenuItem? recentProfilesMenu; // --- Main form controls --- // Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises // UIA state-change notifications on CheckBox.Checked changes, so NVDA reliably announces // "checked" / "not checked" for both spacebar toggles and programmatic toggles (hotkeys, // tray menu). Replaced an earlier CheckedListBox-based approach that was used to work // around older WinForms accessibility issues. // Plain WinForms CheckBox configured exactly like the working RSound.old build: // * Field initializer sets only AutoSize and the bare Text (no ampersand). // * Text (with mnemonic) and AccessibleName are then re-assigned in the constructor body. // This two-step pattern matches the old code byte-for-byte; setting them in the field // initializer alone was enough to break NVDA state-change announcements. // * Each box is wrapped in its own FlowLayoutPanel before being placed in the // TableLayoutPanel cell — same as the old code. private readonly AccessibleCheckBox receiveAudioCheckbox = new() { Text = "Receive audio", AutoSize = true }; private readonly AccessibleCheckBox sendMyAudioCheckbox = new() { Text = "Send my audio", AutoSize = true }; private readonly TrackBar volumeBar = new() { Minimum = 0, Maximum = 100, TickFrequency = 10, Value = 100, Width = 200 }; // Receive output device. Pre-selected to the system default at startup; user can override // for the session. Selection is NOT persisted — next session starts on default again. private readonly CheckedListBox receiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label receiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." }; // Capture devices the user has ticked for sending. Two lists — render-side outputs (loopback // capture: system audio / soundcard playback) and capture-side inputs (mics, line-ins). Both // are summed into one outgoing stream by the sender's MixingEngine. Intentionally NOT // persisted: every session starts with everything unticked and no audio sent. The user // re-ticks once per session. Stops any device-routing surprise (a card unplugged between // runs, IDs changing, etc.). private readonly CheckedListBox sendOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label sendOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." }; private readonly CheckedListBox sendInputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label sendInputDevicesStatusLabel = new() { AutoSize = true, Text = "No input device selected." }; // ASIO-side lists. Always present in the form but hidden when ASIO is disabled. The two // lists are independent of the WASAPI ones — the user can tick any combination across all // five lists. Sender mixes WASAPI capture + ASIO capture into one outgoing stream; // receiver fans rendered audio to WASAPI outputs + ASIO outputs in parallel. This lets // someone use a WASAPI mic and an ASIO instrument input together, or send out to a WASAPI // headset alongside ASIO studio monitors. private readonly CheckedListBox asioSendDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label asioSendDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO send channel selected." }; private readonly CheckedListBox asioReceiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; private readonly Label asioReceiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO receive channel selected." }; // One-press "clear every device tick" — sits above the ASIO driver picker on the I/O tab. // The device lists can get long; this is the quick reset when you've lost track of what's on. private readonly Button uncheckAllDevicesButton = new() { AutoSize = true, Anchor = AnchorStyles.Left }; // Labels paired with the ASIO lists; held as fields so the layout can show/hide them as a // unit when the user toggles "Enable ASIO". private MnemonicLabel? asioSendDevicesLabel; private MnemonicLabel? asioReceiveOutputDevicesLabel; // Mnemonic label for the driver picker, held as a field so we can show/hide it together // with the driver listbox when the audio mode changes. Created in BuildAudioIOTab only // when there is at least one ASIO driver installed; null on machines with no ASIO drivers // (the driver picker is omitted entirely in that case). private MnemonicLabel? asioDriverLabel; // Tabbed UI scaffolding — 2026-05-06 refactor. The form's content panel is now a TabControl // with four logical sections; status (healthLabel/statusLabel) sits in a footer below the // tabs so the user always sees connection health regardless of which tab is active. // // Navigation (the standard Windows / NVDA-friendly pattern): // * Arrow Left/Right when the tab strip has focus → cycle tabs (NVDA announces each). // * Ctrl+Tab / Ctrl+Shift+Tab from anywhere on the form → cycle tabs. // * Tab key from the strip → focus enters the active page's first control. // * Tab past the last page control → focus moves to the status footer / form chrome. // // The TabControl is TabIndex=0 + TabStop=true so a fresh Tab from the form's chrome // lands on the strip first. We deliberately do NOT auto-focus a control inside the // active page on SelectedIndexChanged — that competes with arrow-key navigation (every // arrow press would yank focus off the strip into a page control, and the next arrow // would go to that control instead of cycling the next tab). Ed reported "bounces // about" with the previous always-auto-focus design; removed the handler. // // Alt+letter shortcuts are gated per-tab inside ProcessCmdKey so a shortcut never // auto-jumps the user across tabs. // TabControl + TabPage accessibility: deliberately default everything (no AccessibleName, // no AccessibleRole, no SelectedIndexChanged hook). Andre's working accessible-readout // app uses just `new TabPage(text)` and that's it — NVDA reads the active tab name // correctly via the framework's built-in MSAA exposure. Past attempts to "improve" this // (custom AccessibleName, dynamic sync on tab change, AccessibleRole.None) all made it // worse: extra "main sections", "tab control" double-reads, "pane" prefixes. The // standard pattern wins. 2026-05-06. // TabControl accessibility: the "tab control" prefix Ed kept hearing is from .NET 10 // WinForms' UIA exposure — it deliberately reports TabControl as a Tab control type // with TabItem children, and NVDA announces both. Microsoft removed the opt-out // (Switch.UseLegacyAccessibilityFeatures) for .NET Core / 5+ / 10. Andre's app reads // cleanly because it's .NET Framework 4.x where the older WinForms accessibility // implementation exposes less detail. // // QuietTabControl below is a Hail Mary: subclass TabControl, override its // AccessibleObject to return a non-Tab role so NVDA reads less context. Risk: // dotnet/winforms#11831 throws InvalidOperationException on .NET 8/9 when overriding // CreateAccessibilityInstance — may or may not be fixed in .NET 10. If it throws at // runtime, fall back to the bare TabControl and accept the announcement. private readonly TabControl mainTabControl = new QuietTabControl { Dock = DockStyle.Fill }; private readonly TabPage connectivityTabPage = new("Connectivity"); private readonly TabPage audioIOTabPage = new("Audio inputs and outputs"); private readonly TabPage audioProfileTabPage = new("Audio profile"); // profilesPrefsTabPage retired 2026-05-08 — its contents now live on the File menu. // connectivityTransportButton + ShowConnectivityTransportDialog removed in Phase 2/3 of // the 2026-05-06 UI refactor. Connectivity and audio-profile controls now live inline on // their respective tabs; there's nothing to bridge to. // 2026-05-11 audio-mode listbox retired. The mode is now derived from the ASIO driver // picker below: "(none)" → WasapiOnly, any driver → BothIndependent. The classic mixed-Both // and AsioOnly modes are no longer reachable from the UI; their enum values survive in // RemSound.Core.AudioMode for backward-compat deserialisation of old profile JSONs only. // ListBox (not ComboBox) so the user can arrow up/down to change drivers without having to // click or open a dropdown. Selecting a row immediately fires SelectedIndexChanged, which // re-applies the backend and refreshes the channel-pair lists below. Both Andre (Komplete // Audio) and Ed got confused by the combo's open/close interaction; a plain list with // sticky selection is unambiguous for sighted users and screen-reader users alike. // First item is always the "(none)" sentinel (NoAsioDriverSentinel below) — selecting it // means "no ASIO driver, run WASAPI-only". Real driver names follow. private readonly ListBox asioDriverBox = new() { Width = 280, Height = 80, IntegralHeight = false }; /// Visible label of the "no ASIO driver" sentinel row in . /// Equality against this string is how the code distinguishes "user has chosen WASAPI-only" /// from "user has selected a real driver". Kept as a constant so the visible text and the /// equality check can never drift apart. private const string NoAsioDriverSentinel = "(none)"; /// True when at least one ASIO driver was detected at startup. Set once in the /// constructor; reads it to decide whether to render the /// driver picker at all. On a machine with no ASIO drivers installed the picker (and its /// "Driver (Alt+D):" label) are omitted entirely — there is nothing to switch to. private bool hasAnyAsioDriverInstalled; // Profile-management buttons retired 2026-05-08 — these actions live in File menu now. // The methods (SaveProfileAs / UpdateExistingProfile) are still here; they're called from // the menu item Click handlers in BuildFileMenu. private readonly Label healthLabel = new() { Text = "Health: disconnected", AutoSize = true }; private readonly Label statusLabel = new() { Text = "Disconnected", AutoSize = true }; // --- Audio profile tab controls (Phase 2 refactor: these were previously in the // Connectivity & transport dialog as "dialog*" mirrors of hidden form-fields. Now they // are the canonical UI live on the Audio profile tab, no mirrors required.) --- private readonly ComboBox codecBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 360, AccessibleName = "Audio codec (Alt+C)" }; private readonly ListBox sendRateBox = new() { Width = 240, Height = 40, IntegralHeight = false, AccessibleName = "Packet size (Alt+P)" }; // Min 1 ms is intentionally aggressive — for LAN/localhost users who want to push it. // Values below ~10 ms cause audible crackling on any network with real jitter. private readonly NumericUpDown maxLatencyBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 80, Width = 90, AccessibleName = "Audio latency in milliseconds (Alt+L)" }; // One-shot "Tune latency for best sound" button retired — continuous auto-tune covers // the same job, and the manual button confused users by sitting next to the auto-tune // 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 // and mnemonics on the *existing* controls are re-written at mode-switch time so they // become the WASAPI-lane controls (Alt+W / Alt+Y) and these new ASIO controls take over // the simpler Alt+L / Alt+T mnemonics — ASIO is the "headline" lane in the new mode // (the reason a user picked it) so it gets the more memorable shortcuts. private readonly NumericUpDown maxLatencyAsioBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 10, Width = 90, AccessibleName = "ASIO latency in milliseconds (Alt+L)" }; private readonly AccessibleCheckBox continuousTuneAsioBox = new() { Text = "Continuous auto-tune ASIO latency", AutoSize = true }; private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" }; private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" }; private readonly AccessibleCheckBox tightLatencyBox = new() { AutoSize = true }; // Priority mode (per profile). Sits as the first control on the Audio profile tab, // ungrouped above the two GroupBoxes, so it's the first thing focus lands on when the // user Tabs into the tab. Toggling marks the profile dirty (the setting lives in // Profile, not AppConfig) and flips every PerformanceMode lever in one shot — CPU // scheduling, Windows power management, memory priority, working-set lock, and // MMCSS thread priority. Label deliberately mentions both "CPU" and "Windows // performance settings" because the toggle reaches well past just CPU scheduling. private readonly AccessibleCheckBox priorityModeBox = new() { Text = "&Use CPU and Windows performance settings in high priority mode (Alt+U)", AccessibleName = "Use CPU and Windows performance settings in high priority mode", AutoSize = true, }; // --- Connectivity tab controls (Phase 2 refactor) --- private readonly LiveCheckedListBox connectedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Connected peers (Alt+C)" }; private readonly Label connectedPeersStatus = new() { AutoSize = true, Text = "No peer connected." }; private readonly CheckedListBox discoveredPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Discovered peers (Alt+D)" }; private readonly Label discoveredPeersStatus = new() { AutoSize = true, Text = "No peer discovered." }; private readonly CheckedListBox rememberedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Remembered peers (Alt+R)" }; private readonly Label rememberedPeersStatus = new() { AutoSize = true, Text = "No remembered peer selected." }; private readonly Button manualAddButton = new() { Text = "Add peer by IP (Alt+&A)", AutoSize = true, AccessibleName = "Add peer by IP" }; // loggingBox + writeLogsNowButton field instances retired 2026-05-08 — both controls // now live inside PreferencesDialog. The form-level logFile.Enabled gate is set // directly from the settings store at startup (see ApplyLoggingEnabled). // Read-only multiline TextBox at the end of the Connectivity tab. Tab into it to read // a live snapshot of connection status (peers / pings / uptime / byte rates). Updates // every status-tick (1 Hz) but ONLY when the user is NOT focused on the box — that way // NVDA reads it once when the user lands, doesn't re-announce mid-read. Signature // short-circuit so the actual Text setter only fires when content changes (NVDA pattern // matches the peer-list refresh). private readonly TextBox statusReadout = new() { Multiline = true, ReadOnly = true, TabStop = true, Width = 460, Height = 110, BorderStyle = BorderStyle.FixedSingle, ScrollBars = ScrollBars.Vertical, AccessibleName = "Connection status (Alt+S)", }; private string lastStatusReadoutText = string.Empty; // For computing byte-rate deltas. Sampled at each status tick; first tick has no // prior baseline so the rate shows as 0. private long lastStatusTxBytes; private long lastStatusRxBytes; private DateTime lastStatusSampleUtc = DateTime.MinValue; // Tracks when the FIRST healthy-peer transition happened in the current "connected" // span. Cleared when no peers are healthy. Used for the uptime line. private DateTime? statusConnectedSinceUtc; // Per-list state (used by sync helpers — was per-method in the old dialog). private bool suppressConnectedCheck; private bool suppressDiscoveredCheck; private bool suppressRememberedCheck; /// True while ANY checkable list is being (un)checked programmatically — a device-list /// refresh () or a rebuild of one of the three peer lists /// (each fires ItemCheck for every pre-checked row it adds). The tick/untick CUE must stay silent /// for all of these, not just device-list changes: on startup the connected-peers list is focused /// (see the Shown handler's FocusListControl call), so a saved-peer reconnect rebuilding that list /// would otherwise click a checkbox sound at launch. Only a genuine user toggle — no flag set — /// should click. private bool SuppressingCheckSounds => suppressDeviceCheckChange || suppressConnectedCheck || suppressDiscoveredCheck || suppressRememberedCheck; private string lastConnectedListSignature = string.Empty; private string lastDiscoveredListSignature = string.Empty; private string lastRememberedListSignature = string.Empty; // Local audio bind port. Was a user-editable spinner; removed from the UI on 2026-05-01. // Unified on 2026-05-05: receiver bind, LAN peer-to-peer dials, and the relay all use a // single canonical port (RemPacket.DefaultPort = 47830). New manual peers without an // explicit ":port" suffix default to that, so users never have to type a port for any // common case — Tailscale, LAN, or a relay server. private const int LocalAudioPort = RemPacket.DefaultPort; // The Enable-logs UI is in PreferencesDialog now. Runtime state is logFile.Enabled. // --- Continuous auto-tune state (mirror controls live in the dialog) --- private readonly System.Windows.Forms.Timer continuousTuneTimer = new(); private readonly Queue recentMaxGaps = new(); // Last observed value of receiver.SessionsOpenedCount. When this number increases between // SNAP ticks, a new StreamSession has just opened — the recent-gap and render-callback // queues contain measurements taken before the new session started (potentially including // a multi-second cross-session arrival gap), so we flush them and bump // lastSourceChangeUtc to defer the next auto-tune tick. Without this, the auto-tune would // see the stale gap and recommend an absurd latency target that prevents the new session // from ever arming. See the matching diagnostics.ResetGapMeasurements() inside // AudioReceiver.HandleFormat. 2026-05-11 fix. private long lastObservedSessionsOpenedCount; // Parallel rolling window of measured render-callback gaps. Auto-tune previously assumed a // hardcoded 10ms render period (sized for shared-mode WASAPI), which over-estimated the // recommendation by 8ms+ on ASIO with small buffers (real callback period ~1ms). Tracking // the actual measurement lets the formula reflect reality. Same window length as the gap // queue so they share the lookback discipline. private readonly Queue recentRenderCbGaps = new(); private const int RecentMaxGapWindowSeconds = 60; private DateTime lastUserSliderMoveUtc = DateTime.MinValue; private bool suppressUserSliderMoveTracking; // true while continuous tune is changing the slider private bool continuousTuneEnabled; private int continuousTuneIntervalSec = 5; private long lastObservedUnderrunCount; private HeartbeatService? heartbeatService; // Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address. // Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash). private DateTime lastAddressAdoptionUtc = DateTime.MinValue; // Tracks whether each peer was last considered CONNECTED, for the connect/disconnect cues. // "Connected" now means audio is actually flowing OR the heartbeat is healthy — not the // heartbeat alone (see DetectAndAnnouncePeerHealthTransitions). The bool, rather than the // raw health state, gives natural hysteresis: once connected we stay connected until audio // genuinely stops AND the heartbeat goes unreachable, so a heartbeat blip while audio keeps // playing never fires a false disconnect cue. 2026-05-31 rewrite. private readonly Dictionary peerConnectedState = new(StringComparer.OrdinalIgnoreCase); private CuePlayer? connectSound; private CuePlayer? 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 CuePlayer? recordStartSound; private CuePlayer? recordStopSound; // Profile-save and profile-switch cues, added 2026-05-28 alongside the move of all // default WAVs into a sounds\ subfolder. Save fires after a successful File → Save / // Save As; Profile fires immediately after a profile finishes loading in MainForm. private CuePlayer? saveSound; private CuePlayer? profileSwitchSound; private CuePlayer? profileMenuOpenSound; private CuePlayer? updateSound; // Machine-wide cues (2026-06-13): send/receive toggled on/off, and minimise(hide)/restore(show). private CuePlayer? sendOnSound; private CuePlayer? sendOffSound; private CuePlayer? receiveOnSound; private CuePlayer? receiveOffSound; private CuePlayer? hideSound; private CuePlayer? showSound; // 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. private MnemonicLabel? sendOutputDevicesLabel; private MnemonicLabel? sendInputDevicesLabel; private MnemonicLabel? receiveOutputDevicesLabel; // Set when the user ticks/unticks a source. Auto-tune skips for one interval afterward so the // brief settling jitter on a newly-added capture doesn't bias the recommendation upward. private DateTime lastSourceChangeUtc = DateTime.MinValue; // --- Peer state --- private readonly Dictionary knownPeers = []; private readonly Dictionary manualPeers = []; private readonly Dictionary rememberedPeerInstanceIds = new(StringComparer.OrdinalIgnoreCase); // Endpoint targets the user has ticked. STICKY — once a peer is selected, its IP/port stays // here regardless of whether discovery currently sees it. Discovery turnover (peer briefly // offline, NIC blips, sleep, etc.) does NOT untick or stop the sender. UDP just keeps flowing // toward the cached IP; if no one's home, packets disappear, and they resume the moment the // peer comes back. Neither machine has to be online "first" or "in order". // // Key: peer instance Guid (or generated one for IP-only manual entries). // Value: last-known endpoint. If discovery sees the same instance with a new address (DHCP // renewal etc.) we update the value but keep the key. private readonly Dictionary selectedPeerEndpoints = []; // Display labels for selected peers so we can render them in the dialog list even when // discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline"). private readonly Dictionary selectedPeerLabels = []; private readonly Dictionary lastFocusedListIndices = []; private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 }; // Device-list refresh. As of v3.4 this is EVENT-DRIVEN, not polled: an // AudioDeviceChangeNotifier registers for Windows audio endpoint-change notifications and // pokes this timer when the device set actually changes (USB hot-plug / unplug, default-device // change). The timer then acts as a one-shot DEBOUNCE — a burst of add/remove/default-changed // callbacks collapses into a single RefreshAudioDeviceLists ~750 ms after the last one, so the // listboxes (and NVDA) are only touched when something truly changed, and zero work happens // while nothing is being plugged or unplugged. If notification registration ever fails we fall // back to the pre-v3.4 periodic poll (deviceRefreshOneShot = false, 3 s). This replaces the old // 3-second poll that re-enumerated every WASAPI device — and re-opened the ASIO driver — on // every tick regardless of whether anything had changed. private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 750 }; // True when deviceRefreshTimer is a one-shot debounce (notification-driven — the normal case); // false when it's the periodic-poll fallback. Controls whether the Tick handler stops the timer. private bool deviceRefreshOneShot = true; // Windows audio endpoint-change notifier — drives the debounced device-list refresh. Null until // wired in the constructor; disposed in FormClosing (which unregisters the COM callback). private AudioDeviceChangeNotifier? deviceChangeNotifier; // Receive-output device IDs the user/profile selected — kept even while a device is unplugged, // so a card that returns is silently re-ticked and re-opened (issue #5: recover after USB // unplug). Receive-only: the send lists deliberately don't persist selection (AudioDeviceCatalog). private readonly HashSet rememberedReceiveOutputIds = new(StringComparer.OrdinalIgnoreCase); // Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler // wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts // (NVDA users typically press a few keys in quick succession to scan through items), // short enough that a deliberate selection feels responsive. Auto-stop on Tick. private readonly System.Windows.Forms.Timer asioDriverChangeDebounce = new() { Interval = 300 }; private string sendOutputDevicesSignature = string.Empty; private string sendInputDevicesSignature = string.Empty; private string receiveOutputDevicesSignature = string.Empty; private string asioSendDevicesSignature = string.Empty; private string asioReceiveOutputDevicesSignature = string.Empty; private string? cachedAsioProbeDriverName; private AsioDriverProbeResult? cachedAsioProbeResult; private bool cachedAsioProbeFailed; // ASIO drivers RemSound must never touch (e.g. the handle-leaking Realtek ASIO driver), // mirrored from AppConfig.DisabledAsioDrivers at startup so the device refresh can check // without disk I/O. Updated when the user disables/enables via the warning or Options menu. private readonly HashSet disabledAsioDrivers = new(StringComparer.OrdinalIgnoreCase); // Realtek ASIO drivers found installed at startup (name contains "Realtek"). Drives the // one-time compatibility warning and the Options-menu enable/disable toggle. private List realtekAsioDriverNames = new(); private ToolStripMenuItem? realtekAsioToggleItem; // True while we're rebuilding a CheckedListBox programmatically — suppresses the per-item // ItemCheck handler so re-adding pre-checked items doesn't fire ApplyAudioRuntime per item. private bool suppressDeviceCheckChange; private bool connected; private DateTime connectedSinceUtc = DateTime.MinValue; private DateTime lastSnapshotUtc = DateTime.MinValue; private DateTime lastCaptureZeroLogUtc = DateTime.MinValue; // Full set of selected send endpoints (one per resolved peer address). The heartbeat pings // ALL of these; the audio sender is armed with the subset that isn't long-unreachable — see // RefreshAudioReceivers. Stored so the per-tick re-filter doesn't re-resolve addresses. private IPEndPoint[] allSendEndpoints = []; // Cached "ip:port|ip:port" signature of the endpoints currently armed for AUDIO, so the // per-tick refresh only calls SetReceivers when the armed set actually changes. null forces // a re-push (set when the selected-peer set changes). private string? activeAudioReceiverSignature; // How long an endpoint must be continuously unreachable before we stop sending the audio // stream to it. Well beyond the heartbeat's 5s UnreachableWindow so a transient blip never // interrupts audio to a healthy peer. The endpoint stays in the heartbeat's tracked set, so // when it recovers it's automatically re-armed. Stops the "peer hostname resolves to a live // LAN IP plus a dead Tailscale IP, so we upload the whole stream twice" waste. private static readonly TimeSpan AudioPruneUnreachableAfter = TimeSpan.FromSeconds(30); private bool firstCaptureCallbackLogged; private bool firstSenderPacketLogged; private bool firstReceiverPacketLogged; // Counter for SnapshotLogIfDue's periodic native-memory reaper. Increments once per // snapshot tick (~1 Hz) and triggers a forced gen2 + finalizer flush every 300 ticks // (~5 minutes). See the inline comment in SnapshotLogIfDue for the full rationale. private int nativeReaperTickCount; // Counts status ticks (~1 Hz) so the heavier handle-TYPE probe runs on a slow cadence // (~once a minute) rather than every diag line. 2026-06-07, for the receiver handle leak. private int handleProbeTickCount; // 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. // prevDiagDriftDrops / prevDiagDriftReps removed 2026-05-23. Drift drop/repeat counters // were dead since the Phase-4 fixed-ratio resampler design (always zero); diag columns // are gone too. private long prevDiagConceal; private long prevDiagShortRead; private long prevDiagDeviceGulp; 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; // 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; // Per-process CPU% / memory / allocation / GC meter — drained once per second by the // diag emitter. New 2026-05-22, item 1 + 3 of RemSoundefficiency.md. Carries no cost // when logs are off because the diag emitter is itself gated. private readonly ProcessSelfMeter processSelfMeter = new(); // 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 // slider, ticked peers) is applied later in OnShown via ApplyPendingProfileToControls() // because the device lists aren't populated until then. NextProfileTitleToLoad is read by // Program.cs after the form closes; non-null means "user clicked Switch in Manage profiles — // 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 active profile's encryption password, in PLAIN text (the profile JSON stores it // lightly scrambled — see RemSoundCrypto.Obfuscate). "" = no password set. Two peers can // exchange audio only when their profile passwords match. Set from the loaded profile in // the constructor, changed via File → Change this profile's password, and carried back into // every save by BuildCurrentProfile. 2026-05-31 (always-on encryption, in development). private string currentProfilePassword = ""; // The AES key + fingerprint derived from currentProfilePassword, cached so the slow key // derivation only runs when the password actually changes. Pushed down to the sender and // receiver by RecomputeAudioCrypto. Null when no password is set (then no audio flows). private byte[]? currentAudioKey; private byte[]? currentAudioFingerprint; private string? lastDerivedPassword; // Re-entrancy guard for the "you need a password to stream" gate, so programmatically // un-ticking the send/receive box (when the user cancels the password prompt) doesn't // re-fire the gate. And a record of which peers we've already warned about a password // mismatch / out-of-date version, so the warning shows once per change, not every second. private bool suppressStreamingPasswordGate; private readonly Dictionary lastSecurityWarned = new(); // 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; /// Full filesystem path of the active profile's JSON file. Tracked separately /// from because Save As (2026-05-10) lets the user /// write a profile to an arbitrary path outside . /// Save / Rename operate on this path so they update / rename the file the user is /// actually editing — not whatever happens to be in BaseDirectory under the same name. /// Null on Blank template. private string? currentProfilePath; private Profile? pendingProfile; public string? NextProfileTitleToLoad { get; private set; } /// Set by File → New profile. Program.cs's relaunch loop checks this FIRST and, when /// true, rebuilds the form on a fresh blank template (, no title) /// instead of loading a saved profile. It's the only way to reach a blank template mid-session /// — the fix for being unable to create a new profile when "start with a specific profile" /// boots the user straight past the picker (issue #6). public bool LoadBlankTemplateNext { get; private set; } /// Full path of the next profile to load, set when the user opens a file via /// File → Open profile. Program.cs prefers this over /// when non-null — it deserialises the JSON from this exact path, not from the active /// store's base directory. Lets Open profile work for files saved outside that folder. public string? NextProfilePathToLoad { get; private set; } // Set true by MarkProfileDirty() when the user actively changes something. Used as a // fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip // the diff entirely when no user action has happened. Cleared on save and on profile load. private bool unsavedChanges; // Skip MarkProfileDirty calls while we're programmatically applying a loaded profile. private bool applyingProfile; // Set true the instant an auto-update hands off and we're about to exit to let the // installer restart us. While this is set, the close path skips EVERY prompt — the // update is a deliberate, unattended action and no dialog (least of all the unsaved- // changes prompt, whose default button is Cancel) may be allowed to abort the restart. // This was the second link in Andre's runaway: a stray Enter/Escape on the save prompt // cancelled the update's exit, so the new version never came up cleanly. private bool updatingInProgress; // Set once an install has actually begun (download + stage + helper launch). Guards against // firing the installer twice from a single process: the ~4 s startup check and the periodic // background poll can both surface the same release in quick succession, and without this // each would stage and spawn its own helper. Cross-PROCESS duplication is prevented by the // single-instance lock in Program.Main; this is the within-process half of that protection. private bool updateInstallStarted; /// Set when the user changed the profiles FOLDER (not just switched profile) /// via the Manage Profiles dialog. Program.cs reads this after the form closes; if true, /// it re-runs the entire profile selection flow under the new folder rather than the /// cheap "switch within current folder" path. Mutually exclusive with /// in practice. public bool ReloadFromScratch { get; private set; } /// Bring this window to the front, restoring it from the system tray if it's /// parked there. Called when the user launches a SECOND copy of RemSound and the single- /// instance guard chooses "switch to the running copy" — the second copy signals this one /// to surface instead of starting another process. Safe to call from any thread: it /// marshals to the UI thread itself. Reuses the tray controller's Restore (Show + /// SetForegroundWindow), which works whether the window is in the tray or just behind /// other windows. public void RestoreFromTray() { if (IsDisposed) return; try { if (InvokeRequired) { BeginInvoke(new Action(RestoreFromTray)); return; } trayController.Restore(); // Land focus on a real, named control on whichever tab is showing, so NVDA announces // something when the window comes back from the tray. Without this, focus rests on the // QuietTabControl (deliberately role-less / nameless) and a screen reader has nothing to // read, so the window surfaces silently — the same issue the Preferences dialog had. // Deferred so it runs after the show/foreground settles. BeginInvoke(new Action(() => { if (IsDisposed) return; WinEventNotifier.AnnounceByFocusingLeaf(this, mainTabControl.SelectedTab, mainTabControl); })); } catch { /* best-effort — surfacing the window is a convenience, not load-critical */ } } public MainForm() : this(null, null, null, null) { } public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null) { this.profileStore = profileStore; currentProfileTitle = loadedTitle; // Resolve the active profile's full path from whichever bit of info Program.cs // passed in. If a path was explicitly given (Open-from-arbitrary-folder flow), // honour it. Otherwise infer from the store's BaseDirectory + sanitised title. // Null when on Blank template (no file to track). if (!string.IsNullOrEmpty(loadedPath)) { currentProfilePath = loadedPath; } else if (profileStore is not null && !string.IsNullOrEmpty(loadedTitle)) { currentProfilePath = profileStore.PathFor(loadedTitle); } // Track the loaded profile in the machine-local recents list so the File → Recent // profiles submenu can offer it next time. Skipped for the blank-template case // (currentProfilePath stays null when no profile was loaded). 2026-05-15. if (!string.IsNullOrEmpty(currentProfilePath)) { try { var cfg = AppConfig.Load(); cfg.NoteRecentProfile(currentProfilePath); cfg.Save(); } 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; // Unscramble the profile's stored password into the in-memory plain-text working value. currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password); // 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. if (profile is not null) settings.ApplyProfile(profile); // BothModeWarningSuppressed migration removed 2026-05-11. The popup it suppressed // (the classic-Both ~45 ms latency warning) is gone with the audio-mode listbox, so // there's nothing to suppress any more. Old profile JSONs that still contain the // field deserialise with it ignored. Text = FormatWindowTitle(loadedTitle); Width = 640; Height = 600; MinimumSize = new Size(560, 520); StartPosition = FormStartPosition.CenterScreen; // No AccessibleName / AccessibleRole on the form. Andre's accessible app does not // set these and NVDA reads cleanly there; setting them here was over-engineering. // Set the checkbox visible Text (with mnemonic) AND AccessibleName here in the // constructor body. The working RSound.old build used this two-step pattern; setting // these inline in the field initializer was enough to break NVDA state-change // announcements on toggle. // Explicit "(Alt+letter)" suffix on every shortcut-bearing label so both sighted users // and NVDA see/hear the shortcut consistently. The previous WinForms `&letter` mnemonic // auto-derivation was unreliable in our layout (FlowLayoutPanel-wrapped lists broke // the framework's label-to-control association heuristic). ProcessCmdKey handles every // activation explicitly. Keeping visible label and AccessibleName identical, per Ed's // "labels are one phrase used twice" rule. receiveAudioCheckbox.Text = "Receive audio (Alt+&R)"; receiveAudioCheckbox.AccessibleName = "Receive audio"; sendMyAudioCheckbox.Text = "Send my audio (Alt+&S)"; sendMyAudioCheckbox.AccessibleName = "Send my audio"; hotkeyController = new MainFormHotkeyController( settings, () => sendMyAudioCheckbox.Checked = !sendMyAudioCheckbox.Checked, () => receiveAudioCheckbox.Checked = !receiveAudioCheckbox.Checked, ToggleTrayFromHotkey, () => NudgeVolume(+5), () => NudgeVolume(-5), // Global Start / Stop recording. Same ToggleRecording path the Record menu item // and the in-app Ctrl+R use — the hotkey just makes it work without RemSound // having keyboard focus. ToggleRecording, // Three remote-control hotkeys: each one transmits a Control packet to all // currently-tracked peers via the audio sender's NAT pinhole. The receiving peer // applies the change locally if it has Profile.AcceptRemoteVolumeCommands on. // See SendRemoteControl for the dispatch detail. () => SendRemoteControl(RemoteControlKind.VolumeUp, +5), () => SendRemoteControl(RemoteControlKind.VolumeDown, -5), () => SendRemoteControl(RemoteControlKind.MuteToggle, 0), // Three Windows-global-volume hotkeys: each press makes connected peers nudge // their Windows default-output-device master volume by one OS-native step (~2%). // delta=0 — system commands ignore the delta byte, the per-press step size is // fixed by Windows. Hold the hotkey for bigger jumps. () => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0), () => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0), () => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0), ShowQuickProfileSwitch, // Speak the status line aloud through the active screen reader (issue #13). Screen-reader // specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts). SpeakStatusLine); // Pipe hotkey controller diagnostics into the main log so we can see, e.g., // "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and // "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409: // another app or another RemSound process already registered this combo)". // The user gets the regular MessageBox warning on registration failure; the log // captures the cause so we can debug without guessing. hotkeyController.Log = msg => logFile.Event($"hotkey: {msg}"); // Hotkey edits via the Keyboard shortcuts dialog need to mark the profile dirty // so the close-without-saving prompt fires. The dirty flag is only set by direct // UI handlers in MainForm; the controller is its own object so it can't reach // MarkProfileDirty without being told how. Without this hook the user would change // a binding, close, get no prompt, launch again — and find their new binding // wasn't in the profile JSON. (The settings cache holds it, but the cache is // copied to the profile only on Save / Update, not on close.) hotkeyController.OnHotkeyChanged = () => { MarkProfileDirty(); // Keep the spoken "press X anywhere" hints in sync with the new binding. UpdateHotkeyAnnouncements(); }; trayController = new MainFormTrayController( this, // getSending / toggleSending — the tray's "Enable sending" checkable item reads // and toggles the main-window send checkbox. Toggle (not set-to-true) so right- // clicking it twice doesn't get the user stuck on. 2026-05-28 redesign. getSending: () => sendMyAudioCheckbox.Checked, toggleSending: () => sendMyAudioCheckbox.Checked = !sendMyAudioCheckbox.Checked, getReceiving: () => receiveAudioCheckbox.Checked, toggleReceiving: () => receiveAudioCheckbox.Checked = !receiveAudioCheckbox.Checked, // Profiles submenu — list of recent profile file paths read live from AppConfig // every time the submenu opens, so newly-loaded profiles appear immediately. getRecentProfilePaths: () => AppConfig.Load().RecentProfiles, switchToProfile: path => SwitchToRecentProfile(path), // Tooltip builder — called once just before the tray icon first becomes visible // (in Minimize) so the shell's NIM_ADD sees the current live state instead of a // stale "starting up" string. Subsequent updates ride on the 1 Hz snapshot tick. buildTooltip: BuildTrayTooltip, exit: 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 // WinForms `&letter` auto-derivation. The auto-derivation went wrong because the lists // are wrapped in a FlowLayoutPanel, which breaks the framework's "label associated with // the next focusable" heuristic. ProcessCmdKey is what actually performs the focus // change. Per Ed's working rule "labels are one phrase used twice", visible text and // AccessibleName here are kept identical. // 2026-05-08 NVDA-announce fix — embed "(Alt+X)" in AccessibleName for non-CheckBox // controls. The framework's auto-derivation of KeyboardShortcut from a labelled-by // MnemonicLabel is unreliable inside FlowLayoutPanel-wrapped rows (sometimes picks // up the wrong row's label, sometimes finds nothing). Putting the shortcut in the // AccessibleName text guarantees NVDA announces it consistently right after the // control name. CheckBoxes own their own &-mnemonic via their Text and don't need // the suffix in AccessibleName — they're left as bare names. volumeBar.AccessibleName = "Set volume for all received audio (Alt+V)"; receiveOutputDevicesList.AccessibleName = "WASAPI outputs for received sound (Alt+3)"; receiveOutputDevicesStatusLabel.AccessibleName = "Selected receive output device status"; sendOutputDevicesList.AccessibleName = "WASAPI outputs to send (Alt+4)"; sendOutputDevicesStatusLabel.AccessibleName = "Selected output device status"; sendInputDevicesList.AccessibleName = "WASAPI inputs to send (Alt+5)"; sendInputDevicesStatusLabel.AccessibleName = "Selected input device status"; asioReceiveOutputDevicesList.AccessibleName = "ASIO outputs for received sound (Alt+1)"; asioReceiveOutputDevicesStatusLabel.AccessibleName = "Selected ASIO receive channel status"; asioSendDevicesList.AccessibleName = "ASIO inputs to send (Alt+2)"; asioSendDevicesStatusLabel.AccessibleName = "Selected ASIO send channel status"; // Keyboard shortcuts / Minimise to tray / Save / Save as buttons retired 2026-05-08 // (now File menu items in BuildFileMenu). asioDriverBox.AccessibleName = "ASIO driver (Alt+D)"; // "Uncheck all inputs and outputs on all soundcards" — clears every device tick in one // press. Button owns its own &-mnemonic (Alt+U), so AccessibleName stays clean per Ed's // mnemonic convention. uncheckAllDevicesButton.Text = "Uncheck all inputs and outputs on all soundcards and set ASIO driver to none (Alt+&U)"; uncheckAllDevicesButton.AccessibleName = "Uncheck all inputs and outputs on all soundcards and set ASIO driver to none"; uncheckAllDevicesButton.Click += (_, _) => UncheckAllDevices(); // Populate ASIO driver list at startup. Discovers all ASIO drivers via NAudio + a // registry scan covering 32-bit + 64-bit + HKLM + HKCU views (some drivers register in // unusual places). The "(none)" sentinel is always row 0 so the user can return to // WASAPI-only without uninstalling drivers; if no real drivers are found at all, the // driver picker is hidden entirely in BuildAudioIOTab and the form runs WASAPI-only. var asioDriverNames = AsioDeviceProbe.EnumerateDriverNames(); hasAnyAsioDriverInstalled = asioDriverNames.Count > 0; // Mirror the per-machine "never touch this driver" list (global config) so the picker can // hide disabled drivers and the device refresh can skip them without disk I/O. var realtekStartupConfig = AppConfig.Load(); disabledAsioDrivers.Clear(); foreach (var d in realtekStartupConfig.DisabledAsioDrivers) disabledAsioDrivers.Add(d); // Realtek's bundled ASIO driver leaks OS handles on every open — flag any installed Realtek // ASIO driver so OnShown can offer to disable it and the Options menu can toggle it. realtekAsioDriverNames = asioDriverNames.Where(n => AppConfig.IsRealtekAsioDriver(n)).ToList(); logFile.Event($"asio drivers enumerated at startup: [{string.Join(", ", asioDriverNames.Select(n => $"\"{n}\""))}]" + (disabledAsioDrivers.Count > 0 ? $"; disabled in RemSound: [{string.Join(", ", disabledAsioDrivers)}]" : "") + (realtekAsioDriverNames.Count > 0 ? $"; realtek detected: [{string.Join(", ", realtekAsioDriverNames)}]" : "")); asioDriverBox.Items.Add(NoAsioDriverSentinel); foreach (var name in asioDriverNames) { if (disabledAsioDrivers.Contains(name)) continue; // hidden — RemSound won't touch it asioDriverBox.Items.Add(name); } // Restore the previously-chosen driver if it's still installed; otherwise land on the // "(none)" sentinel. We deliberately do NOT auto-pick the first real driver — the user // opts in by arrowing down to a driver row themselves. This is the "driver dropdown // IS the mode switch" design (2026-05-11): default off, explicit user action turns // ASIO on. var savedDriver = settings.LoadAsioDriverName(); if (!string.IsNullOrWhiteSpace(savedDriver) && asioDriverBox.Items.Contains(savedDriver!)) { asioDriverBox.SelectedItem = savedDriver; } else { asioDriverBox.SelectedIndex = 0; // "(none)" } // Debounced driver-change. Each SelectedIndexChanged restarts the timer; the actual // apply runs once 300 ms after the user stops moving. Reasons: // 1. Arrowing through 5 drivers to read their names should not tear down + reopen // the COM object 5 times — single-client drivers can get confused by rapid // open/close churn. Timer collapses the burst into one apply at the end. // 2. Each apply auto-unticks the ASIO send/receive channel rows (see comment in // the timer Tick handler) — we don't want to thrash that on every arrow press. asioDriverBox.SelectedIndexChanged += (_, _) => { asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Start(); }; asioDriverChangeDebounce.Tick += (_, _) => { asioDriverChangeDebounce.Stop(); var selected = asioDriverBox.SelectedItem as string; // Translate the "(none)" sentinel into a real null at the settings boundary so // the rest of the app sees the legacy "no ASIO driver chosen" shape. var newDriver = string.Equals(selected, NoAsioDriverSentinel, StringComparison.Ordinal) ? null : selected; var previousDriver = settings.LoadAsioDriverName(); settings.SaveAsioDriverName(newDriver); var driverActuallyChanged = !string.Equals(previousDriver, newDriver, StringComparison.OrdinalIgnoreCase); if (driverActuallyChanged) MarkProfileDirty(); if (driverActuallyChanged) ClearAsioProbeCache(); // When the driver actually changes (including switching to/from "(none)"), clear // ASIO ticks. The synthetic device-id "asio:N" is a pair-index into whichever // driver is loaded; pair 2 of the Audient is a different physical channel from // pair 2 of the Komplete. If we let the old ticks survive a driver swap, the // wrong channels would be captured/rendered until the user noticed and re-ticked. if (driverActuallyChanged) { try { suppressDeviceCheckChange = true; for (var i = 0; i < asioSendDevicesList.Items.Count; i++) asioSendDevicesList.SetItemChecked(i, false); for (var i = 0; i < asioReceiveOutputDevicesList.Items.Count; i++) asioReceiveOutputDevicesList.SetItemChecked(i, false); } finally { suppressDeviceCheckChange = false; } } // The audio mode is now derived from whether a driver is selected — re-applying // here switches sender/receiver between WasapiOnly and BothIndependent as needed. // UpdateBothIndependentVisibility refreshes the ASIO-lane latency row, and // ApplyContinuousTuneTimer re-evaluates which auto-tune lanes need ticking. UpdateBothIndependentVisibility(); ApplyContinuousTuneTimer(); ApplyAsioMode(); }; healthLabel.AccessibleName = "Connection health"; statusLabel.AccessibleName = "Status"; codecBox.AccessibleName = "Audio codec (Alt+C)"; maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)"; // --- Populate static choices --- // Three transport choices, ordered most-tolerant-of-bad-networks to most-demanding: // * PCM 48K 24-bit — uncompressed, ~2.3 Mbps // * Opus broadcast quality — 20 ms frame (960 samples/ch at 48 kHz), loss tolerant // * Opus live latency — 2.5 ms frame (120 samples/ch at 48 kHz), 8× the packet // rate of broadcast quality, for jamming / live monitoring // The 10 ms middle option (480 samples/ch) that lived here in v2.x has been retired — // it sat between the other two without a clear use case (saved only 5 ms over 20 ms // and gave up loss tolerance for no clearly audible win). Frame size on the wire is // samples-per-channel at 48 kHz (v3.0 unit). Labels avoid numbers and ms jargon per // the manual's "use case in words" convention; the per-peer status line surfaces the // actual ms figure for users who want to verify. codecBox.Items.AddRange(new object[] { new CodecChoice("PCM 48K 24 bit — uncompressed", AudioTransportCodec.Pcm, 0), new CodecChoice("Opus, broadcast quality — loss tolerant", AudioTransportCodec.Opus, 960), new CodecChoice("Opus, live latency — for jamming and monitoring", AudioTransportCodec.Opus, 120), }); codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameSamplesPerChannel()); var initialCodec = (CodecChoice)codecBox.SelectedItem!; sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameSamples(initialCodec.Codec, initialCodec.OpusFrameSamples, settings.LoadSendRate())); sender.SetSendRate(settings.LoadSendRate()); // Relay-mode plumbing. The sender's UDP socket is always-receiving from form construction // onwards: in LAN peer-to-peer no inbound traffic arrives at this socket (LAN peers send // direct to the receiver's well-known port), but in relay mode this is where audio and // heartbeat replies show up — they come back through the NAT pinhole opened by the first // outbound packet from this socket. We dispatch by packet type to the right pipeline. sender.OnInboundPacket = (buffer, length, remote) => { if (length < RemPacket.HeaderSize) return; if (!RemPacket.TryReadHeader(buffer.AsSpan(0, length), out var type, out _, out _)) return; if (type == RemPacketType.Heartbeat) { heartbeatService?.HandleInjectedPacket(buffer, length, remote); } else { // Format / Audio / KeepAlive — feed into the receiver's existing pipeline as if // it had arrived on the well-known port. Allow-list, session creation, decoder, // and playout all work unchanged — they don't know or care which socket the // packet came in on. receiver.InjectExternalPacket(buffer, length, remote); } }; sender.StartReceiving(); // Tight-latency mode is now sender-side only (per-callback PCM emission in ASIO mode). // The receiver-side hook was removed in the 2026-05-06 cleanup since the resampler is // no longer in the receive path. The dialog checkbox label still says "Lock to audio // clock" but only affects the sender now. var initialTightLatency = settings.LoadTightLatencyMode(); sender.SetTightLatency(initialTightLatency); // Log it so post-test analysis can correlate clicks with tight-latency state without // having to infer from sender-engine restarts. Includes the audio mode because what // "tight" means is mode-dependent (per-callback ASIO emission vs. WASAPI push-mode). logFile.Event($"tight latency at startup: {(initialTightLatency ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); // Priority mode (per-profile). Applies every PerformanceMode lever on first launch // under this profile so the OS doesn't start coasting before the user has tabbed // onto the Audio profile tab. The Audio-profile tab's checkbox handler re-applies // on every toggle. PerformanceMode.Apply(settings.LoadPriorityMode(), msg => logFile.Event(msg)); // Native-rate passthrough is automatic now (driven by codec, not a user setting): // PCM+single-source-WASAPI-push = pass capture-device rate through to the wire; // Opus = always pre-resample to 48 kHz (encoder is locked at 48 k); MixingEngine / // ASIO sender = always 48 kHz on the wire. Nothing for the user to toggle. receiver.SetSmoothness(settings.LoadSmoothness()); receiver.SetConcealmentArtifact(settings.LoadConcealmentArtifact()); // Continuous auto-tune state — UI lives in the Connectivity & transport dialog. continuousTuneEnabled = settings.LoadContinuousAutoTuneEnabled(); continuousTuneIntervalSec = settings.LoadContinuousAutoTuneIntervalSec(); maxLatencyBox.Value = Math.Clamp(settings.LoadMaxLatencyMs(), (int)maxLatencyBox.Minimum, (int)maxLatencyBox.Maximum); // Select-all-on-focus for the numeric spinners. Fixes the WinForms default where typing // a new value into a NumericUpDown that already shows "80" produces "8010" instead of // "10". The Enter event fires when the control receives focus (keyboard or click); we // post a select-all to it so the cursor lands on a fully-selected value, and any // typed digits replace the selection. Applies to both the form and dialog instances. SelectAllOnFocus(maxLatencyBox); // Push the slider's value to the receiver. In classic modes that's the Mixed route // (legacy behaviour); in BothIndependent the slider drives the WasapiLane route. The // ASIO-lane initial push happens later in WireBothIndependentControls once the // companion control has been created and its loaded value applied. receiver.SetMaxLatencyMsFor(MaxLatencyBoxRoute, (int)maxLatencyBox.Value); // Apply the user's "enable logs" preference to the log gate. Logging is a // machine-local debug knob stored in AppConfig (default off) — switching profiles // doesn't change it. RemSoundLog defers actually creating the file in // \logs\ until the first write arrives while Enabled is true, so an idle "off" // setting produces zero filesystem traffic. The Preferences dialog's Enable-logs // checkbox writes through to both AppConfig.LoggingEnabled and logFile.Enabled when // the user toggles it. logFile.Enabled = AppConfig.Load().LoggingEnabled; // DiagnosticsGate gates the engine's hot-path instrumentation (sender/receiver // max-time probes, spike detector, callback-gap timers) so the audio threads pay // zero cost when nobody is going to read the numbers. It's ON whenever either the // Enable-logs checkbox is on OR continuous auto-tune is on (auto-tune needs the // same per-second diag data the log emits). Real initial value is set after the // settings cache has finished loading; see the call further down. We seed it false // here so any early probe fires before the settings load are a no-op. DiagnosticsGate.Enabled = false; if (logFile.Enabled) AppendLogEntry("logging enabled at startup"); // Sender diagnostic events (capture started, errors, etc.) get written to the log file. sender.Diagnostic = msg => logFile.Event($"sender: {msg}"); receiver.Diagnostic = msg => logFile.Event($"receiver: {msg}"); // Pre-load all cue sounds so the first playback isn't delayed by file I/O. Default // WAVs are deployed to a sounds\ subfolder under RemSound.exe (see RemSound.App // .csproj Content rules); the per-cue custom-path overrides in AppConfig.CustomCuePaths // are honoured by TryLoadCueSound when set. TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound); TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound); TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound); TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound); TryLoadCueSound(CueId.Save, "save.wav", out saveSound); TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound); TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound); TryLoadCueSound(CueId.Update, "update.wav", out updateSound); TryLoadCueSound(CueId.SendOn, "send on.wav", out sendOnSound); TryLoadCueSound(CueId.SendOff, "send off.wav", out sendOffSound); TryLoadCueSound(CueId.ReceiveOn, "recieve on.wav", out receiveOnSound); TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound); TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound); TryLoadCueSound(CueId.Show, "maximise.wav", out showSound); LoadAudioDevices(); // Apply persisted ASIO mode from settings — switches sender/receiver backends so the // device-list refresh below populates with the right kind of entries (WASAPI endpoints // or ASIO channel pairs). ApplyAsioMode(); // --- Wire main-form events --- receiveAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(receiveAudioCheckbox); sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox); // Send/receive have their own dedicated cue sounds (send/receive turned on/off). When that // dedicated cue is on, only it plays - suppress the generic checkbox tick/untick on these two. // When the dedicated cue is "(none)", these return false and the checkbox sound plays as normal. sendMyAudioCheckbox.SuppressCheckSound = on => on ? AppConfig.Load().EnableSendOnCue : AppConfig.Load().EnableSendOffCue; receiveAudioCheckbox.SuppressCheckSound = on => on ? AppConfig.Load().EnableReceiveOnCue : AppConfig.Load().EnableReceiveOffCue; volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); }; WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device"); receiveOutputDevicesList.ItemCheck += (_, e) => { if (suppressDeviceCheckChange) return; // Track the user's intent so a card that's later unplugged is re-ticked + re-opened when // it returns (issue #5). See ReapplyRememberedReceiveOutputs. if (receiveOutputDevicesList.Items[e.Index] is AudioDeviceChoice c && c.DeviceId is { } rid) { if (e.NewValue == CheckState.Checked) rememberedReceiveOutputIds.Add(rid); else rememberedReceiveOutputIds.Remove(rid); } BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); }; WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device"); WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device"); sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; sendInputDevicesList.ItemCheck += (_, args) => { if (suppressDeviceCheckChange) return; logFile.Event($"ui: capture (WASAPI mic) '{sendInputDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}"); BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); // Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app // microphone access — capture would open but silently send nothing. Deferred so the // tick commits first and the modal doesn't re-enter ItemCheck. Skipped while a profile // is being applied: the startup check (MaybeWarnMicBlockedOnStartup) owns the warning // then, so a launch into a blocked-mic profile doesn't pop it twice. if (!applyingProfile && args.NewValue == CheckState.Checked && IsMicrophoneBlockedByWindowsPrivacy()) { BeginInvoke(new Action(WarnMicrophoneBlockedByWindowsPrivacy)); } }; // ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones. WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel"); WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel"); asioReceiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } }; asioSendDevicesList.ItemCheck += (_, args) => { if (!suppressDeviceCheckChange) { logFile.Event($"ui: capture (ASIO) '{asioSendDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}"); BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; // Profile-management button click wirings retired 2026-05-08 — File menu items now // call SaveProfileAs() / UpdateExistingProfile() / hotkeyController.ShowKeyboardShortcutsDialog // / trayController.Minimize() directly. See BuildFileMenu. // --- Settings shared with dialog --- codecBox.SelectedIndexChanged += (_, _) => { if (codecBox.SelectedItem is CodecChoice item) { settings.SaveCodec(item.Codec); if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameSamplesPerChannel(item.OpusFrameSamples); var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, settings.LoadSendRate()); sender.ConfigureCodec(item.Codec, effectiveSamples); logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveSamples / 48.0:0.##}ms" : "")}"); MarkProfileDirty(); } }; maxLatencyBox.ValueChanged += (_, _) => { // Track when the user (vs continuous auto-tune) moved the slider, so the auto-tune // can defer to the user's intent for a few seconds before adjusting again. // suppressUserSliderMoveTracking is set by both continuous auto-tune AND the manual // one-shot tune button while they're driving the slider — anything where the user // didn't physically move the control. We use the same flag to take the soft path // through the receiver: auto-tune lowers don't drain (drift corrector handles it), // so the slider can drift down silently when conditions improve. Manual user // lowers still drain, since the user is asking for an immediate, responsive change. var fromAutoTune = suppressUserSliderMoveTracking; if (!fromAutoTune) { lastUserSliderMoveUtc = DateTime.UtcNow; // When continuous auto-tune is currently enabled, the latency value is // effectively runtime state (auto-tune will overwrite whatever the user sets // anyway), so don't dirty the profile on latency changes — matches the user's // mental model that "auto-tune on = latency is automatic, not a saved setting". // Toggling the auto-tune checkbox itself still dirties (handled separately on // the checkbox CheckedChanged), so a profile that goes from auto-tune-off to // auto-tune-on is still flagged as needing a save. 2026-05-06. if (!continuousTuneEnabled) MarkProfileDirty(); } settings.SaveMaxLatencyMs((int)maxLatencyBox.Value); // Route the value to whichever route this slider is currently driving. In every // classic mode that's Mixed (the legacy behaviour — single-knob world). In // BothIndependent it's WasapiLane: the slider has been re-labeled "WASAPI // latency" and the user is adjusting only the WASAPI side of the wire. var sliderRoute = MaxLatencyBoxRoute; if (fromAutoTune) { receiver.SetMaxLatencyMsSoftFor(sliderRoute, (int)maxLatencyBox.Value); } else { receiver.SetMaxLatencyMsFor(sliderRoute, (int)maxLatencyBox.Value); } }; // Logging-enabled toggle wiring lives in PreferencesDialog now (it constructs its // own Enable-logs checkbox and writes through via the applyLoggingEnabled callback // we pass it from OpenPreferencesDialog). // --- Discovery --- discovery.PeersChanged += () => BeginInvoke(RefreshKnownPeers); // Continuous auto-tune timer — checkbox/combo live in the dialog and update our state // fields directly. The timer reads from those fields; we just (re)apply it here. continuousTuneTimer.Tick += (_, _) => ContinuousTuneTick(); ApplyContinuousTuneTimer(); // Self-updater background poll. Frequency lives in AppConfig.UpdateCheckFrequency // (the user picks Never / hourly / 6-hour / 24-hour in Preferences). The updater // logs its activity through the same RemSoundLog gate as everything else. updater.Log = msg => logFile.Event($"updater: {msg}"); updateCheckTimer.Tick += (_, _) => CheckForUpdatesInBackground(); ApplyUpdateCheckTimer(); // --- Status / health ticker --- statusTimer.Tick += (_, _) => { // Belt-and-braces: this is a 1 Hz UI tick — a transient WinForms hiccup (e.g. a // stale-index ItemArray throw during a churny peer-list rebuild) must never take // the whole app down with a crash dialog. Log and ride it out; the next tick // recovers. The individual Sync* methods are also hardened (see SafeSelectedItem). try { UpdateStatus(); SnapshotLogIfDue(); EnsureRequestedAudioRunning(); TryAdoptLiveHeartbeatAddress(); // Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces // the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits // when its signature is unchanged so NVDA isn't spammed with re-announcements. SyncAllPeerLists(); } catch (Exception ex) { AppendLogEntry($"status tick: {ex.GetType().Name}: {ex.Message}"); } }; // --- Hot-swap device watcher --- deviceRefreshTimer.Tick += (_, _) => { if (deviceRefreshOneShot) deviceRefreshTimer.Stop(); // debounce: one refresh per change burst RefreshAudioDeviceLists(); }; BuildLayout(); LoadRememberedPeersFromSettings(); // Seed the discovery service's unicast hint list with any remembered peer IPs so that, // the moment we start announcing, those addresses get directly contacted (bridges // Tailscale/VPN where broadcast doesn't traverse). PushDiscoveryUnicastHints(); hotkeyController.Initialize(this); // Announce each configurable global hotkey on the control / menu item it drives, so NVDA // reads "… press Control+Shift+Alt+R anywhere" when you land on it. UpdateHotkeyAnnouncements(); // 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(); deviceRefreshTimer.Stop(); continuousTuneTimer.Stop(); updateCheckTimer.Stop(); asioDriverChangeDebounce.Stop(); try { deviceChangeNotifier?.Dispose(); } catch { } 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. try { PerformanceMode.Apply(false, msg => logFile.Event(msg)); } catch { /* harmless */ } try { discovery.Dispose(); } catch { } try { heartbeatService?.Dispose(); } catch { } // Audio dispose can hang for many seconds on certain ASIO drivers (Audient is the // confirmed offender — it takes 10–20 s to release on close in test logs). Run // sender.Dispose() and receiver.Dispose() on a background thread with a hard // timeout. If they don't finish in 2 seconds we stop waiting and let the rest of // the form-close path run; the OS reclaims any audio resources on process exit. // Worst case the user sees a brief tray-icon stutter; before this they saw a // ~16 s frozen window before the form went away. var audioDispose = Task.Run(() => { try { sender.Dispose(); } catch { /* ignore */ } try { receiver.Dispose(); } catch { /* ignore */ } }); if (!audioDispose.Wait(TimeSpan.FromSeconds(2))) { try { logFile.Event("close: audio dispose taking >2s; letting process exit reclaim"); } catch { } } hotkeyController.Dispose(); ScreenReader.Shutdown(); trayController.Dispose(); logFile.Dispose(); }; Shown += (_, _) => { if (!connected) Connect(); // Apply control-state portion of the loaded profile (device ticks, send/receive // checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are // populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.) // were already pushed into the in-memory settings cache in the constructor. ApplyPendingProfileToControls(); // The profile-switch cue is played ON CLICK by the switch entry points (Recent menu, // quick switch, File open) — NOT here. A fresh launch into the first profile must stay // silent: hearing the switch cue and then the connect cue at startup is confusing // (Ed, 2026-06-08). So the rebuilt form never replays it. // Andre's app gets focus inside the active tab page for free because his form is // a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the // chain TabControl → active TabPage → first child. Our form is the main window, // not a modal dialog, and that walk doesn't always reach a child — focus can rest // on the TabControl itself, which makes NVDA announce "tab control" before // anything else. One explicit Focus() call here mimics Andre's effective behaviour // without otherwise changing the tab control. NOT a tab-change handler — no // auto-jumping when the user arrows between tabs, only on first show. BeginInvoke(() => FocusListControl(connectedPeersList)); // First-launch pass: warn about any cue that's switched on but whose sound file is // missing (deferred so it lands after the window is fully up and can surface). BeginInvoke(CheckForMissingEnabledCues); // "Start minimised" is a COLD-BOOT preference: drop straight to the tray when the app // first launches. It must NOT apply when the user deliberately creates a new profile or // switches profiles — those relaunch the window through the Program.cs loop. Those paths // already set startNextInstanceMinimized to "stay in the tray only if we were already // there", which is the right intent; but ORing in the global StartMinimised used to // override that and hide the window on every new-profile / switch, which read to the user // as a crash (issue #12). So gate StartMinimised to the genuine first launch, and let // relaunches honour only the explicit per-instance flag. // BeginInvoke so the minimise happens *after* Shown completes (otherwise the form-show + // form-hide collide and some virtual-machine drivers throw a redraw exception). The // pending-profile apply path above is unaffected — settings/devices/peers are already // wired up before we hide the window. var coldStart = isFirstLaunch; isFirstLaunch = false; var minimizeThisInstance = startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised); startNextInstanceMinimized = false; if (minimizeThisInstance) { // playCue:false — starting up in the tray (StartMinimised / --minimized) isn't the // user choosing to minimise, so it must not sound the "minimise" cue. BeginInvoke(() => trayController.Minimize(playCue: false)); } // 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. Run on a thread-pool thread because // NatUtility.StartDiscovery() (Mono.Nat 3.0.4) sets up SSDP sockets on every // network interface and CAN BLOCK FOR TENS OF SECONDS, or indefinitely, on // unusual network setups (multiple adapters, VPNs, hostile firewalls, routers // that swallow SSDP). Calling it on the UI thread freezes the WinForms message // pump — Andre's v3.0 hang was this exact pattern. The status label still // updates correctly because StatusChanged fires on the mapper's own thread and // the PreferencesDialog handler BeginInvokes back to the UI thread. 2026-05-23. var startupCfg = AppConfig.Load(); if (startupCfg.UpnpEnabled) { Task.Run(() => { 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. // Skipped on a --silent (automated/throwaway) launch: a test instance must never pop an // "update available" prompt or, worse, silently download/install + restart mid-test. if (startupCfg.CheckForUpdatesOnStartup && !CuePlayer.GloballyMuted) { // 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}"); } }); } // Post-launch notices, shown ONE AT A TIME via a single BeginInvoke that runs them in // sequence — NOT one BeginInvoke per notice. Separate BeginInvokes NEST: the second // dialog opens inside the first's modal message loop, the two stack on top of each // other, and that nesting tangles their modal state so the boxes stop closing cleanly // (the bug where the what's-new About box wouldn't close after the Realtek warning). // RunStartupNotices shows each notice, waits for the user to close it, THEN shows the // next — every one modal to the main window, never nested. BeginInvoke(new Action(RunStartupNotices)); }; statusTimer.Start(); // Hot-plug detection is event-driven (see the deviceRefreshTimer comment): register for // Windows audio endpoint-change notifications and refresh the device lists only when the // device set actually changes. If that registration fails, fall back to the pre-v3.4 // periodic poll. try { deviceChangeNotifier = new AudioDeviceChangeNotifier(OnAudioEndpointsChanged); } catch (Exception ex) { logFile.Event($"device-change notifier failed, using periodic poll: {ex.GetType().Name}: {ex.Message}"); deviceRefreshOneShot = false; deviceRefreshTimer.Interval = 3000; } // Kick one refresh shortly after launch to populate the ASIO channel lists (LoadAudioDevices // only fills the WASAPI lists). After this it's notification-driven; in one-shot mode the // Tick handler stops the timer, in the poll fallback it's the first of the periodic ticks. deviceRefreshTimer.Start(); } /// /// Runs the post-launch notices one at a time — each ShowDialog blocks until the user closes it, /// so the next never opens on top of a still-open one. Order: the what's-new About box (after an /// update), then the Realtek-ASIO compatibility warning. The config-migration notice is handled /// separately in Program.Main (shown before the profile picker), so it's already outside this /// sequence and can't stack with these. /// private void RunStartupNotices() { if (IsDisposed) return; MaybeOfferKeyboardShortcutImport(); if (IsDisposed) return; MaybeShowWhatsNewAfterUpdate(); if (IsDisposed) return; MaybeRunLogHousekeeping(); if (IsDisposed) return; MaybeWarnAboutRealtekAsio(); if (IsDisposed) return; MaybeWarnMicBlockedOnStartup(); } /// Startup log-folder housekeeping driven by the Logging-tab preferences. Both steps are /// opt-in (off by default): first prune logs older than the configured age, then warn if the /// folder still exceeds the configured size. Best-effort — failures never block launch. private void MaybeRunLogHousekeeping() { if (IsDisposed) return; AppConfig cfg; try { cfg = AppConfig.Load(); } catch { return; } if (cfg.PruneOldLogs) { var removed = LogMaintenance.PruneLogsOlderThan(cfg.PruneOldLogsDays, logFile.Path); if (removed > 0) logFile.Event($"log housekeeping: pruned {removed} log(s) older than {cfg.PruneOldLogsDays} day(s)"); } if (cfg.WarnIfLogsFolderExceeds) { var bytes = LogMaintenance.LogsFolderSizeBytes(); var limitBytes = (long)cfg.LogsFolderWarnThresholdMb * 1024 * 1024; if (bytes > limitBytes) { var mb = bytes / (1024.0 * 1024.0); logFile.Event($"log housekeeping: logs folder {mb:0.#} MB exceeds {cfg.LogsFolderWarnThresholdMb} MB threshold — warning user"); var page = new TaskDialogPage { Caption = "RemSound", Heading = "Logs folder is getting large", Text = $"The RemSound logs folder is using about {mb:0} MB, which is over your {cfg.LogsFolderWarnThresholdMb} MB warning size.\n\n" + "You can clear old logs from Preferences, on the Logging tab.", Icon = TaskDialogIcon.Warning, Buttons = { TaskDialogButton.OK }, AllowCancel = true, }; try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); } catch (Exception ex) { logFile.Event($"log housekeeping: warn dialog failed: {ex.GetType().Name}: {ex.Message}"); } } } } /// One-time upgrade flow (replaces the v4.4 reset notice): keyboard shortcuts moved from /// per-profile to machine-wide storage (issue #14). Offers upgraders the choice of copying their /// shortcuts from one of their existing profiles (still readable in the profile files) or starting /// fresh. Only offered to people coming straight from a PRE-v4.4 version (where shortcuts were still /// per-profile), to spare them the reset — anyone who already went through v4.4's reset is left /// alone (re-offering would only annoy them), as is a fresh install or a user with no saved shortcuts /// to import. Runs BEFORE , which overwrites the /// LastWhatsNewVersion we read to tell upgraders apart from fresh installs. private void MaybeOfferKeyboardShortcutImport() { if (IsDisposed) return; AppConfig cfg; try { cfg = AppConfig.Load(); } catch { return; } if (cfg.KeyboardShortcutsImportOffered) return; // Leave the v4.4 crowd alone. v4.4's reset set KeyboardShortcutsGlobalNoticeShown for everyone // who ran it; those people have already re-done their shortcuts, so re-offering an import would // only annoy them. We only want to catch people coming straight from a PRE-v4.4 version (where // shortcuts were still per-profile), before they lose anything. if (cfg.KeyboardShortcutsGlobalNoticeShown) { MarkShortcutImportOffered(); return; } // Only relevant to upgraders (a previous version ran here, so LastWhatsNewVersion is set) who // actually have old per-profile shortcuts to bring across. var isUpgrade = !string.IsNullOrEmpty(cfg.LastWhatsNewVersion); if (!isUpgrade) { MarkShortcutImportOffered(); return; } var titles = ProfilesWithSavedShortcuts(); if (titles.Count == 0) { MarkShortcutImportOffered(); return; } logFile.Event($"keyboard shortcuts: offering import from {titles.Count} profile(s) with saved shortcuts"); try { using var dlg = new KeyboardShortcutImportDialog(titles); var result = ForegroundDialog.Show(owner => dlg.ShowDialog(owner)); if (result != DialogResult.OK) return; // dismissed (Escape) — offer again next launch if (dlg.ChosenProfileTitle is { } title) { var imported = ImportShortcutsFromProfile(title); logFile.Event($"keyboard shortcuts: imported {imported} shortcut(s) from profile \"{title}\""); hotkeyController.ReloadAndReRegisterAll(); } else { logFile.Event("keyboard shortcuts: user chose to start fresh with the defaults"); } MarkShortcutImportOffered(); } catch (Exception ex) { logFile.Event($"keyboard shortcuts import failed: {ex.GetType().Name}: {ex.Message}"); } } private static void MarkShortcutImportOffered() { try { var c = AppConfig.Load(); c.KeyboardShortcutsImportOffered = true; c.Save(); } catch { /* harmless — at worst the offer shows again next launch */ } } /// Titles of profiles that have at least one customised keyboard shortcut saved in their /// file (the only ones worth importing — unchanged shortcuts were stored as null). private List ProfilesWithSavedShortcuts() { var result = new List(); if (profileStore is null) return result; try { foreach (var title in profileStore.ListProfileTitles()) { try { if (profileStore.Load(title) is { } p && ProfileHasAnyShortcut(p)) result.Add(title); } catch { /* skip an unreadable profile */ } } } catch { /* enumeration failed — offer nothing */ } return result; } private static bool ProfileHasAnyShortcut(Profile p) => p.ReceiveMuteHotkey is not null || p.SendMuteHotkey is not null || p.TrayHotkey is not null || p.VolumeUpHotkey is not null || p.VolumeDownHotkey is not null || p.ToggleRecordingHotkey is not null || p.RemoteVolumeUpHotkey is not null || p.RemoteVolumeDownHotkey is not null || p.RemoteMuteToggleHotkey is not null || p.SystemVolumeUpHotkey is not null || p.SystemVolumeDownHotkey is not null || p.SystemMuteToggleHotkey is not null || p.QuickProfileSwitchHotkey is not null || p.SpeakStatusLineHotkey is not null; /// Copy a profile's saved (non-null) keyboard shortcuts into the machine-wide store, via the /// settings store's now-global Save* methods. Returns how many were copied; shortcuts the profile /// never customised (null) are left at the global default. private int ImportShortcutsFromProfile(string title) { if (profileStore is null || profileStore.Load(title) is not { } p) return 0; var n = 0; void Copy(HotkeyRecord? rec, Action save) { if (rec is not null) { save(rec.ToHotkeyInfo()); n++; } } Copy(p.ReceiveMuteHotkey, settings.SaveReceiveMuteHotkey); Copy(p.SendMuteHotkey, settings.SaveSendMuteHotkey); Copy(p.TrayHotkey, settings.SaveTrayHotkey); Copy(p.VolumeUpHotkey, settings.SaveVolumeUpHotkey); Copy(p.VolumeDownHotkey, settings.SaveVolumeDownHotkey); Copy(p.ToggleRecordingHotkey, settings.SaveToggleRecordingHotkey); Copy(p.RemoteVolumeUpHotkey, settings.SaveRemoteVolumeUpHotkey); Copy(p.RemoteVolumeDownHotkey, settings.SaveRemoteVolumeDownHotkey); Copy(p.RemoteMuteToggleHotkey, settings.SaveRemoteMuteToggleHotkey); Copy(p.SystemVolumeUpHotkey, settings.SaveSystemVolumeUpHotkey); Copy(p.SystemVolumeDownHotkey, settings.SaveSystemVolumeDownHotkey); Copy(p.SystemMuteToggleHotkey, settings.SaveSystemMuteToggleHotkey); Copy(p.QuickProfileSwitchHotkey, settings.SaveQuickProfileSwitchHotkey); Copy(p.SpeakStatusLineHotkey, settings.SaveSpeakStatusLineHotkey); return n; } /// If the user opted in () and the /// running version changed since the last launch we recorded, open the About box once so /// they see what changed in the update just installed. Always records the current version /// so the change is detected exactly once. A fresh install (no version recorded yet) does /// NOT count as an update. 2026-05-31. private void MaybeShowWhatsNewAfterUpdate() { if (IsDisposed) return; var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? ""; AppConfig cfg; try { cfg = AppConfig.Load(); } catch { return; } var versionChanged = !string.IsNullOrEmpty(cfg.LastWhatsNewVersion) && cfg.LastWhatsNewVersion != current; if (cfg.ShowWhatsNewAfterUpdate && versionChanged) { try { logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}"); using var dlg = new AboutDialog(); ForegroundDialog.Show(owner => dlg.ShowDialog(owner)); } catch (Exception ex) { logFile.Event($"what's new: failed to open About: {ex.GetType().Name}: {ex.Message}"); } } // Record the current version so the next change is detected once. Reload first so we // don't clobber a concurrent config write (e.g. the startup update-check timestamp). if (cfg.LastWhatsNewVersion != current) { try { var fresh = AppConfig.Load(); fresh.LastWhatsNewVersion = current; fresh.Save(); } catch { /* harmless — at worst we re-show next launch */ } } } // ===================== UI layout ===================== private void BuildLayout() { // === Menu bar + tabbed root layout === // Top: MenuStrip with the File menu (replaces the old Profiles & preferences tab — // profile-management actions and the cross-cutting preferences live here now). // Middle: TabControl with 3 pages (Connectivity, Audio I/O, Audio profile). // Bottom: status footer (healthLabel + statusLabel), always visible. // // 2026-05-08 refactor: dropped the fourth tab. Save / Save as / Open / Rename / // Min-to-tray / Keyboard shortcuts / Preferences / Exit now live in the menu bar // with single-press accelerators (Ctrl+S / Ctrl+K / Ctrl+P / Alt+M) instead of // requiring a Tab-stop journey to a dedicated tab. Mute cues + Accept remote vol + // Startup behaviour are now under File → Preferences (Ctrl+P). var rootLayout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, }; rootLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // menu rootLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // tabs rootLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // status footer BuildConnectivityTab(); BuildAudioIOTab(); BuildAudioProfileTab(); mainTabControl.TabPages.Add(connectivityTabPage); mainTabControl.TabPages.Add(audioIOTabPage); mainTabControl.TabPages.Add(audioProfileTabPage); // No SelectedIndexChanged handler. No focus management on tab change. Andre's // accessible app does ZERO event hooking on TabControl — relies entirely on // default WinForms + NVDA behaviour. Per Ed's repeated request: arrow keys cycle // tabs (focus on strip), NVDA announces the tab name as the active selection // changes, no auto-jumping into the page contents. var menu = BuildFileMenu(); rootLayout.Controls.Add(menu, 0, 0); rootLayout.Controls.Add(mainTabControl, 0, 1); // Status footer — always visible. var statusPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, Padding = new Padding(8, 4, 8, 4), }; statusPanel.Controls.Add(healthLabel); statusPanel.Controls.Add(new Label { Text = " ", AutoSize = true }); statusPanel.Controls.Add(statusLabel); rootLayout.Controls.Add(statusPanel, 0, 2); SetTabOrder(); Controls.Add(rootLayout); // The MenuStrip is added LAST so it claims the form's MainMenuStrip property. Without // this, the form may not auto-handle Alt-keystroke focus into the menu bar. MainMenuStrip = menu; } /// Build the File menu and wire each item to its action. Single-press /// accelerators are set via ShortcutKeys on the menu items so they fire from anywhere /// in the form. Alt+M (Minimise) is NOT set as a ShortcutKeys binding — it goes through /// ProcessCmdKey instead, gated per-tab so the Audio I/O tab's Alt+M (Audio mode) wins /// when that tab is active. private MenuStrip BuildFileMenu() { var menu = new MenuStrip { Dock = DockStyle.Top }; var fileMenu = new ToolStripMenuItem("&File") { AccessibleName = "File menu" }; var helpMenu = new ToolStripMenuItem("&Help") { AccessibleName = "Help menu" }; // New profile — starts a fresh blank template as a new unsaved session. Lives at the top of // the File menu (the conventional New / Open / Save order) and, crucially, is reachable even // when "start with a specific profile" boots straight past the picker (issue #6). Mnemonic // Alt+F, W ('w' — N is taken by Minimise) plus the conventional Ctrl+N global shortcut. var newProfileItem = new ToolStripMenuItem("Ne&w profile") { ShortcutKeys = Keys.Control | Keys.N, AccessibleName = "New profile", }; newProfileItem.Click += (_, _) => NewProfile(); var openItem = new ToolStripMenuItem("&Open profile...") { ShortcutKeys = Keys.Control | Keys.O, AccessibleName = "Open profile", }; openItem.Click += (_, _) => OpenProfileFromPicker(); // Recent profiles submenu. Populated dynamically on drop-down so the latest list is // always shown — AppConfig.RecentProfiles is the source of truth and gets mutated on // every profile load. Each item gets a 1..5 single-digit mnemonic so the user can // pick a recent without having to read it: Alt+F, R, 1 jumps to the most recent; // Alt+F, R, 2 to the second-most-recent, etc. recentProfilesMenu = new ToolStripMenuItem("&Recent profiles") { AccessibleName = "Recent profiles", }; recentProfilesMenu.DropDownOpening += (_, _) => PopulateRecentProfilesMenu(); // Seed the submenu so it isn't visibly empty before the first DropDownOpening fires. PopulateRecentProfilesMenu(); var saveItem = new ToolStripMenuItem("&Save") { ShortcutKeys = Keys.Control | Keys.S, AccessibleName = "Save profile", }; saveItem.Click += (_, _) => SaveOrSaveAs(); var saveAsItem = new ToolStripMenuItem("Save &as...") { AccessibleName = "Save profile as", }; saveAsItem.Click += (_, _) => SaveProfileAs(); var renameItem = new ToolStripMenuItem("Rena&me current profile...") { AccessibleName = "Rename current profile", }; 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); }; // Change this profile's encryption password. Alt+F, P — 'p' is free in the File menu // (O / R / S / A / M / L / N / X are taken). Opens a small dialog showing the current // password (in plain text, so a screen reader can read it) with OK / Cancel. var changePasswordItem = new ToolStripMenuItem("Change this profile's &password...") { AccessibleName = "Change this profile's password", }; changePasswordItem.Click += (_, _) => ChangeProfilePassword(); var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray") { // No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now — // moved off M because the Rename item took the M slot in the 2026-05-15 menu // reorg) plus the configurable "Show or hide window" hotkey cover this. Pre- // 2026-05-11 Alt+M was gated per-tab via ProcessCmdKey because the Audio I/O // tab had an "Audio mode" listbox that used Alt+M; that listbox is gone now // so the gating was retired. AccessibleName = "Minimise to tray", }; minimiseItem.Click += (_, _) => trayController.Minimize(); var exitItem = new ToolStripMenuItem("E&xit") { AccessibleName = "Exit RemSound", }; exitItem.Click += (_, _) => Close(); fileMenu.DropDownItems.AddRange(new ToolStripItem[] { newProfileItem, openItem, recentProfilesMenu, saveItem, saveAsItem, renameItem, lockProfileMenuItem, changePasswordItem, new ToolStripSeparator(), minimiseItem, new ToolStripSeparator(), exitItem, }); // === Options menu (new, 2026-05-15) === // Holds all the "configure the app" entry points that used to be scattered across // the File menu (Keyboard shortcuts, Preferences) and the Record menu (Recording // settings). Startup behaviour is also here as its own top-level item rather than // hiding inside Preferences as it did before. Reads as a natural sequence: // recording-specific → input config → startup → general prefs. // // Mnemonic Alt+O — natural for "Options". Required moving the Record menu off of // Alt+O (it's now Alt+K — see comment in BuildRecordMenu); the trade reads more // naturally for users because "Options" is exactly what's in the menu. var optionsMenu = new ToolStripMenuItem("&Options") { AccessibleName = "Options menu" }; var recordingSettingsItem = new ToolStripMenuItem("Recording &settings...") { AccessibleName = "Recording settings", }; recordingSettingsItem.Click += (_, _) => OpenRecordingSettingsDialog(); var keyboardItem = new ToolStripMenuItem("&Keyboard shortcuts...") { ShortcutKeys = Keys.Control | Keys.K, AccessibleName = "Keyboard shortcuts", }; keyboardItem.Click += (_, _) => hotkeyController.ShowKeyboardShortcutsDialog(this); // Startup behaviour moved into the Preferences dialog (its own tab) on 2026-06-13; the // Options-menu item and the standalone StartupBehaviourDialog are retired. var prefsItem = new ToolStripMenuItem("&Preferences...") { ShortcutKeys = Keys.Control | Keys.P, AccessibleName = "Preferences", }; prefsItem.Click += (_, _) => OpenPreferencesDialog(); // Password manager — list every profile with its password, edit any of them in one place. // 'w' mnemonic (pass&words) is free in the Options menu (s / K / t / P are taken). var profilePasswordsItem = new ToolStripMenuItem("Profile pass&words...") { AccessibleName = "Profile passwords", }; profilePasswordsItem.Click += (_, _) => OpenProfilePasswordManager(); // Realtek ASIO enable/disable toggle — only present when a Realtek ASIO driver is actually // installed. Lets the user reverse the disable decision (or disable a driver they kept). ToolStripMenuItem? realtekToggle = null; if (realtekAsioDriverNames.Count > 0) { // AccessibleName is set (alongside Text) by UpdateRealtekAsioMenuItemText so the screen // reader hears "Enable"/"Disable", matching what's shown — never "Toggle". realtekToggle = new ToolStripMenuItem(); realtekToggle.Click += (_, _) => ToggleRealtekAsio(); realtekAsioToggleItem = realtekToggle; UpdateRealtekAsioMenuItemText(); } var optionItems = new List { recordingSettingsItem, keyboardItem, profilePasswordsItem, }; if (realtekToggle is not null) optionItems.Add(realtekToggle); optionItems.Add(new ToolStripSeparator()); optionItems.Add(prefsItem); optionsMenu.DropDownItems.AddRange(optionItems.ToArray()); // Help menu — separate from File so users with their hand on Alt + arrow keys can // walk straight to it. F1 is the global "open the manual" key; the menu mirrors it // for users who prefer mouse / arrow navigation. var helpItem = new ToolStripMenuItem("&Help") { ShortcutKeys = Keys.F1, AccessibleName = "Open user manual", }; helpItem.Click += (_, _) => HelpLauncher.OpenManual(); var checkForUpdatesItem = new ToolStripMenuItem("&Check for updates") { AccessibleName = "Check for updates", }; checkForUpdatesItem.Click += (_, _) => CheckForUpdatesManually(); var aboutItem = new ToolStripMenuItem("&About RemSound") { AccessibleName = "About RemSound", }; aboutItem.Click += (_, _) => { using var dialog = new AboutDialog(); dialog.ShowDialog(this); }; helpMenu.DropDownItems.AddRange(new ToolStripItem[] { helpItem, checkForUpdatesItem, aboutItem, }); var recordMenu = BuildRecordMenu(); // Order: File / Record / Options / Help. Options sits between Record and Help per // user request — left-to-right reads file-management → recording-tasks → config → // help, which is the natural sequence for someone walking the menu bar with Alt // and the arrow keys. menu.Items.Add(fileMenu); menu.Items.Add(recordMenu); menu.Items.Add(optionsMenu); menu.Items.Add(helpMenu); return menu; } /// Rebuild the Recent profiles submenu from . /// Called once during menu construction (so it's not visibly empty before the first /// open) and on every DropDownOpening so the latest list is always shown. Entries that /// reference a profile file that no longer exists on disk are skipped — the path stays /// in the AppConfig list (it might come back, e.g. external drive remount) but doesn't /// clutter the menu. /// /// Mnemonic / numeric-pick convention: each item is prefixed with "&N" where N is 1..5 /// for the position. Pressing the digit while the submenu is open selects that item. /// The most-recently-opened profile is &1 (top); oldest in the list is &5 (bottom). private void PopulateRecentProfilesMenu() { if (recentProfilesMenu is null) return; recentProfilesMenu.DropDownItems.Clear(); var cfg = AppConfig.Load(); var slot = 1; foreach (var path in cfg.RecentProfiles) { if (string.IsNullOrWhiteSpace(path)) continue; if (!File.Exists(path)) continue; // skip missing files; keep in storage in case they reappear var title = Path.GetFileNameWithoutExtension(path); // Visible Text has the &1..&5 mnemonic for number-key access; AccessibleName is // just the profile name so NVDA reads the menu item naturally rather than // prefixing every entry with "Recent profile N:" (which was the original cut // and Ed flagged it as noisy / unwanted). var item = new ToolStripMenuItem($"&{slot} {title}") { AccessibleName = title, // Stash the path on the menu item so the click handler doesn't depend on // closure capture of the loop variable. Tag = path, }; item.Click += (s, _) => { var sender = (ToolStripMenuItem)s!; var profilePath = (string)sender.Tag!; SwitchToRecentProfile(profilePath); }; recentProfilesMenu.DropDownItems.Add(item); slot++; if (slot > AppConfig.MaxRecentProfiles) break; } if (recentProfilesMenu.DropDownItems.Count == 0) { recentProfilesMenu.DropDownItems.Add(new ToolStripMenuItem("(No recent profiles)") { Enabled = false, AccessibleName = "No recent profiles", }); } } // Carried across the close-and-relaunch profile switch (static so the NEXT MainForm instance, // built by Program.Main after this one closes, can read it). A switch done while RemSound was // in the tray should land back in the tray; internal so Program.Main can also skip the // "loading audio driver" splash in that case. internal static bool startNextInstanceMinimized; // True until the first MainForm of the process has shown its window. Distinguishes a genuine // cold launch (where the "Start minimised" preference applies) from an in-session new-profile or // profile-switch relaunch (where it must NOT — those would otherwise hide the window and look // like a crash, issue #12). Flipped false in the first OnShown. private static bool isFirstLaunch = true; /// Switch to the profile at via the same close-and-relaunch /// flow OpenProfileFromPicker uses. The active profile gets pushed to the front of the /// recents list by the next MainForm constructor when it sees the loaded path. private void SwitchToRecentProfile(string path) { if (string.IsNullOrWhiteSpace(path)) return; if (string.Equals(path, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded if (!File.Exists(path)) { MessageBox.Show(this, $"Profile file no longer exists:\n\n{path}\n\nIt'll be removed from the Recent profiles list.", "Recent profile", MessageBoxButtons.OK, MessageBoxIcon.Information); // Trim the dead entry out of the recents list so the user doesn't keep seeing it. var cfg = AppConfig.Load(); cfg.RecentProfiles.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); try { cfg.Save(); } catch { /* benign — list will be re-pruned at next attempt */ } return; } var title = Path.GetFileNameWithoutExtension(path); if (string.IsNullOrEmpty(title)) return; // Play the switch cue NOW, on click, for immediate feedback — CuePlayer.Play is fire-and- // forget on its own thread + device, so it survives the form rebuild that follows. Covers // BOTH the Recent-profiles menu and the quick-switch popup (both route through here). The // rebuilt form deliberately does NOT replay it, so startup into the first profile is silent. if (settings.LoadEnableProfileSwitchCue()) { profileSwitchSound?.Play(); } NextProfilePathToLoad = path; NextProfileTitleToLoad = title; // If the switch was triggered while the window was minimised / in the tray (the quick- // switch hotkey can fire from anywhere), keep the rebuilt instance in the tray too rather // than popping the window up in front of whatever the user is doing. startNextInstanceMinimized = !Visible || WindowState == FormWindowState.Minimized; AppendLogEntry($"profile switch via Recent profiles: \"{title}\" from {path}"); Close(); } /// /// Opens the Quick profile switch popup (bound to the global quick-switch hotkey): an /// NVDA-friendly, foreground-activated list of every profile with the current one marked. /// Plays the "profile menu open" cue as it appears (honouring its mute toggle); choosing a /// profile reloads into it — which plays the profile-switch cue on the relaunch. Escape, Close, /// or picking the already-current profile does nothing. /// private void ShowQuickProfileSwitch() { try { var store = profileStore; if (store is null) return; var titles = store.ListProfileTitles(); if (titles.Count == 0) return; var entries = new List(titles.Count); foreach (var title in titles) { var path = store.PathFor(title); var isCurrent = !string.IsNullOrEmpty(currentProfilePath) && string.Equals(path, currentProfilePath, StringComparison.OrdinalIgnoreCase); entries.Add(new QuickProfileSwitchDialog.ProfileEntry(title, path, isCurrent)); } if (settings.LoadEnableProfileMenuOpenCue()) { profileMenuOpenSound?.Play(); } var chosen = QuickProfileSwitchDialog.Show(entries); if (!string.IsNullOrEmpty(chosen)) { // SwitchToRecentProfile plays the switch cue on click, keeps the window in the // tray if it was there, and no-ops if the chosen profile is already current. SwitchToRecentProfile(chosen); } } catch (Exception ex) { logFile.Event($"quick profile switch failed: {ex.GetType().Name}: {ex.Message}"); } } /// File → New profile. Starts a fresh blank template as a new unsaved session — the way /// to create a profile from scratch even when "start with a specific profile" boots the user /// straight past the picker (issue #6). Offers to save the current profile first if it has /// unsaved changes, then hands off to Program.cs's loop via . private void NewProfile() { // Don't silently lose unsaved work when abandoning the current session for a blank one. if (unsavedChanges && profileStore is not null && !currentProfileReadOnly) { var result = MessageBox.Show(this, "You have unsaved changes to your current profile. Save them before starting a new profile?\n\n" + "Yes — save, then start a new profile.\nNo — discard the changes and start a new profile.\nCancel — stay where you are.", "RemSound — unsaved changes", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question, MessageBoxDefaultButton.Button3); if (result == DialogResult.Cancel) return; if (result == DialogResult.Yes) { if (string.IsNullOrEmpty(currentProfileTitle)) { // Current session is itself a blank template — needs a name before it can be saved. var saveTitle = ProfileSaveAsPrompt.Show(this, profileStore, null); if (string.IsNullOrEmpty(saveTitle)) return; // cancelled the name prompt → abort the whole thing SaveProfileTo(saveTitle, showConfirmation: false); } else { SaveProfileTo(currentProfileTitle, showConfirmation: false); } } // No → fall through, discarding the unsaved changes. } // No profile-switch cue here — deliberately. Unlike Recent/Open (which play it on click), // opening a fresh blank template should be silent; the switch sound feels wrong for "start // new" (Ed, 2026-06-11). The rebuilt form never replays the cue either (see OnShown), so the // entire New-profile path stays quiet. LoadBlankTemplateNext = true; // Stay in the tray if we were there, mirroring the quick-switch behaviour. startNextInstanceMinimized = !Visible || WindowState == FormWindowState.Minimized; AppendLogEntry("new profile: loading blank template"); Close(); } /// 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. private ToolStripMenuItem BuildRecordMenu() { // Record menu uses Alt+K. The natural "R" letter is taken on the main form by the // Receive audio checkbox; "O" is now claimed by the Options menu (2026-05-15 // reorg). K isn't a letter in "Record", so we surface the mnemonic explicitly in // the title: "Record (Alt+K)" with the K underlined. The visible hint keeps the // chord discoverable for keyboard-only users despite the unusual letter choice. // // This collides with the Lock-to-audio-clock checkbox on the Audio profile tab // which used to take Alt+K — the menu always wins at the form's top level, so the // checkbox loses its mnemonic and stays Tab-reachable only. The (Alt+&K) hint on // that checkbox's text was removed below to avoid a misleading prompt. var recordMenu = new ToolStripMenuItem("Record (Alt+&K)") { AccessibleName = "Record menu" }; // Start/Stop uses Alt+R — matches the Ctrl+R global toggle so the same letter does // the same job from either entry point. The "&" position shifts when the label flips // (Sta&rt → Stop &recording) so the underline stays on an R in both states. See // UpdateStartStopRecordingMenuLabel for the runtime label flip. startStopRecordingMenuItem = new ToolStripMenuItem("Sta&rt recording") { ShortcutKeys = Keys.Control | Keys.R, AccessibleName = "Start recording", }; startStopRecordingMenuItem.Click += (_, _) => ToggleRecording(); 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(); }; // Recording settings used to live here as the third item with Alt+S; in the // 2026-05-15 menu reorg it moved out to the Options menu so all of the "configure // the app" affordances live together. The Record menu now only carries the start / // stop toggle plus the two folder operations — actions you perform AT recording // time, not configuration. recordMenu.DropDownItems.AddRange(new ToolStripItem[] { startStopRecordingMenuItem, new ToolStripSeparator(), openFolderItem, changeFolderItem, }); return recordMenu; } /// 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. 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(); } } /// 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. private void UpdateStartStopRecordingMenuLabel(bool nowRecording) { void Apply() { if (startStopRecordingMenuItem is null) return; // Mnemonic stays on an "R" in both states: "Sta&rt recording" (Alt+R activates // the R in Start) when not recording, "Stop &recording" (Alt+R activates the R // in recording) when recording. Same keystroke does the same job in both states // — matches the Ctrl+R global toggle. startStopRecordingMenuItem.Text = nowRecording ? "Stop &recording" : "Sta&rt recording"; startStopRecordingMenuItem.AccessibleName = nowRecording ? "Stop recording" : "Start recording"; } if (InvokeRequired) BeginInvoke(Apply); else Apply(); } /// Open the recording settings dialog. On OK, write the settings back through /// 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. 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(); } /// 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). private void OpenProfileFromPicker() { if (profileStore is null) return; using var dialog = new OpenFileDialog { Title = "Open profile", Filter = "RemSound profiles (*.json)|*.json", InitialDirectory = profileStore.BaseDirectory, CheckFileExists = true, Multiselect = false, }; if (dialog.ShowDialog(this) != DialogResult.OK) return; var pickedPath = dialog.FileName; var picked = Path.GetFileNameWithoutExtension(pickedPath); if (string.IsNullOrEmpty(picked)) return; if (string.Equals(pickedPath, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded // Switch cue on click (same rationale as SwitchToRecentProfile). if (settings.LoadEnableProfileSwitchCue()) { profileSwitchSound?.Play(); } // Always pass the full path through. Program.cs deserialises directly from this // path, so profiles saved outside the active BaseDirectory still load correctly. NextProfilePathToLoad = pickedPath; NextProfileTitleToLoad = picked; AppendLogEntry($"profile open requested: \"{picked}\" from {pickedPath}"); Close(); } /// 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. /// /// Read-only profiles: the lock suppresses the automatic "you have unsaved changes" /// prompt on close / profile switch (the user has declared "anything I changed this /// session is throwaway"), but it does NOT block explicit Ctrl+S / File → Save — if the /// user asks to save on purpose, the save goes through. First time they do this we show /// a one-time warning explaining the situation, with a "Do not show again" tick so the /// warning self-suppresses for power users. Changed 2026-05-23 from the v2.x hard-block /// behaviour after Ed's feedback that the lock should protect against accident, not /// against intent. private void SaveOrSaveAs() { if (currentProfileReadOnly) { if (!AppConfig.Load().SaveOnReadOnlyWarningSuppressed) { if (!ShowSaveOnReadOnlyWarningDialog()) return; } // Read-only profiles always have a title — read-only is meaningless on the blank // template — so we go straight to UpdateExistingProfile without the // string-null-check that the unlocked path needs. UpdateExistingProfile(); return; } if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs(); else UpdateExistingProfile(); } /// Native TaskDialog warning the user that they're about to overwrite a profile /// marked read-only. Returns true if the user confirmed the save, false if they /// cancelled. Verification checkbox lets the user suppress future occurrences via /// ; same shape as /// . NVDA reads the heading + body + checkbox /// as part of the normal tab order. 2026-05-23 (rewrite of the v2.x hard-block dialog). /// private bool ShowSaveOnReadOnlyWarningDialog() { var verification = new TaskDialogVerificationCheckBox("Do not show me this message again"); var saveButton = new TaskDialogButton("Save anyway"); var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true }; var page = new TaskDialogPage { Caption = AppName, Heading = "Saving onto a read-only profile", Text = "You're about to save changes onto a profile that's marked as read-only. " + "RemSound allows this because you asked to save on purpose — the lock only " + "stops the automatic \"save your changes?\" prompt; it doesn't stop you " + "saving when you mean to.\n\n" + "Click Save anyway to overwrite this profile, or Cancel and use " + "File → Save as... if you'd rather save your changes to a new profile.", Icon = TaskDialogIcon.Warning, Verification = verification, Buttons = { saveButton, cancelButton }, DefaultButton = cancelButton, AllowCancel = true, }; var clicked = TaskDialog.ShowDialog(this, page); if (verification.Checked) { var cfg = AppConfig.Load(); cfg.SaveOnReadOnlyWarningSuppressed = true; try { cfg.Save(); } catch { /* harmless — preference just won't persist */ } AppendLogEntry("save-on-read-only warning suppressed by user"); } return clicked == saveButton; } /// 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. private void RenameCurrentProfile() { if (profileStore is null) return; if (string.IsNullOrEmpty(currentProfileTitle)) { MessageBox.Show(this, "There is no active profile to rename. Use File → Save as to save the current state under a name first.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var oldTitle = currentProfileTitle; // Rename uses the simple text-prompt dialog (no overwrite check — pass store=null — // because rename has its own conflict path: profileStore.Rename returns false when // the new name already exists, and we surface a popup below). var newTitle = ProfileSaveAsPrompt.Show( this, store: null, defaultName: oldTitle, dialogTitle: "Rename profile", promptLabel: "Please enter a new name for your profile:"); if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, oldTitle, StringComparison.Ordinal)) return; // Rename in the directory the profile actually lives in, NOT in BaseDirectory. The // active profile may have been Save-As'd to an arbitrary path on a previous step, // and Rename has to follow it. Falls back to BaseDirectory only when we somehow // don't have a path tracked (shouldn't happen if currentProfileTitle is non-empty). var oldPath = currentProfilePath ?? profileStore.PathFor(oldTitle); var directory = Path.GetDirectoryName(oldPath) ?? profileStore.BaseDirectory; // Re-encode the new title via PathFor's sanitiser so file-invalid characters get // stripped consistently with how every other save path names files. var sanitisedNewName = Path.GetFileName(profileStore.PathFor(newTitle)); var newPath = Path.Combine(directory, sanitisedNewName); if (string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) { // Same filename after sanitisation — nothing to do. return; } if (File.Exists(newPath)) { MessageBox.Show(this, $"A profile file named \"{sanitisedNewName}\" already exists in:\n\n{directory}\n\nChoose a different name.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } try { if (File.Exists(oldPath)) { File.Move(oldPath, newPath); } else { // Old file is gone (someone deleted it externally). Just write a fresh copy // under the new name so the active profile still has a backing file. var profile = BuildCurrentProfile(newTitle); File.WriteAllText(newPath, JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true })); } } catch (Exception ex) { MessageBox.Show(this, $"Could not rename \"{oldTitle}\" to \"{newTitle}\":\n\n{ex.Message}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } currentProfileTitle = newTitle; currentProfilePath = newPath; Text = FormatWindowTitle(newTitle); AccessibleName = Text; AppendLogEntry($"renamed profile \"{oldTitle}\" → \"{newTitle}\" (path: {newPath})"); } /// Show the Preferences dialog. After it closes, mark the profile dirty if /// the user toggled either of the two profile-bound preferences (mute cues / accept /// remote vol). Startup behaviour persists outside of the profile so it doesn't /// trigger the dirty flag. private void OpenPreferencesDialog() { using var dialog = new PreferencesDialog( settings, profileStore, getLoggingEnabled: () => logFile.Enabled, applyLoggingEnabled: enabled => { // Persist the user's choice to AppConfig — it's machine-local, not part of // the profile, so switching profiles doesn't change it. var cfg = AppConfig.Load(); cfg.LoggingEnabled = enabled; try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ } // Flip the gate live so the user's tick takes effect immediately. No need to // restart the app or reopen the log file — writes simply stop / resume mid-flight. logFile.Enabled = enabled; // Engine instrumentation rides on logging OR auto-tune — auto-tune needs the // same per-second diag data the log line emits, so disabling logs alone must // not starve auto-tune. UpdateDiagnosticsGate(); }, writeLogsNow: () => logFile.Event("user requested write logs now"), deleteAllLogs: () => { // Spare the log we're currently writing (it's held open and can't be removed anyway). var removed = LogMaintenance.DeleteAllLogs(logFile.Path); logFile.Event($"user deleted all logs from Preferences ({removed} file(s) removed)"); return removed; }, checkForUpdatesNow: () => CheckForUpdatesManually(), onUpdateFrequencyChanged: ApplyUpdateCheckTimer, applyUpnpEnabled: enabled => { // The persist already happened in the dialog; this callback only flips the // live RouterPortMapper. Start/Stop both run on a thread-pool thread because // NatUtility's discovery + socket teardown CAN BLOCK FOR TENS OF SECONDS, or // indefinitely, on unusual network setups (multiple adapters, VPNs, hostile // firewalls). Doing that on the UI thread here would freeze the // Preferences dialog AND every other UI element until the call returned — // Andre's v3.0 hang was triggered from this exact handler. See the longer // explanation on the startup-time UPnP block in OnShown. 2026-05-23. if (enabled) { Task.Run(() => { try { routerPortMapper.Start(); } catch (Exception ex) { logFile.Event($"upnp: start from prefs failed: {ex.GetType().Name}: {ex.Message}"); } }); } else { Task.Run(() => { 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(); // The Preferences dialog includes per-cue Browse buttons that can change custom // WAV paths in AppConfig.CustomCuePaths. Reload the cached SoundPlayer instances // here unconditionally — cheap, only six small files, and guarantees the next // play uses whatever the user just picked without waiting for the next launch. ReloadAllCueSounds(); } /// User pressed "Check for updates" (Help menu or Preferences button). Always /// runs the check noisily — i.e. surfaces "you're up to date", "vX.Y is available", or /// "couldn't reach the server" via a MessageBox, regardless of the Silently-install /// setting. Silent install only applies to background polls. Caller is on the UI thread. /// private async void CheckForUpdatesManually() { var result = await updater.CheckForUpdateAsync().ConfigureAwait(true); switch (result) { case UpToDate: MessageBox.Show(this, $"You are running the latest version (v{updater.CurrentVersion}).", "Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information); return; case UpdateCheckFailed failure: ShowUpdateCheckFailedDialog(failure); return; case UpdateAvailable available: var info = available.Info; 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 choice = MessageBox.Show(this, summary, "Update available", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); if (choice != DialogResult.Yes) return; await InstallUpdateAsync(info).ConfigureAwait(true); return; } } /// Show a plain-English MessageBox explaining why an update check couldn't /// complete. The wording is tailored to the : the SecureConnection /// case (which is most often Windows 7 lacking TLS 1.2 / SHA-2 updates) gets pointers to /// the specific Microsoft KBs that fix it; other failures get a friendlier "check your /// internet" message. The user is also offered the manual zip-install fallback URL so they /// can recover without us debugging their machine. The technical detail goes to the log, /// not the dialog. 2026-05-28. private void ShowUpdateCheckFailedDialog(UpdateCheckFailed failure) { var (heading, body) = failure.Kind switch { FailureKind.SecureConnection => ( "Couldn't reach the update server (secure connection failed)", "RemSound couldn't make a secure connection to GitHub to check for an update.\n\n" + "This is almost always because the Windows install is missing one or both of these official Microsoft updates that enable modern secure connections:\n\n" + " • KB3140245 — turns on TLS 1.2 support.\n" + " • KB4474419 — updates the trusted certificate list (SHA-2 support).\n\n" + "Both are free and won't break anything else. Run Windows Update (Control Panel → Windows Update) and install whatever it offers. Once those are in, Check for updates should work normally.\n\n" + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder. That works regardless of the secure-connection issue."), FailureKind.NetworkUnreachable => ( "Couldn't reach the update server", "RemSound couldn't reach GitHub to check for an update. This is usually a network problem — check your internet connection, then try Check for updates again.\n\n" + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), FailureKind.Timeout => ( "Update check timed out", "RemSound's request to GitHub took too long to respond. This is usually a slow or congested network — try Check for updates again in a minute or two.\n\n" + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), _ => ( "Couldn't check for updates", "RemSound's request to GitHub didn't get the response it expected, so it can't tell whether a newer version is available. Try Check for updates again later.\n\n" + "If you'd rather install the latest version by hand: go to https://github.com/Ednunp/RemSound/releases/latest, download the zip, close RemSound, and extract the zip over your RemSound folder."), }; MessageBox.Show(this, body, heading, MessageBoxButtons.OK, MessageBoxIcon.Warning); } /// Background-poll path. Runs on a timer tick; surfaces nothing unless an update /// is available, then either silently installs (per ) /// or pops the same confirmation dialog the manual path uses. "No update available" is a /// silent no-op — the user already chose to delegate scheduling to the timer. private async void CheckForUpdatesInBackground() { var result = await updater.CheckForUpdateAsync().ConfigureAwait(true); // Persist the timestamp so cross-launch scheduling can space the next poll out. try { var cfg = AppConfig.Load(); cfg.LastUpdateCheckUtc = DateTime.UtcNow; cfg.Save(); } catch { /* timestamp persistence is best-effort */ } // Background polls stay silent on both UpToDate and UpdateCheckFailed — the user // delegated scheduling to the timer and a failure here isn't actionable from where // they are. The next poll re-checks. The failure detail has already gone to the log // via the updater's Log callback. if (result is not UpdateAvailable available) return; var info = available.Info; 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; } 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 choice = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1)); if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); } /// Startup-poll path. Fired ~4 s after the main window finishes loading when /// is true. Distinct from /// 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. private async void CheckForUpdatesOnStartup() { UpdateCheckResult result; try { result = await updater.CheckForUpdateAsync().ConfigureAwait(true); } catch (Exception ex) { logFile.Event($"updater: startup check threw unexpectedly: {ex.GetType().Name}: {ex.Message}"); return; } try { var cfg = AppConfig.Load(); cfg.LastUpdateCheckUtc = DateTime.UtcNow; cfg.Save(); } catch { /* harmless */ } // Log each outcome distinctly so a failed check (TLS error, network down, GitHub // 5xx) doesn't get filed as "up to date" — that's the misclassification Tech Singer's // log from 2026-05-28 demonstrated, where a Win7 SSL handshake failure quietly logged // as "up to date" instead of the real cause. if (result is UpdateCheckFailed failure) { logFile.Event($"updater: startup check failed ({failure.Kind}): {failure.TechnicalDetail}"); return; } if (result is UpToDate) { logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})"); return; } if (result is not UpdateAvailable available) return; var info = available.Info; 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 = ForegroundDialog.Show(owner => notice.ShowDialog(owner)); 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 = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1)); if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); } /// 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. private async Task InstallUpdateAsync(UpdateInfo info) { // Pass the currently-loaded profile title so the updater drops a resume-after-update // sentinel; the relaunched RemSound.exe will pick this up in Program.Main and silently // re-open the same profile, skipping the picker. Without this, a silent or // mid-session update would drop the session AND leave the user back at the picker — // the session never resumes by itself. Null/empty title (blank template, no profile // saved yet) skips the sentinel and the relaunch falls through to normal startup. // Don't let two near-simultaneous checks both stage an install and spawn two helpers. if (updateInstallStarted) { logFile.Event($"updater: install already in progress; ignoring repeat request for {info.Tag}"); return; } updateInstallStarted = true; // Update cue — an audible heads-up that an update is going in, played just before it // starts. Fires for every install path (manual, background-silent, startup-silent), so // a silent background update isn't completely silent: the user hears that RemSound is // about to close and update. Played before the download so it sounds well before the // app vanishes; the download comfortably outlasts the short cue. Honours the per-profile // EnableUpdateCue flag set in Preferences. if (settings.LoadEnableUpdateCue()) updateSound?.Play(); var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true); if (!ok) { // Nothing was staged — allow a later attempt rather than wedging the updater off // for the rest of the session. updateInstallStarted = false; ForegroundDialog.Show(owner => MessageBox.Show(owner, $"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}", "Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning)); return; } // The helper is staged and launched. We MUST now exit cleanly so it can replace our // files and restart us — set updatingInProgress so the close path skips every prompt; // no dialog (least of all the unsaved-changes prompt) may be allowed to cancel this. updatingInProgress = true; logFile.Event($"updater: install helper launched for {info.Tag}, exiting"); Application.Exit(); } /// Clamp the release notes to a reasonable dialog-friendly length so the /// MessageBox doesn't push off-screen. Full notes always live in About and on the /// GitHub release page. private static string TruncateForDialog(string s) { const int max = 600; if (s.Length <= max) return s; return s[..max] + "\n…"; } /// Apply (or stop) the background update-poll timer based on /// . Called at startup and whenever the user /// changes the dropdown in Preferences. The first tick fires after one interval — we /// don't immediately probe GitHub on every app launch because that's both rude and /// would race with the Profile-load + audio-engine startup the user actually cares /// about. private void ApplyUpdateCheckTimer() { updateCheckTimer.Stop(); var freq = AppConfig.Load().UpdateCheckFrequency; var intervalMs = freq switch { UpdateCheckFrequency.EveryHour => 60 * 60 * 1000, UpdateCheckFrequency.Every6Hours => 6 * 60 * 60 * 1000, UpdateCheckFrequency.Every24Hours => 24 * 60 * 60 * 1000, _ => 0, }; if (intervalMs <= 0) return; updateCheckTimer.Interval = intervalMs; updateCheckTimer.Start(); } /// Connectivity tab — peer lists (connected/discovered/remembered), manual-add, /// logging toggle and write-logs-now. Wires per-list ItemCheck/KeyDown handlers, status /// labels, and binds the lists to the existing peer-state dictionaries via the Sync* /// helpers below. Phase 2 of the 2026-05-06 refactor; previously these controls lived /// inside ShowConnectivityTransportDialog and the form had a "Connectivity and transport" /// bridge button. private void BuildConnectivityTab() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 2, RowCount = 5, AutoScroll = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); // === Peer lists wiring === WireCheckedListAccessibility(connectedPeersList, connectedPeersStatus, "connected peer"); WireCheckedListAccessibility(discoveredPeersList, discoveredPeersStatus, "discovered peer"); WireCheckedListAccessibility(rememberedPeersList, rememberedPeersStatus, "remembered peer"); // Connected list: items are always checked. Unchecking disconnects. connectedPeersList.ItemCheck += (_, args) => { if (suppressConnectedCheck) return; BeginInvoke(() => { if (args.NewValue == CheckState.Unchecked && args.Index >= 0 && args.Index < connectedPeersList.Items.Count && connectedPeersList.Items[args.Index] is PeerListItem item) { DeselectPeer(item.Peer.InstanceId); } SyncAllPeerLists(); ApplyAudioRuntime(); }); }; connectedPeersList.KeyDown += (_, args) => { if (args.KeyCode == Keys.Delete && connectedPeersList.SelectedItem is PeerListItem selected) { var prevIndex = connectedPeersList.SelectedIndex; DeselectPeer(selected.Peer.InstanceId); SyncAllPeerLists(); FocusListItemAfterDelete(connectedPeersList, prevIndex); ApplyAudioRuntime(); args.Handled = true; args.SuppressKeyPress = true; } }; // Discovered list: items are unchecked. Checking connects + auto-remembers. Delete // suppressed (discovered peers go away when their broadcaster does). discoveredPeersList.ItemCheck += (_, args) => { if (suppressDiscoveredCheck) return; if (args.NewValue != CheckState.Checked) return; BeginInvoke(() => { if (args.Index >= 0 && args.Index < discoveredPeersList.Items.Count && discoveredPeersList.Items[args.Index] is PeerListItem item) { SelectPeer(item.Peer); EnsurePeerRemembered(item.Peer); } SyncAllPeerLists(); ApplyAudioRuntime(); }); }; discoveredPeersList.KeyDown += (_, args) => { if (args.KeyCode == Keys.Delete) { args.Handled = true; args.SuppressKeyPress = true; } }; // Remembered list: items are unchecked (connected ones hide). Check reconnects, Delete forgets. rememberedPeersList.ItemCheck += (_, args) => { if (suppressRememberedCheck) return; if (args.NewValue != CheckState.Checked) return; BeginInvoke(async () => { if (args.Index >= 0 && args.Index < rememberedPeersList.Items.Count && rememberedPeersList.Items[args.Index] is RememberedPeerItem item) { PeerAnnouncement? toSelect = null; if (rememberedPeerInstanceIds.TryGetValue(item.Entry, out var existingId) && knownPeers.TryGetValue(existingId, out var known)) { toSelect = known; } else { var address = await ResolvePeerAddressAsync(item.Entry); if (address is not null) { var peer = CreateManualPeer(item.Entry, address); manualPeers[peer.InstanceId] = peer; rememberedPeerInstanceIds[item.Entry] = peer.InstanceId; toSelect = peer; } } if (toSelect is not null) SelectPeer(toSelect); } RefreshKnownPeers(); SyncAllPeerLists(); ApplyAudioRuntime(); }); }; rememberedPeersList.KeyDown += (_, args) => { if (args.KeyCode == Keys.Delete) { var prevIndex = rememberedPeersList.SelectedIndex; RemoveSelectedRememberedPeer(rememberedPeersList); SyncAllPeerLists(); FocusListItemAfterDelete(rememberedPeersList, prevIndex); args.Handled = true; args.SuppressKeyPress = true; } }; // === Manual add + Write logs now === manualAddButton.Click += async (_, _) => { var entry = ManualPeerPrompt.Show(this); if (string.IsNullOrWhiteSpace(entry)) return; await AddManualPeerAsync(entry); SyncAllPeerLists(); BeginInvoke(() => FocusListControl(connectedPeersList)); }; // Logging controls retired from this tab 2026-05-08 — they now live in the // Preferences dialog (File → Preferences, Ctrl+P) as the last two items. // === Layout === // 5 rows: 0–2 the three peer lists, 3 manual-add, 4 connection-status readout. panel.RowCount = 5; FormLayoutRows.AddCheckedListRow(panel, 0, "Connected peers (Alt+&C)", connectedPeersList, connectedPeersStatus, FocusListControl); FormLayoutRows.AddCheckedListRow(panel, 1, "Discovered peers (Alt+&D)", discoveredPeersList, discoveredPeersStatus, FocusListControl); FormLayoutRows.AddCheckedListRow(panel, 2, "Remembered peers (Alt+&R)", rememberedPeersList, rememberedPeersStatus, FocusListControl); panel.Controls.Add(new Label { Text = "Manual peer", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 3); panel.Controls.Add(manualAddButton, 1, 3); // Connection status readout — last row, tab-into-able. var statusLabel = new MnemonicLabel { Text = "Connection status (Alt+&S)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = statusReadout }; statusLabel.Click += (_, _) => statusReadout.Focus(); panel.Controls.Add(statusLabel, 0, 4); panel.Controls.Add(statusReadout, 1, 4); // Initial render so the box has content the moment the user tabs into it. RefreshStatusReadout(); connectivityTabPage.Controls.Add(panel); // Initial population so screen readers see something on first open. SyncAllPeerLists(); } /// Audio I/O tab — full content. All the existing main-form audio controls /// (mode, ASIO driver, send/receive checkboxes, device lists, volume) live here. private void BuildAudioIOTab() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 2, RowCount = 11, AutoScroll = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); // 2026-05-11 mnemonic refresh — Ed's spec for the Audio I/O tab: // ASIO driver → Alt+D (drives audio mode: "(none)" = WASAPI-only, // any real driver = WASAPI + ASIO) // Set volume → Alt+V (unchanged) // ASIO outputs (receive) → Alt+1 // ASIO inputs (send) → Alt+2 // WASAPI outputs (receive) → Alt+3 // WASAPI outputs (send) → Alt+4 // WASAPI inputs (send) → Alt+5 // Receive Alt+R, Send Alt+S — unchanged. // // The pre-2026-05-11 "Audio mode" listbox (Alt+M) is gone — selecting a driver here // brings the ASIO half of the form to life; selecting "(none)" hides it again. On // machines with no ASIO drivers installed the driver picker is hidden entirely (there // is nothing to switch to) and the form runs WASAPI-only. // Row 0: "Uncheck all inputs and outputs on all soundcards", spanning both columns, // sitting just above the ASIO driver picker. Always present (independent of ASIO). panel.Controls.Add(uncheckAllDevicesButton, 0, 0); panel.SetColumnSpan(uncheckAllDevicesButton, 2); if (hasAnyAsioDriverInstalled) { asioDriverLabel = new MnemonicLabel { Text = "ASIO driver (Alt+&D)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = asioDriverBox }; asioDriverLabel.Click += (_, _) => asioDriverBox.Focus(); panel.Controls.Add(asioDriverLabel, 0, 1); panel.Controls.Add(asioDriverBox, 1, 1); } else { // Reserve the row but keep both cells empty. We could collapse the row entirely, // but leaving it as a no-op AutoSize row keeps the rest of the row indices stable // with the original layout (each subsequent control still lives in row N). } // Each checkbox wrapped in its own FlowLayoutPanel — required for NVDA state-change // announcements to fire reliably (a CheckBox directly in a TableLayoutPanel cell // suppresses them; the FlowLayoutPanel wrapper restores the announcement chain). var receiveCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; receiveCheckboxPanel.Controls.Add(receiveAudioCheckbox); panel.Controls.Add(receiveCheckboxPanel, 1, 2); receiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 3, "WASAPI outputs for received sound (Alt+&3)", receiveOutputDevicesList, receiveOutputDevicesStatusLabel, FocusListControl); asioReceiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 4, "ASIO outputs for received sound (Alt+&1)", asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, FocusListControl); FormLayoutRows.AddRow(panel, 5, "Set volume for all received audio (Alt+&V)", volumeBar, FocusControl); var sendCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox); panel.Controls.Add(sendCheckboxPanel, 1, 6); sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl); sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "WASAPI inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl); asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 9, "ASIO inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl); audioIOTabPage.Controls.Add(panel); } /// Audio profile tab — split into two GroupBox sections so NVDA announces the /// section name when focus first crosses into it. Send-side group: codec, packet size, /// lock to audio clock. Receive-side group: latency + auto-tune controls, buffer /// smoothness, artefact. Inside each group, focus traversal is the natural top-to-bottom /// order; crossing the boundary triggers NVDA's grouping-name announcement on the first /// child of the entered group. GroupBox `Text` is also the accessible name (single-source /// label rule); no `&` mnemonic since GroupBox isn't focusable. Phase 3 of the refactor; /// previously these controls lived inside ShowConnectivityTransportDialog as "dialog*" /// mirrors of hidden form-fields. private void BuildAudioProfileTab() { // Outer layout: one column, three rows. Row 0 is the Full-CPU-speed checkbox — the // first thing the user lands on when they Tab into the tab, deliberately ungrouped // and at the top so it can't be missed. Rows 1 and 2 are the existing Audio send // parameters / Audio receive parameters GroupBoxes. AutoScroll on so the tab page // handles overflow rather than the inner groups clipping their contents. var outerPanel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 1, RowCount = 3, AutoScroll = true, }; outerPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // Wrap the checkbox in its own FlowLayoutPanel — same NVDA-friendly pattern the // form's other top-level checkboxes use (a bare CheckBox in a TableLayoutPanel // cell suppresses some state-change announcements; the FlowLayoutPanel restores // the announcement chain). var priorityModePanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; priorityModePanel.Controls.Add(priorityModeBox); priorityModeBox.Checked = settings.LoadPriorityMode(); priorityModeBox.CheckedChanged += (_, _) => { settings.SavePriorityMode(priorityModeBox.Checked); PerformanceMode.Apply(priorityModeBox.Checked, msg => logFile.Event(msg)); MarkProfileDirty(); }; var sendGroup = new GroupBox { Text = "Audio send parameters", AutoSize = true, Dock = DockStyle.Top, Padding = new Padding(8, 4, 8, 8), }; var receiveGroup = new GroupBox { Text = "Audio receive parameters", AutoSize = true, Dock = DockStyle.Top, Padding = new Padding(8, 4, 8, 8), }; BuildAudioSendGroupContents(sendGroup); BuildAudioReceiveGroupContents(receiveGroup); outerPanel.Controls.Add(priorityModePanel, 0, 0); outerPanel.Controls.Add(sendGroup, 0, 1); outerPanel.Controls.Add(receiveGroup, 0, 2); audioProfileTabPage.Controls.Add(outerPanel); } /// Send-side controls: codec + packet size on row 0, lock-to-audio-clock on /// row 1. The codec and packet-size combo share a row because they're tightly coupled /// (changing the codec resets the meaningful packet sizes). Lock-to-clock is a sender- /// side toggle whose label varies by audio mode (WASAPI vs ASIO vs Both). private void BuildAudioSendGroupContents(GroupBox group) { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 2, AutoSize = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); // === Row 0: codec + packet size === // Packet size: per-packet audio frame the sender chops into. Smaller = lower send-side // accumulator latency at the cost of doubling packet rate (more sensitive to USB / // network hiccups). Renamed from "Send rate" 2026-05-02 — the label confused users // into thinking it was a bandwidth knob. sendRateBox.Items.Clear(); sendRateBox.Items.Add("Standard (5 ms PCM, 10/20 ms Opus)"); sendRateBox.Items.Add("Small (2.5 ms PCM, 5/10 ms Opus, LAN only)"); sendRateBox.SelectedIndex = (int)settings.LoadSendRate(); sendRateBox.SelectedIndexChanged += (_, _) => { var newRate = (SendRate)sendRateBox.SelectedIndex; settings.SaveSendRate(newRate); sender.SetSendRate(newRate); ApplySendRateToOpus(newRate); MarkProfileDirty(); }; // 2026-05-08 mnemonic refresh per Ed's spec: // Audio codec (renamed from "Transport codec") → Alt+C (was Alt+T) // Packet size → Alt+P (was Alt+S) var codecAndSendLabel = new Label { Text = "Audio codec (Alt+&C) / Packet size (Alt+&P)", AutoSize = true, Anchor = AnchorStyles.Left }; codecAndSendLabel.Click += (_, _) => FocusControl(codecBox); var codecRowPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; codecRowPanel.Controls.Add(codecBox); codecRowPanel.Controls.Add(new Label { Text = " Packet size: ", AutoSize = true, Padding = new Padding(8, 6, 0, 0) }); codecRowPanel.Controls.Add(sendRateBox); panel.Controls.Add(codecAndSendLabel, 0, 0); panel.Controls.Add(codecRowPanel, 1, 0); // === Row 1: Tight latency (sender-side, mode-dependent label) === // Mnemonic was Alt+K until v1.5 (2026-05-15) when the Record menu took Alt+K at // the menu-bar level. Replaced with Alt+D — the D in "au&dio" is naturally part of // the word, no explicit "(Alt+...)" hint needed. Free on the Audio profile tab // (no other Audio-profile control uses D). var currentAudioModeForLabel = settings.LoadAudioMode(); var tightLatencyText = currentAudioModeForLabel switch { AudioMode.WasapiOnly => "Lock to au&dio clock, WASAPI sender", AudioMode.BothIndependent => "Lock to au&dio clock, WASAPI + ASIO senders", _ => "Lock to au&dio clock", }; var tightLatencyAccessible = currentAudioModeForLabel switch { AudioMode.WasapiOnly => "Lock to audio clock (Alt+D) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up.", AudioMode.BothIndependent => "Lock to audio clock (Alt+D) — both lanes tighten independently. WASAPI lane uses push-mode (single source); ASIO lane emits per callback. Brief clicks possible on either if the link can't keep up.", _ => "Lock to audio clock (Alt+D) — sender-side timing tighten.", }; tightLatencyBox.Text = tightLatencyText; tightLatencyBox.AccessibleName = tightLatencyAccessible; tightLatencyBox.Checked = settings.LoadTightLatencyMode(); tightLatencyBox.CheckedChanged += (_, _) => { settings.SaveTightLatencyMode(tightLatencyBox.Checked); sender.SetTightLatency(tightLatencyBox.Checked); logFile.Event($"tight latency changed to {(tightLatencyBox.Checked ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); MarkProfileDirty(); }; var tightLatencyLabel = new Label { Text = tightLatencyText, AutoSize = true, Anchor = AnchorStyles.Left }; tightLatencyLabel.Click += (_, _) => tightLatencyBox.Focus(); var tightLatencyContainer = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; tightLatencyContainer.Controls.Add(tightLatencyBox); panel.Controls.Add(tightLatencyLabel, 0, 1); panel.Controls.Add(tightLatencyContainer, 1, 1); group.Controls.Add(panel); } /// Receive-side controls: latency spinner + tune button + continuous-tune toggle /// + interval combo on row 0; smoothness list on row 1; artefact combo (with hint) on /// row 2. Tab order within the group flows naturally top-down. The tune-button hookup /// uses TuneLatencyAsync via the cancellation token field. private void BuildAudioReceiveGroupContents(GroupBox group) { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 4, AutoSize = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); // === Row 0: ASIO latency row — VISIBLE ONLY IN BOTHINDEPENDENT MODE === // In BothIndependent the WASAPI and ASIO lanes have independent targets. The ASIO row // sits above the WASAPI row so it's first in tab order (ASIO is the "headline" lane // a user picks the new mode for) and takes the simpler Alt+L / Alt+T mnemonics — when // the user enters BothIndependent the WASAPI row's labels mutate to "WASAPI latency // (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)", surrendering L/T to ASIO. In every // classic mode this row is hidden via UpdateBothIndependentVisibility and the WASAPI // row keeps the original "Audio latency (Alt+L)" labels. asioLatencyLabel = new Label { Text = "ASIO latency in milliseconds (Alt+&L)", AutoSize = true, Anchor = AnchorStyles.Left }; asioLatencyLabel.Click += (_, _) => FocusControl(maxLatencyAsioBox); SelectAllOnFocus(maxLatencyAsioBox); maxLatencyAsioBox.Value = Math.Clamp(settings.LoadMaxLatencyMsAsio(), (int)maxLatencyAsioBox.Minimum, (int)maxLatencyAsioBox.Maximum); continuousTuneAsioBox.Text = "Continuous auto-tune ASIO latency (Alt+&T)"; continuousTuneAsioBox.AccessibleName = "Continuous auto-tune ASIO latency"; continuousTuneAsioBox.Checked = settings.LoadContinuousAutoTuneAsioEnabled(); asioDelayContainer = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = true, }; asioDelayContainer.Controls.Add(maxLatencyAsioBox); asioDelayContainer.Controls.Add(continuousTuneAsioBox); panel.Controls.Add(asioLatencyLabel, 0, 0); panel.Controls.Add(asioDelayContainer, 1, 0); // === Row 1: WASAPI / classic latency row === // Labels and mnemonics mutate based on audio mode — see UpdateBothIndependentVisibility. // Classic modes: "Audio latency (Alt+L)" / "Continuous auto-tune latency (Alt+T)" // BothIndependent: "WASAPI latency (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)" // The interval dropdown stays attached to this row in both modes; one interval setting // governs both lanes' auto-tune ticks (separate intervals would be more knobs than // value). wasapiLatencyLabel = new Label { Text = "Audio latency in milliseconds (Alt+&L)", AutoSize = true, Anchor = AnchorStyles.Left }; wasapiLatencyLabel.Click += (_, _) => FocusControl(maxLatencyBox); SelectAllOnFocus(maxLatencyBox); continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)"; continuousTuneBox.AccessibleName = "Continuous auto-tune latency"; continuousTuneBox.Checked = continuousTuneEnabled; // 3 seconds added 2026-05-06 alongside the lookback shortening — the new combination // lets users dial in tighter latency on calm networks much faster (each tick samples // then potentially lowers, so 3s ticks × 5ms/tick = 1.7ms/sec descent). 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 }; // 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, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = true, }; delayContainer.Controls.Add(maxLatencyBox); delayContainer.Controls.Add(continuousTuneBox); delayContainer.Controls.Add(continuousIntervalLabel); delayContainer.Controls.Add(continuousIntervalBox); panel.Controls.Add(wasapiLatencyLabel, 0, 1); panel.Controls.Add(delayContainer, 1, 1); continuousTuneBox.CheckedChanged += (_, _) => { continuousTuneEnabled = continuousTuneBox.Checked; settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled); continuousIntervalBox.Enabled = AnyAutoTuneEnabled(); ApplyContinuousTuneTimer(); MarkProfileDirty(); }; continuousIntervalBox.SelectedIndexChanged += (_, _) => { continuousTuneIntervalSec = continuousIntervalBox.SelectedIndex switch { 0 => 3, 1 => 5, 3 => 15, 4 => 30, _ => 10 }; settings.SaveContinuousAutoTuneIntervalSec(continuousTuneIntervalSec); ApplyContinuousTuneTimer(); MarkProfileDirty(); }; // === Row 1: Buffer smoothness === smoothnessBox.Items.Clear(); smoothnessBox.Items.Add("10 — smoothest, no clicks, longest delay"); smoothnessBox.Items.Add("9"); smoothnessBox.Items.Add("8"); smoothnessBox.Items.Add("7"); smoothnessBox.Items.Add("6"); smoothnessBox.Items.Add("5"); smoothnessBox.Items.Add("4"); smoothnessBox.Items.Add("3 — default, brief clicks"); smoothnessBox.Items.Add("2"); smoothnessBox.Items.Add("1 — tightest delay, frequent clicks"); // Map int smoothness ↔ list index: index 0 = 10, index 9 = 1. smoothnessBox.SelectedIndex = Math.Clamp(10 - settings.LoadSmoothness(), 0, 9); smoothnessBox.SelectedIndexChanged += (_, _) => { if (smoothnessBox.SelectedIndex < 0) return; var newSmoothness = 10 - smoothnessBox.SelectedIndex; settings.SaveSmoothness(newSmoothness); receiver.SetSmoothness(newSmoothness); logFile.Event($"buffer smoothness changed to {newSmoothness}"); MarkProfileDirty(); }; var smoothnessLabel = new Label { Text = "Buffer smoothness (Alt+&B)", AutoSize = true, Anchor = AnchorStyles.Left }; smoothnessLabel.Click += (_, _) => FocusControl(smoothnessBox); panel.Controls.Add(smoothnessLabel, 0, 2); panel.Controls.Add(smoothnessBox, 1, 2); // === Row 2: Artefact === artefactBox.Items.Clear(); artefactBox.Items.Add("Noise burst (default) — broadband shhh, blends into music"); artefactBox.Items.Add("Click — no concealment, raw zero-fill click"); var loadedArtifact = settings.LoadConcealmentArtifact(); artefactBox.SelectedIndex = loadedArtifact == ConcealmentArtifact.Click ? 1 : 0; artefactBox.SelectedIndexChanged += (_, _) => { if (artefactBox.SelectedIndex < 0) return; var newArtifact = artefactBox.SelectedIndex == 1 ? ConcealmentArtifact.Click : ConcealmentArtifact.NoiseBurst; settings.SaveConcealmentArtifact(newArtifact); receiver.SetConcealmentArtifact(newArtifact); logFile.Event($"concealment artifact changed to {newArtifact}"); MarkProfileDirty(); }; var artefactLabel = new Label { Text = "Artefact sound type (Alt+&A)", AutoSize = true, Anchor = AnchorStyles.Left }; artefactLabel.Click += (_, _) => FocusControl(artefactBox); var artefactHint = new Label { Text = "Use this to change the way audio artefacts sound when they appear (e.g. on brief network or buffer hiccups). Changes take effect immediately.", AutoSize = false, Width = 420, Height = 36, Anchor = AnchorStyles.Left, }; var artefactContainer = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill }; artefactContainer.Controls.Add(artefactHint); artefactContainer.Controls.Add(artefactBox); panel.Controls.Add(artefactLabel, 0, 3); panel.Controls.Add(artefactContainer, 1, 3); // Wire ASIO companion control event handlers and apply initial visibility now that // every element exists. After this method returns the panel is ready to dock into // its parent groupbox. WireBothIndependentControls(); UpdateBothIndependentVisibility(); group.Controls.Add(panel); } /// Calls all three peer-list sync helpers in one go. Wired into the existing /// status timer (1 Hz) so the Connectivity tab stays current with discovery / heartbeat /// state without needing its own dedicated timer. private void SyncAllPeerLists() { SyncConnectedList(); SyncDiscoveredList(); SyncRememberedList(); RefreshStatusReadout(); } /// Updates the Connection-status read-only TextBox at the bottom of the /// Connectivity tab. Skips the actual Text-set when (a) the user is currently focused on /// the box (so NVDA isn't disrupted while reading), or (b) the freshly-computed text /// matches the last-rendered text (avoids redundant work and any chance of NVDA stutter). /// 2026-05-06. private void RefreshStatusReadout() { var text = ComputeStatusText(); if (text == lastStatusReadoutText) return; lastStatusReadoutText = text; // Don't disrupt the user mid-read. The text we computed is already cached so the // next tick will pick it up if the user moves focus away. if (statusReadout.Focused) return; statusReadout.Text = text; } /// Speak the current connection status line aloud through the active screen reader, via /// Tolk (). Answers issue #13: NVDA sometimes can't see the status /// readout ("no status line found"), so this reads it on demand. Triggered only by a user-set /// global hotkey (screen-reader only, unset by default) — being a system-wide hotkey it works from /// anywhere, whether or not RemSound is focused. Newlines become ". " so the multi-line readout /// speaks as a sentence rather than running together. private void SpeakStatusLine() { var text = statusReadout.Text; text = string.IsNullOrWhiteSpace(text) ? "No status information available." : text.Replace("\r\n", ". ").Replace("\n", ". "); ScreenReader.Speak(text); } private string ComputeStatusText() { // Compute byte-rates from delta since last sample. First call has no baseline so // the rate shows as 0; second and subsequent calls produce a real number. var nowUtc = DateTime.UtcNow; var txBytes = sender.BytesSent; var rxBytes = receiver.BytesReceived; double txKbs = 0, rxKbs = 0; if (lastStatusSampleUtc != DateTime.MinValue) { var elapsed = (nowUtc - lastStatusSampleUtc).TotalSeconds; if (elapsed > 0) { txKbs = (txBytes - lastStatusTxBytes) / 1024.0 / elapsed; rxKbs = (rxBytes - lastStatusRxBytes) / 1024.0 / elapsed; } } lastStatusSampleUtc = nowUtc; lastStatusTxBytes = txBytes; lastStatusRxBytes = rxBytes; // Healthy peers from heartbeat. Map each to its display label (the user-friendly // name from selectedPeerLabels, falling back to the address). var healthy = new List<(string Label, int? RttMs)>(); if (heartbeatService is { } hb) { foreach (var ph in hb.GetAllPeerHealth()) { if (ph.State != PeerHealthState.Healthy) continue; // Find a label by walking selectedPeerEndpoints for a matching address+port. string? label = null; foreach (var (id, ep) in selectedPeerEndpoints) { if (ep.Address.Equals(ph.AudioEndpoint.Address) && ep.Port == ph.AudioEndpoint.Port) { label = selectedPeerLabels.GetValueOrDefault(id); break; } } label ??= ph.AudioEndpoint.ToString(); int? rtt = ph.RttMs is { } r ? RoundToFive(r) : null; healthy.Add((label, rtt)); } } // Update the connected-since timestamp based on whether we have any healthy peers. if (healthy.Count > 0) { statusConnectedSinceUtc ??= nowUtc; } else { statusConnectedSinceUtc = null; } // Build the readout, one line per piece of information. Uses CRLF so the TextBox // multiline rendering is correct on Windows + readable to NVDA. var sb = new System.Text.StringBuilder(); if (healthy.Count == 0) { sb.AppendLine("Not connected to any peer."); } else { sb.AppendLine($"Connected to {healthy.Count} peer{(healthy.Count == 1 ? "" : "s")}."); foreach (var (label, rtt) in healthy) { var rttStr = rtt is { } r ? $"{r} ms" : "unknown"; sb.AppendLine($" {label}: ping {rttStr}"); } } if (statusConnectedSinceUtc is { } since) { var span = nowUtc - since; sb.AppendLine($"Uptime: {FormatUptime(span)}."); } else { sb.AppendLine("Uptime: 0 seconds."); } sb.AppendLine($"Receiving {rxKbs:0.0} kB/s; sending {txKbs:0.0} kB/s."); sb.Append($"Total received {receiver.BytesReceived / 1048576.0:0.0} MB; sent {sender.BytesSent / 1048576.0:0.0} MB."); return sb.ToString(); } private static string FormatUptime(TimeSpan span) { if (span.TotalSeconds < 1) return "0 seconds"; if (span.TotalMinutes < 1) return $"{(int)span.TotalSeconds} second{((int)span.TotalSeconds == 1 ? "" : "s")}"; if (span.TotalHours < 1) return $"{(int)span.TotalMinutes} minute{((int)span.TotalMinutes == 1 ? "" : "s")} {span.Seconds} second{(span.Seconds == 1 ? "" : "s")}"; return $"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? "" : "s")} {span.Minutes} minute{(span.Minutes == 1 ? "" : "s")}"; } /// /// Reads list.SelectedItem without the IndexOutOfRangeException WinForms' internal /// ItemArray throws when SelectedIndex is briefly left pointing past the item array. /// That happens during churny peer-list rebuilds (peer reboots, rapid reconnects): the /// 1 Hz Sync* tick read SelectedItem — whose getter blindly does Items[SelectedIndex] /// — and crashed the whole app from a timer callback. Bounds-check the index ourselves, /// the same defensive pattern the ItemCheck handlers already use. 2026-05-15. /// private static object? SafeSelectedItem(ListBox list) { var i = list.SelectedIndex; return i >= 0 && i < list.Items.Count ? list.Items[i] : null; } private void SyncConnectedList() { var desired = new List<(PeerListItem Item, Guid Id)>(); foreach (var (id, ep) in selectedPeerEndpoints) { if (knownPeers.TryGetValue(id, out var known)) { desired.Add((new PeerListItem(known), id)); } else { var label = selectedPeerLabels.GetValueOrDefault(id, ep.Address.ToString()); // Don't call a peer "offline" just because DISCOVERY briefly lost sight of it — // if audio is still arriving from it, or its heartbeat is healthy, it's plainly // still connected. Discovery beacons are easy to miss for a second; the audio // stream and heartbeat are the real signal. Only tag "(offline)" when it's gone // by every measure. 2026-06-02 (Ed's "offline but still sending audio" report). var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3)) || IsEndpointHeartbeatHealthy(ep); var suffix = stillThere ? "" : " (offline)"; var ghost = new PeerAnnouncement(id, $"{label}{suffix}", ep.Port, true, true, DateTime.UtcNow, ep.Address); desired.Add((new PeerListItem(ghost), id)); } } desired = desired.OrderBy(d => d.Item.Peer.Name).ThenBy(d => d.Item.Peer.Address.ToString()).ToList(); // Signature is stable identity only (peer id + name + address + port). Live status // (connected, codec, direction, RTT) is NOT in the signature — it gets updated in // place via RefreshItem so NVDA focus on a row survives tick updates. var signature = string.Join("|", desired.Select(d => d.Item.StableKey())); if (signature != lastConnectedListSignature) { lastConnectedListSignature = signature; var selectedId = SafeSelectedItem(connectedPeersList) is PeerListItem si ? si.Peer.InstanceId : Guid.Empty; suppressConnectedCheck = true; try { connectedPeersList.BeginUpdate(); connectedPeersList.Items.Clear(); var idx = -1; foreach (var d in desired) { var i = connectedPeersList.Items.Add(d.Item, isChecked: true); if (selectedId == d.Id) idx = i; } if (idx >= 0) connectedPeersList.SelectedIndex = idx; connectedPeersList.EndUpdate(); } finally { suppressConnectedCheck = false; } } UpdateConnectedListLiveStatus(); } private void UpdateConnectedListLiveStatus() { var healthByAddress = new Dictionary(); if (heartbeatService is not null) { foreach (var ph in heartbeatService.GetAllPeerHealth()) { healthByAddress[ph.AudioEndpoint.Address.ToString()] = ph; } } var sendingNow = connected && IsSendEnabled && sender.IsRunning; var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameSamplesPerChannel); for (int i = 0; i < connectedPeersList.Items.Count; i++) { if (connectedPeersList.Items[i] is not PeerListItem item) continue; var s = item.Status; var prevText = item.ToString(); var addrKey = item.Peer.Address.ToString(); var ph = healthByAddress.GetValueOrDefault(addrKey); var isHealthy = ph is { State: PeerHealthState.Healthy }; s.Connected = isHealthy; s.Sending = isHealthy && sendingNow; s.Receiving = isHealthy && receiver.IsRunning && receiver.IsReceivingFromAddress(item.Peer.Address); s.CodecLabel = isHealthy ? codecLabel : null; s.RttMs = isHealthy && ph is { RttMs: { } rtt } ? RoundToFive(rtt) : null; if (item.ToString() != prevText) { connectedPeersList.RefreshItemPublic(i); } } } private void SyncDiscoveredList() { // Discovered = peers seen by discovery NOT currently connected, AND NOT manual peers // (manual peers were added by user typing an IP — they aren't really "discovered"). var desired = new List<(PeerListItem Item, Guid Id)>(); foreach (var peer in knownPeers.Values .Where(p => !selectedPeerEndpoints.ContainsKey(p.InstanceId)) .Where(p => !manualPeers.ContainsKey(p.InstanceId)) .OrderBy(p => p.Name).ThenBy(p => p.Address.ToString())) { desired.Add((new PeerListItem(peer), peer.InstanceId)); } var signature = string.Join("|", desired.Select(d => d.Item.ToString())); if (signature == lastDiscoveredListSignature) return; lastDiscoveredListSignature = signature; var selectedId = SafeSelectedItem(discoveredPeersList) is PeerListItem si ? si.Peer.InstanceId : Guid.Empty; suppressDiscoveredCheck = true; try { discoveredPeersList.BeginUpdate(); discoveredPeersList.Items.Clear(); var idx = -1; foreach (var d in desired) { var i = discoveredPeersList.Items.Add(d.Item, isChecked: false); if (selectedId == d.Id) idx = i; } if (idx >= 0) discoveredPeersList.SelectedIndex = idx; discoveredPeersList.EndUpdate(); } finally { suppressDiscoveredCheck = false; } } private void SyncRememberedList() { // Hide entries whose mapped peer is currently connected — they live in Connected // until disconnection, then reappear here. var hiddenEntries = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var (entry, id) in rememberedPeerInstanceIds) { if (selectedPeerEndpoints.ContainsKey(id)) hiddenEntries.Add(entry); } var entries = settings.LoadRememberedPeers() .Where(e => !hiddenEntries.Contains(e)) .ToList(); var signature = string.Join("|", entries); if (signature == lastRememberedListSignature) return; lastRememberedListSignature = signature; var selectedEntry = SafeSelectedItem(rememberedPeersList) is RememberedPeerItem si ? si.Entry : null; suppressRememberedCheck = true; try { rememberedPeersList.BeginUpdate(); rememberedPeersList.Items.Clear(); var idx = -1; foreach (var entry in entries) { var item = new RememberedPeerItem(entry); var i = rememberedPeersList.Items.Add(item, isChecked: false); if (entry == selectedEntry) idx = i; } if (idx >= 0) rememberedPeersList.SelectedIndex = idx; rememberedPeersList.EndUpdate(); } finally { suppressRememberedCheck = false; } } /// Profiles & preferences tab — list of saved profiles with inline Switch / /// Rename / Delete buttons + Save / Save-as + the mute-cues checkbox + remote-volume /// opt-in + Keyboard-shortcuts + Minimise-to-tray. Phase 4 of the refactor; the old /// "Manage profiles" dialog (and the ProfileManagementDialog.cs file) is gone. // BuildProfilesPrefsTab and its companion UI methods (UpdateCurrentProfileLabel, // RefreshProfilesList, SwitchSelectedProfile, RenameSelectedProfile, // DeleteSelectedProfile) were deleted on 2026-05-08 when the fourth tab was retired. // The same actions now live on the File menu: // * Switch profile → File → Open profile (OpenProfileFromPicker) // * Rename profile → File → Rename current profile (RenameCurrentProfile) // * Save / Save as → File → Save / Save as // * Delete profile → removed from app UI; users can delete via the OS file // picker's right-click menu (File → Open profile shows the // folder; right-click any entry → Delete). // * Mute cues / Accept remote / Startup behaviour → File → Preferences (Ctrl+P). // * Keyboard shortcuts → File → Keyboard shortcuts (Ctrl+K). // * Minimise to tray → File → Minimise to tray (Alt+M, gated per-tab so the // Audio I/O tab's Audio mode mnemonic wins on that tab). // FocusFirstControlOnActiveTab was removed in the arrow-key fix. The original intent — // landing on something useful after a tab change — turned out to defeat the standard // tab-strip navigation: every SelectedIndexChanged would yank focus off the strip, // breaking arrow-key cycling and NVDA's tab-announcement chain. WinForms' built-in // behaviour (focus stays on strip until user presses Tab) is what we want. // FocusFirstChildOnActiveTab removed — caused unwanted "jumping into the box" on tab // change. Andre's app doesn't do this; we shouldn't either. Arrow keys cycle tabs with // focus staying on the strip; user presses Tab once to enter the active page. private void SetTabOrder() { // Andre's accessible app sets no TabIndex on the TabControl itself — defaults work. // Tab order is set per-tab now (each TabPage has its own focus traversal). Keeping // the existing relative order from the pre-tab single-form layout so the user's // muscle memory is preserved. // // Connectivity tab. connectedPeersList.TabIndex = 0; discoveredPeersList.TabIndex = 1; rememberedPeersList.TabIndex = 2; manualAddButton.TabIndex = 3; statusReadout.TabIndex = 4; // Audio I/O tab. The driver picker is row 0 when present (a real driver chosen here // is what enables ASIO). Audio-mode listbox retired 2026-05-11. asioDriverBox.TabIndex = 0; receiveAudioCheckbox.TabIndex = 1; receiveOutputDevicesList.TabIndex = 2; asioReceiveOutputDevicesList.TabIndex = 3; volumeBar.TabIndex = 4; sendMyAudioCheckbox.TabIndex = 5; sendOutputDevicesList.TabIndex = 6; sendInputDevicesList.TabIndex = 7; asioSendDevicesList.TabIndex = 8; // Profiles & preferences tab retired 2026-05-08 — the controls that used to live // there have moved to the File menu (Open/Save/Save as/Rename/etc.) and the // Preferences dialog (Mute cues / Accept remote vol / Startup behaviour). } /// True if the given control is on the currently-selected tab. Used by /// to gate Alt+letter shortcuts so they only fire when the /// target is on the visible tab — pressing Alt+L on the Connectivity tab does NOT auto- /// switch to the Audio profile tab and focus the latency spinner. The user has to first /// Ctrl+Tab to the right tab. This is the explicit per-tab shortcut isolation rule. private bool IsControlOnActiveTab(Control? c) { if (c is null) return false; var active = mainTabControl.SelectedTab; if (active is null) return false; for (var p = c.Parent; p is not null; p = p.Parent) { if (ReferenceEquals(p, active)) return true; } return false; } private bool IsSendEnabled => sendMyAudioCheckbox.Checked; private bool IsReceiveEnabled => receiveAudioCheckbox.Checked; // ===================== Connectivity / lifecycle ===================== private void Connect() { if (connected) return; connected = true; connectedSinceUtc = DateTime.UtcNow; try { discovery.Start(LocalAudioPort, IsSendEnabled, IsReceiveEnabled); logFile.Event("discovery started"); } catch (Exception ex) { AppendLogEntry($"discovery failed: {ex.Message}"); logFile.Event($"discovery failed: {ex.Message}"); } // Heartbeat starts as soon as we connect, regardless of send/receive state. That way // RTT and reachability are measured even when the user has both audio toggles off, // and the moment they tick a peer the heartbeat picks them up. // // Single-port mode (2026-05-06): the heartbeat service no longer binds a UDP socket. // Outbound pings/pongs route through the audio sender's socket (sender.SendVia, sharing // the audio NAT pinhole on the audio port). Inbound heartbeats arrive on either of two // App-owned sockets and are forwarded into HandleInjectedPacket: // * The audio receiver's listener (LAN — peers send heartbeat to our audio port). // * The audio sender's recv-side via OnInboundPacket (relay-return path). // Because the receiver's listener is bound for the duration of the connection (split // from the playback gate, see AudioReceiver.SetPlaybackEnabled), heartbeat works even // when "Receive audio" is off — no separate +2 port needed any more. try { heartbeatService = new HeartbeatService(msg => logFile.Event($"heartbeat: {msg}")); heartbeatService.SendTransport = sender.SendVia; receiver.OnHeartbeatReceived = (buffer, length, remote) => heartbeatService.HandleInjectedPacket(buffer, length, remote); // Remote-control handler (volume up/down, mute toggle from a connected peer). // Hooks into the same single-port receive path: the audio receiver's listener // sees the Control packet, parses it, and fires this delegate. We marshal back // onto the UI thread to mutate volumeBar / mute state. receiver.OnRemoteControlReceived = HandleRemoteControlPacket; heartbeatService.Start(); } catch (Exception ex) { AppendLogEntry($"heartbeat failed to start: {ex.Message}"); logFile.Event($"heartbeat failed to start: {ex.Message}"); } // Single-port mode: bind the audio receiver's listener socket immediately on connect, // independent of the user's "Receive audio" tick. The listener carries heartbeat // packets even when audio playback is off; ApplyAudioRuntime below toggles playback // separately via SetPlaybackEnabled. Without this, heartbeats sent to our audio port // would hit a closed socket and the peer would see us as unreachable until the user // ticked Receive. try { receiver.Start(LocalAudioPort); logFile.Event($"receiver listener started port={LocalAudioPort}"); } catch (Exception ex) { AppendLogEntry($"receiver listener failed to start: {ex.Message}"); logFile.Event($"receiver listener failed to start: {ex.Message}"); } RefreshKnownPeers(); ApplyAudioRuntime(); UpdateStatus(); } private void HandleCapabilityChange() { if (!connected) return; discovery.UpdateCapabilities(LocalAudioPort, IsSendEnabled, IsReceiveEnabled); ApplyAudioRuntime(); } private void EnsureRequestedAudioRunning() { if (!connected) return; var wantSend = IsSendEnabled; var wantReceive = IsReceiveEnabled; if ((wantSend && !sender.IsRunning) || (wantReceive && !receiver.IsRunning)) { ApplyAudioRuntime(); } } private void ApplyAudioRuntime() { if (!connected) return; var endpoints = SelectedSendEndpoints().ToArray(); allSendEndpoints = endpoints; // Single-port heartbeat: tracked peers' audio endpoints ARE the heartbeat target. // HeartbeatService sends via sender.SendVia (wired in Connect) so heartbeat shares // the audio NAT pinhole on the audio port — no separate socket, no +2 port. The // heartbeat tracks the FULL set so a recovered endpoint is detected and re-armed. heartbeatService?.SetTrackedPeers(endpoints); // Arm the audio sender with the full set initially (nothing is known-dead yet). The // 1 Hz tick (RefreshAudioReceivers) then drops any endpoint that stays unreachable, // so we don't blast the stream at a dead address. Clear the cached signature so the // refresh re-pushes against the new peer set. activeAudioReceiverSignature = null; RefreshAudioReceivers(); // Push the current profile-password key + fingerprint down to the sender and receiver so // audio is encrypted/decrypted with it. Cheap when the password hasn't changed. RecomputeAudioCrypto(); // Sender does NOT depend on a peer being currently online. As long as the user has ticked // "Send my audio" AND a capture device, we keep capturing and emitting UDP. If no peer is // selected, packets just go nowhere; the moment a peer is ticked, packets start flowing. // Either machine can start first; either machine can disappear and reappear; nothing // teardowns. UDP doesn't care. // // No fallback to the system default capture device — if the user hasn't ticked anything, // we send nothing. Avoids the "wrong source captured silently" failure mode. var wantReceive = IsReceiveEnabled; // Note: wantSend is driven by IsSendEnabled alone, NOT by HasCheckedSendDevice. If the // user has the "send my audio" toggle on but has unticked all devices for a moment // (typical mid-edit state), we keep the sender RUNNING with empty specs rather than // tearing it down and rebuilding. The reason: tearing the engine down closes the ASIO // driver, and Audient's driver (plus a couple of others) hangs for ~5 seconds when // closed and reopened in quick succession, which freezes RemSound and previously took // the laptop process down with it. Empty specs are handled gracefully — MixingEngine // keeps its mix task running over zero sources (produces silence), AsioCaptureBackend // keeps the driver open with zero active channel pairs (callbacks fire harmlessly). // The sender only actually stops when the user toggles off "send my audio" itself. var wantSend = IsSendEnabled; try { // Single-port model: the receiver's listener socket is bound at Connect time and // stays bound for the connection's lifetime (so heartbeats keep flowing regardless // of the playback toggle). The "Receive audio" checkbox now only gates playback. // Push the device list and allow-list before enabling playback, so the very first // packets after enable have correct routing. if (wantReceive && !receiver.IsRunning) { ApplyReceiveDevices(); PushAllowedReceiveSenders(); receiver.SetPlaybackEnabled(true); logFile.Event("receiver playback enabled"); } else if (!wantReceive && receiver.IsRunning) { receiver.SetPlaybackEnabled(false); logFile.Event("receiver playback disabled"); } if (wantSend && !sender.IsRunning) { ApplySendSources(); sender.Start(); logFile.Event($"sender started codec={sender.Codec} sources=[{sender.CaptureDeviceName}] peers=[{string.Join(",", endpoints.Select(e => e.ToString()))}]"); } else if (wantSend && sender.IsRunning) { // Already running — user may have ticked/unticked devices in either list. Push // the new spec list down; sender restarts the mixer transparently if the set changed. ApplySendSources(); } else if (!wantSend && sender.IsRunning) { sender.Stop(); logFile.Event("sender stopped"); } } catch (Exception ex) { AppendLogEntry($"audio runtime error: {ex.Message}"); logFile.Event($"audio runtime error: {ex.Message}"); } } /// /// Re-arm the audio sender's destination list from the full selected-peer set /// (), dropping any endpoint the heartbeat reports as /// continuously unreachable for longer than . The /// heartbeat keeps pinging dropped endpoints (they stay in SetTrackedPeers), so a recovered /// endpoint is automatically re-added on a later tick. This stops RemSound blasting the full /// audio stream at a dead address — e.g. a peer hostname resolving to both a live LAN IP and /// a long-dead Tailscale IP, where half the upload went into a black hole. Called once per /// second from ; only touches the sender when the armed set /// actually changes, so it's cheap to run per tick. /// private void RefreshAudioReceivers() { if (!connected) return; var all = allSendEndpoints; // Decide which selected peers to actually TRANSMIT audio to. We arm only peers that are // genuinely reachable — anyone whose heartbeat has been Unreachable for longer than // AudioPruneUnreachableAfter is dropped from the send set, so RemSound never streams audio // into a dead address. The heartbeat keeps pinging EVERY selected peer regardless (it runs // off SetTrackedPeers, not this set), so the instant a peer comes back it is re-armed. // // Carve-out: a peer we are actively RECEIVING audio from stays armed even if its heartbeat // reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat // round-trip doesn't, so a working stream is never cut. HashSet? dead = null; if (all.Length > 0 && heartbeatService is { } hb) { foreach (var ph in hb.GetAllPeerHealth()) { if (ph.State == PeerHealthState.Unreachable && ph.AgeOfLastPong is { } age && age > AudioPruneUnreachableAfter && !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3))) { (dead ??= new HashSet(StringComparer.OrdinalIgnoreCase)) .Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"); } } } var armed = dead is null ? all : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray(); // There used to be a "never silence EVERY peer" safety net here that re-armed the whole // set when pruning would leave nobody. Removed 2026-06-12: when NO peer is reachable we // must send NOTHING, not blast audio at every dead address. Otherwise someone connected to // a single peer that goes offline keeps uploading the full stream into the void — exactly // a-singer's issue #8 ("Not connected to any peer ... sending 51.1 kB/s ... sent 2116.5 MB" // after the only receiver was switched off hours earlier). The heartbeat still probes all // peers, so the moment one answers again it is re-armed and audio resumes on its own. var signature = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); if (signature == activeAudioReceiverSignature) return; activeAudioReceiverSignature = signature; sender.SetReceivers(armed); var pruned = all.Length - armed.Length; if (armed.Length == 0) { logFile.Event($"audio receivers updated: 0 active — no reachable peer, not sending (heartbeat still probing {all.Length})"); } else if (pruned > 0) { logFile.Event($"audio receivers updated: {armed.Length} active, {pruned} pruned (unreachable >{AudioPruneUnreachableAfter.TotalSeconds:0}s); heartbeat still probing all"); } else { logFile.Event($"audio receivers updated: {armed.Length} active"); } } private bool HasCheckedSendDevice() => sendOutputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) || sendInputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) || asioSendDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null); private void ApplySendSources() { // Build the unified spec list from all three send-side lists. The CompositeCaptureBackend // splits this set internally into WASAPI specs (sent to MixingEngine) and ASIO specs // (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed. var specs = new List(); foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) { if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); } foreach (var item in sendInputDevicesList.CheckedItems.OfType()) { if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, item.Name)); } foreach (var item in asioSendDevicesList.CheckedItems.OfType()) { // ASIO channels have no Loopback/Input distinction — Kind is irrelevant for ASIO // (AsioDeviceId.TryParse routes by id format, not by Kind). Use Input for symmetry. if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, item.Name)); } sender.Configure(specs); // Tell the auto-tune to ignore the next tick AND throw away the rolling window — newly- // added captures take a moment to fill their first ring buffer, and that initial-fill // jitter shouldn't bias the recommendation. The window-clear is the load-bearing piece; // without it a single big-gap entry keeps the recommendation pinned for ~30 s. InvalidateAutoTuneHistory(); } // ===================== Devices ===================== private void LoadAudioDevices() { try { var outputs = AudioDeviceCatalog.LoadOutputs(); var inputs = AudioDeviceCatalog.LoadInputs(); // All three lists start UNCHECKED every session. No persisted selection — by design. // The user re-ticks once per session, avoiding the "wrong-device-still-selected" // failure mode after a card unplug or ID change. sendOutputDevicesSignature = SyncDeviceCheckedListBox(sendOutputDevicesList, outputs); sendInputDevicesSignature = SyncDeviceCheckedListBox(sendInputDevicesList, inputs); receiveOutputDevicesSignature = SyncDeviceCheckedListBox(receiveOutputDevicesList, outputs); // Ground-truth log so we can definitively see the device list and initial check state // each launch — diagnoses any "device was checked at startup" mystery. var outputList = string.Join(", ", outputs.Select(d => $"\"{d.Name}\"")); var inputList = string.Join(", ", inputs.Select(d => $"\"{d.Name}\"")); logFile.Event($"device load: {outputs.Count} active render devices [{outputList}]; {inputs.Count} active capture devices [{inputList}]; all lists initial check state: unchecked"); } catch (Exception ex) { AppendLogEntry($"could not enumerate devices: {ex.Message}"); } } /// /// Called (on a COM thread) by whenever Windows reports an /// audio endpoint change. Marshals to the UI thread and (re)starts the debounce timer, so a /// burst of add / remove / default-changed callbacks collapses into a single refresh. /// private void OnAudioEndpointsChanged() { if (IsDisposed) return; logFile.Event("device-event: Windows reported an audio endpoint change (refresh queued)"); try { BeginInvoke(new Action(() => { if (IsDisposed) return; deviceRefreshTimer.Stop(); deviceRefreshTimer.Start(); })); } catch { /* handle gone / form closing — nothing to refresh */ } } /// /// Re-enumerates active audio endpoints and rebuilds any list whose set of devices changed. /// As of v3.4 this is driven by (Windows endpoint-change /// notifications), debounced through , rather than polled — so /// it runs only when the device set actually changes. Each list is rebuilt only when its /// (id, name) signature changes — the no-op fast path leaves NVDA's focus and the listbox state /// untouched. Check state is preserved by DeviceId across rebuilds; if a checked device /// disappeared, the relevant runtime Apply* is called so the engine sees the change. /// private void RefreshAudioDeviceLists() { // WASAPI lists are always populated from the Windows audio device catalogue — they're // visible regardless of ASIO state. ASIO lists are populated from the chosen driver's // channel-pair info, but only if ASIO is enabled with a valid driver; otherwise empty. IReadOnlyList wasapiOutputs; IReadOnlyList wasapiInputs; IReadOnlyList asioInputChoices = []; IReadOnlyList 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(); wasapiInputs = AudioDeviceCatalog.LoadInputs(); var currentMode = settings.LoadAudioMode(); if (ModeUsesAsio(currentMode) && settings.LoadAsioDriverName() is { } asioDriver && !string.IsNullOrWhiteSpace(asioDriver) && !disabledAsioDrivers.Contains(asioDriver)) { var info = GetCachedAsioProbeInfo(asioDriver, out var probeFailed); if (info is not null) { LogAsioChannelNamesIfChanged(asioDriver, info); asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames); asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames); } else if (probeFailed) { // 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) { logFile.Event($"device refresh failed: {ex.GetType().Name}: {ex.Message}"); return; } var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature); var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature); var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature); 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) { ApplyAudioRuntime(); } if (receiveOutputChanged) ReapplyRememberedReceiveOutputs(); if (receiveOutputChanged || asioReceiveChanged) { ApplyReceiveDevices(); } } private void ClearAsioProbeCache() { cachedAsioProbeDriverName = null; cachedAsioProbeResult = null; cachedAsioProbeFailed = false; } private AsioDriverProbeResult? GetCachedAsioProbeInfo(string driverName, out bool probeFailed) { probeFailed = false; if (string.Equals(cachedAsioProbeDriverName, driverName, StringComparison.OrdinalIgnoreCase)) { if (cachedAsioProbeResult is not null) return cachedAsioProbeResult; if (cachedAsioProbeFailed) { probeFailed = true; return null; } } // Opening some ASIO drivers is not a passive metadata read. On Andre's Realtek // driver (rthdasio64.dll), every AsioOut construction leaks Event+Mutant handles. // The 3-second device refresh timer only needs stable channel metadata, so probe // once per selected driver and reuse the result until the driver changes or resume // forces a backend refresh. var info = AsioDeviceProbe.ProbeDriverInfo(driverName); cachedAsioProbeDriverName = driverName; if (info.InputChannelCount >= 0 && info.OutputChannelCount >= 0) { cachedAsioProbeResult = info; cachedAsioProbeFailed = false; return info; } cachedAsioProbeResult = null; cachedAsioProbeFailed = true; probeFailed = true; return null; } /// Reads Windows' microphone privacy settings and returns true when desktop apps — or /// THIS app specifically — are blocked from the mic. When blocked, WASAPI capture still opens but /// returns pure silence; ASIO bypasses this gate entirely, which is why the same mic can be live /// in ASIO yet dead here. Checks, in order: the per-user and machine top-level gate and the /// NonPackaged (all desktop apps) gate; a per-app Deny aimed at this exe under /// NonPackaged\<exe> (the case the old single-value check missed, where one app is /// denied while the top-level value still says Allow); and a Group-Policy / MDM force-deny /// (LetAppsAccessMicrophone=2, which the Settings UI doesn't even show). Best-effort — any /// failure returns false so a registry hiccup never stops the user enabling their mic. private static bool IsMicrophoneBlockedByWindowsPrivacy() { try { const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone"; const string nonPackaged = consent + @"\NonPackaged"; return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent) || IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, nonPackaged) || IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent) || IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, nonPackaged) || IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.CurrentUser, nonPackaged) || IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.LocalMachine, nonPackaged) || IsMicPolicyForceDenied(); } catch { return false; } } private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey) { using var key = root.OpenSubKey(subKey); // The ConsentStore "Value" is a REG_SZ "Allow"/"Deny". `as string` yields null for any other // type or a missing value, so anything that isn't an explicit "Deny" reads as allowed. return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase); } /// True if a per-app override under NonPackaged denies THIS exe specifically (its child /// key is named by the exe's full path with '\' replaced by '#'). We match by exe filename rather /// than reconstructing the exact encoding, so a deny on a different app never false-warns us. private static bool IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.RegistryKey root, string nonPackagedKey) { var exeName = System.IO.Path.GetFileName(Environment.ProcessPath ?? ""); if (string.IsNullOrEmpty(exeName)) return false; using var key = root.OpenSubKey(nonPackagedKey); if (key is null) return false; foreach (var childName in key.GetSubKeyNames()) { if (childName.IndexOf(exeName, StringComparison.OrdinalIgnoreCase) < 0) continue; using var child = key.OpenSubKey(childName); if (string.Equals(child?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase)) return true; } return false; } /// True if a Group Policy / MDM rule force-denies microphone access to apps /// (HKLM\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\LetAppsAccessMicrophone == 2). This /// sits below the Settings toggles, so the user can't see or undo it without policy access — and /// the old check never looked here at all. private static bool IsMicPolicyForceDenied() { using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Policies\Microsoft\Windows\AppPrivacy"); return key?.GetValue("LetAppsAccessMicrophone") is int v && v == 2; } /// One-shot, OK-only message telling the user Windows is blocking desktop-app mic /// access (so their mic would send silence) and exactly which two toggles to turn on. Shown /// when they tick a WASAPI mic on while the block is in place. private void WarnMicrophoneBlockedByWindowsPrivacy() { ForegroundDialog.Show(owner => MessageBox.Show(owner, "Windows is currently blocking desktop apps from using your microphone, so RemSound can " + "switch the mic on but will only send silence - the people you're connected to won't " + "hear you.\n\n" + "To fix it, open Windows Settings, go to Privacy & security, then Microphone, and turn " + "ON both of these:\n\n" + " - Microphone access\n" + " - Let desktop apps access your microphone\n\n" + "Then your mic will work. This doesn't affect sound you receive - only sending your " + "own microphone.", "Windows is blocking your microphone", MessageBoxButtons.OK, MessageBoxIcon.Warning)); } /// On profile load, if a WASAPI microphone is already ticked but Windows is blocking /// desktop-app mic access, show the warning once — the per-tick warning only fires on a fresh /// tick, so this covers a profile that loads with the mic already on. Same OK-only message. private void MaybeWarnMicBlockedOnStartup() { if (IsDisposed) return; var blocked = IsMicrophoneBlockedByWindowsPrivacy(); var anyWasapiMicChecked = false; for (var i = 0; i < sendInputDevicesList.Items.Count; i++) { if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; } } // Log the detector's verdict every startup so a silent-mic session is no longer ambiguous — // we can see whether RemSound thought Windows was blocking the mic, not just whether it warned. logFile.Event($"mic-privacy: windows-blocks-desktop-mic={blocked} wasapiMicTicked={anyWasapiMicChecked}"); // A --silent (automated/throwaway) launch logs the verdict but never pops the warning dialog. if (blocked && anyWasapiMicChecked && !CuePlayer.GloballyMuted) WarnMicrophoneBlockedByWindowsPrivacy(); } /// /// On startup, if a Realtek ASIO driver is installed and we haven't already disabled it or /// shown the warning, offer (once) to disable it — Realtek's ASIO driver leaks OS handles on /// every open. "Yes" adds it to the global never-touch list; "No" is remembered so we don't nag. /// Shown from the Shown handler so the TaskDialog has a visible owner window. /// private void MaybeWarnAboutRealtekAsio() { // A --silent (automated/throwaway) launch must not pop this warning - its TaskDialog plays // the Windows warning ding even when nobody can see the dialog (it's on a minimized test // instance that's then auto-closed). The decision belongs to a real user at a real launch. if (CuePlayer.GloballyMuted) return; if (realtekAsioDriverNames.Count == 0) return; var cfg = AppConfig.Load(); var changed = false; foreach (var driver in realtekAsioDriverNames) { if (cfg.IsAsioDriverDisabled(driver)) continue; // already disabled if (cfg.HasWarnedAboutAsioDriver(driver)) continue; // already asked; user kept it var disable = ShowRealtekAsioWarning(driver); cfg.MarkAsioDriverWarned(driver); changed = true; if (disable) { cfg.SetAsioDriverDisabled(driver, true); disabledAsioDrivers.Add(driver); RemoveDisabledDriverFromPicker(driver); logFile.Event($"realtek asio disabled in RemSound via startup warning: \"{driver}\""); } else { logFile.Event($"realtek asio kept (user declined disable): \"{driver}\""); } } if (changed) { try { cfg.Save(); } catch { /* best-effort */ } UpdateRealtekAsioMenuItemText(); } } private bool ShowRealtekAsioWarning(string driver) { var page = new TaskDialogPage { Caption = "RemSound — ASIO driver warning", Heading = "A Realtek ASIO driver was detected", Text = $"RemSound has detected you have a Realtek ASIO driver installed (\"{driver}\").\n\n" + "This driver is known to cause compatibility issues with ASIO software, including " + "RemSound — it leaks system resources and can make audio unstable.\n\n" + "Would you like to disable it in RemSound? RemSound will then never touch this " + "driver.\n\n" + "Whichever you choose now, you can change it at any time: the Options menu has an " + "\"Enable / Disable Realtek ASIO driver\" item that turns this driver on or off for " + "RemSound whenever you like.", Icon = TaskDialogIcon.Warning, }; var yes = new TaskDialogButton("&Yes, disable it (recommended)"); var no = new TaskDialogButton("&No, keep using it"); page.Buttons.Add(yes); page.Buttons.Add(no); page.DefaultButton = yes; return ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)) == yes; } /// Options-menu handler: flip every installed Realtek ASIO driver between disabled and /// enabled in RemSound. If any are currently disabled, the action re-enables them all; otherwise /// it disables them all. private void ToggleRealtekAsio() { if (realtekAsioDriverNames.Count == 0) return; var anyDisabled = realtekAsioDriverNames.Exists(d => disabledAsioDrivers.Contains(d)); var disable = !anyDisabled; var cfg = AppConfig.Load(); foreach (var driver in realtekAsioDriverNames) { cfg.SetAsioDriverDisabled(driver, disable); cfg.MarkAsioDriverWarned(driver); if (disable) { disabledAsioDrivers.Add(driver); RemoveDisabledDriverFromPicker(driver); } else { disabledAsioDrivers.Remove(driver); AddDriverToPickerIfMissing(driver); } } try { cfg.Save(); } catch { /* best-effort */ } UpdateRealtekAsioMenuItemText(); logFile.Event($"realtek asio {(disable ? "disabled" : "enabled")} in RemSound via Options menu: [{string.Join(", ", realtekAsioDriverNames)}]"); } private void UpdateRealtekAsioMenuItemText() { if (realtekAsioToggleItem is null || realtekAsioDriverNames.Count == 0) return; var anyDisabled = realtekAsioDriverNames.Exists(d => disabledAsioDrivers.Contains(d)); // Set BOTH the visible Text (with the mnemonic) and the AccessibleName (no mnemonic) to the // same Enable/Disable wording, so the screen reader reads exactly what's shown — never "Toggle". realtekAsioToggleItem.Text = anyDisabled ? "&Enable Realtek ASIO driver in RemSound" : "&Disable Realtek ASIO driver in RemSound"; realtekAsioToggleItem.AccessibleName = anyDisabled ? "Enable Realtek ASIO driver in RemSound" : "Disable Realtek ASIO driver in RemSound"; } private void RemoveDisabledDriverFromPicker(string driver) { var idx = asioDriverBox.Items.IndexOf(driver); if (idx < 0) return; var wasSelected = string.Equals(asioDriverBox.SelectedItem as string, driver, StringComparison.OrdinalIgnoreCase); asioDriverBox.Items.RemoveAt(idx); if (wasSelected) { asioDriverBox.SelectedIndex = 0; // "(none)" — back to WASAPI-only settings.SaveAsioDriverName(null); ClearAsioProbeCache(); } } private void AddDriverToPickerIfMissing(string driver) { if (!asioDriverBox.Items.Contains(driver)) asioDriverBox.Items.Add(driver); } /// /// Builds entries for ASIO channel pairs (stereo) using the /// driver's own per-channel names, prefixed with the driver name. The /// uses the synthetic "asio:<pair>" /// format that and parse. /// /// Label format: "<driverName> — Pair N (channels A/B): <lname> / <rname>". /// Driver name first so NVDA announces "Audient EVO 8 — …" up front and there's no /// ambiguity about which card's channels you're picking. Pair number gives anchor context /// when the per-channel names are terse. If the left and right names share a common stem /// ending in L/R or 1/2 we collapse them ("Main Output L"/"Main Output R" → "Main Output L/R"). /// private static IReadOnlyList BuildAsioChannelPairChoices(string driverName, IReadOnlyList channelNames) { var pairCount = channelNames.Count / 2; var choices = new List(pairCount); for (var i = 0; i < pairCount; i++) { var lName = channelNames[i * 2]; var rName = channelNames[i * 2 + 1]; var combined = TryCollapsePairLabel(lName, rName) ?? $"{lName} / {rName}"; var label = $"{driverName} — Pair {i + 1} (channels {i * 2 + 1}/{i * 2 + 2}): {combined}"; choices.Add(new AudioDeviceChoice(label, AsioDeviceId.Format(i), CaptureKind.Loopback)); } return choices; } /// /// Try to collapse "Main Output L" / "Main Output R" → "Main Output L/R", and similar /// patterns ending in "1"/"2" or "Left"/"Right". Returns null if the names don't share a /// common stem we can collapse cleanly — caller falls back to "Left / Right" form. /// private static string? TryCollapsePairLabel(string left, string right) { if (string.Equals(left, right, StringComparison.Ordinal)) return left; // Walk back from the end to find the divergence point — if the only difference is the // last character (and it's a known L/R pattern), collapse. Otherwise null. var commonLen = 0; var min = Math.Min(left.Length, right.Length); while (commonLen < min && left[commonLen] == right[commonLen]) commonLen++; if (commonLen == 0) return null; var stem = left[..commonLen].TrimEnd(); var ldiff = left[commonLen..]; var rdiff = right[commonLen..]; if ((ldiff == "L" && rdiff == "R") || (ldiff == "1" && rdiff == "2") || (ldiff == "Left" && rdiff == "Right") || (ldiff == "left" && rdiff == "right")) { return $"{stem} {ldiff}/{rdiff}"; } return null; } private string lastLoggedAsioChannelSignature = string.Empty; /// /// Logs ASIO channel names once (and re-logs if they change because the driver was swapped). /// Helpful for diagnosing "the names don't look like the WASAPI ones" issues — we can see /// exactly what the ASIO driver is reporting and decide if our label-building is at fault /// or the driver is just terse. /// private void LogAsioChannelNamesIfChanged(string driverName, AsioDriverProbeResult info) { var sig = $"{driverName}|in:{string.Join(",", info.InputChannelNames)}|out:{string.Join(",", info.OutputChannelNames)}"; if (sig == lastLoggedAsioChannelSignature) return; lastLoggedAsioChannelSignature = sig; logFile.Event($"asio channel names for \"{driverName}\": inputs=[{string.Join(", ", info.InputChannelNames.Select(n => $"\"{n}\""))}] outputs=[{string.Join(", ", info.OutputChannelNames.Select(n => $"\"{n}\""))}]"); } /// /// Sync wrapper around that compares against the /// stored signature and only rebuilds on change. Returns true when the list was rebuilt. /// private bool MaybeSyncList(CheckedListBox list, IReadOnlyList devices, ref string lastSignature) { var signature = ComputeDeviceSignature(devices); if (signature == lastSignature) return false; SyncDeviceCheckedListBox(list, devices); lastSignature = signature; return true; } /// /// Rebuilds the list of devices in a CheckedListBox, preserving check state by DeviceId /// and SelectedIndex by DeviceId where possible. Returns the (newly-computed) signature /// of the device set so callers can stash it. Suppresses the per-item ItemCheck handler /// during the rebuild so existing handlers don't fire spuriously while we re-add items. /// private string SyncDeviceCheckedListBox(CheckedListBox list, IReadOnlyList devices) { var signature = ComputeDeviceSignature(devices); var checkedIds = new HashSet( list.CheckedItems.OfType().Where(c => c.DeviceId is not null).Select(c => c.DeviceId!), StringComparer.OrdinalIgnoreCase); var selectedId = (list.SelectedItem as AudioDeviceChoice)?.DeviceId; suppressDeviceCheckChange = true; try { list.BeginUpdate(); list.Items.Clear(); var idx = -1; for (var i = 0; i < devices.Count; i++) { var d = devices[i]; var isChecked = d.DeviceId is not null && checkedIds.Contains(d.DeviceId); list.Items.Add(d, isChecked); if (selectedId is not null && d.DeviceId == selectedId) idx = i; } if (idx >= 0) list.SelectedIndex = idx; list.EndUpdate(); } finally { suppressDeviceCheckChange = false; } return signature; } private static string ComputeDeviceSignature(IReadOnlyList devices) => string.Join(";", devices.Select(d => $"{d.DeviceId}|{d.Name}")); private void ApplyReceiveDevices() { // Combine WASAPI device-ids and ASIO synthetic-ids into one list. The // CompositeRenderBackend splits them internally and feeds each child the right subset. var ids = new List(); foreach (var c in receiveOutputDevicesList.CheckedItems.OfType()) { if (!string.IsNullOrEmpty(c.DeviceId)) ids.Add(c.DeviceId); } foreach (var c in asioReceiveOutputDevicesList.CheckedItems.OfType()) { if (!string.IsNullOrEmpty(c.DeviceId)) ids.Add(c.DeviceId); } receiver.SetOutputDevices(ids); } /// A receive-output card that was unplugged drops out of the WASAPI list (and its tick /// with it). When it returns, re-tick it from so audio /// resumes automatically (issue #5). Only RE-ticks present-but-unticked remembered devices; it /// never unticks — that's a deliberate user action handled in the ItemCheck handler. Receive-only /// on purpose: the send lists keep their "re-tick each session" behaviour (see AudioDeviceCatalog). private void ReapplyRememberedReceiveOutputs() { if (rememberedReceiveOutputIds.Count == 0) return; var changed = false; suppressDeviceCheckChange = true; try { for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++) { if (receiveOutputDevicesList.Items[i] is not AudioDeviceChoice c || c.DeviceId is null) continue; if (rememberedReceiveOutputIds.Contains(c.DeviceId) && !receiveOutputDevicesList.GetItemChecked(i)) { receiveOutputDevicesList.SetItemChecked(i, true); changed = true; } } } finally { suppressDeviceCheckChange = false; } if (changed) logFile.Event("receive output: re-ticked a returning device from the remembered selection"); } /// /// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective /// modes after the 2026-05-11 cleanup: /// * WasapiOnly: no ASIO driver selected. WASAPI lists shown, ASIO lists hidden, /// fast path active. /// * BothIndependent: an ASIO driver is selected. All five lists shown; WASAPI and ASIO /// run as two parallel lanes each at their own native latency. /// On every call, list visibility is refreshed and any ticks in now-hidden lists are wiped /// so they don't contribute ghost specs to the next ApplyAudioRuntime push. /// /// True if this audio-mode runs an ASIO backend. BothIndependent does; WasapiOnly /// does not. The legacy AudioMode.Both and AudioMode.AsioOnly values can only arrive here /// from an old persisted profile JSON; they're treated as ASIO-using so deserialisation /// stays graceful but no UI path can produce them any more. private static bool ModeUsesAsio(AudioMode mode) => mode == AudioMode.AsioOnly || mode == AudioMode.Both || mode == AudioMode.BothIndependent; // ===================== BothIndependent companion controls ===================== // // The ASIO-lane latency row created in BuildAudioReceiveGroupContents. These four refs // live at class scope so UpdateBothIndependentVisibility can hide/show the row whenever // the audio mode changes, and so WireBothIndependentControls can attach event handlers // once the form is built. private Label? asioLatencyLabel; private Label? wasapiLatencyLabel; private FlowLayoutPanel? asioDelayContainer; /// /// Attaches the ValueChanged / CheckedChanged handlers for the ASIO-lane companion /// controls. Called once from BuildAudioReceiveGroupContents after both rows exist. /// private void WireBothIndependentControls() { // ASIO latency spinner. Persists to settings + pushes to the receiver's per-route // setter so the audio thread sees the new target on the next Read. Soft-set on the // receiver (no drain) — drift correction will shrink the buffer naturally on a // lower; raising is silent by definition. maxLatencyAsioBox.ValueChanged += (_, _) => { var value = (int)maxLatencyAsioBox.Value; var fromAutoTune = suppressUserAsioSliderMoveTracking; if (!fromAutoTune) { lastUserAsioSliderMoveUtc = DateTime.UtcNow; // When auto-tune is on, the slider value is runtime state (auto-tune will // overwrite it). Don't dirty the profile for those changes — matches the // user's mental model of "auto-tune on = latency is automatic, not saved". if (!settings.LoadContinuousAutoTuneAsioEnabled()) MarkProfileDirty(); } settings.SaveMaxLatencyMsAsio(value); // Soft path on auto-tune (no drain, drift corrector handles the lower); hard // path on a user-initiated change (immediate, responsive). if (fromAutoTune) { receiver.SetMaxLatencyMsSoftFor(RenderRoute.AsioLane, value); } else { receiver.SetMaxLatencyMsFor(RenderRoute.AsioLane, value); } }; 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(); }; // Push initial value to the receiver so the per-route state matches the persisted // slider value even before any audio flows. receiver.SetMaxLatencyMsSoftFor(RenderRoute.AsioLane, (int)maxLatencyAsioBox.Value); } /// /// Toggles visibility of the BothIndependent-only ASIO row and rewrites the WASAPI row's /// labels and mnemonics based on the current audio mode. In classic modes the WASAPI row /// reverts to its legacy "Audio latency (Alt+L)" / "Continuous auto-tune latency (Alt+T)" /// shape and the ASIO row is hidden. In BothIndependent the ASIO row is shown above the /// WASAPI row (first in tab order) and the WASAPI row's labels become "WASAPI latency /// (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)" so the two sets of mnemonics don't /// collide. Idempotent — call from anywhere the audio mode might have changed. /// private void UpdateBothIndependentVisibility() { if (asioLatencyLabel is null || wasapiLatencyLabel is null || asioDelayContainer is null) return; var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; asioLatencyLabel.Visible = inBothIndependent; 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 { wasapiLatencyLabel.Text = "Audio latency in milliseconds (Alt+&L)"; 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)"; } } // Tracks the last time the user moved the ASIO slider — auto-tune defers tuning for one // tick afterward so the user's deliberate change isn't immediately overridden. Parallels // lastUserSliderMoveUtc which serves the same role for the WASAPI / classic slider. private DateTime lastUserAsioSliderMoveUtc = DateTime.MinValue; /// True if this audio-mode runs a WASAPI backend. Today only AsioOnly excludes /// it; everything else (WasapiOnly, BothIndependent, the legacy Both) shows the WASAPI /// device lists. Kept as a predicate so a future mode addition just needs to update the /// expression rather than every call site. private static bool ModeUsesWasapi(AudioMode mode) => mode != AudioMode.AsioOnly; // ModeFromListIndex / ListIndexFromMode retired 2026-05-11 — there is no audio-mode // listbox any more, so there are no indices to translate. The audio mode is derived // directly from settings.LoadAudioMode(), which itself reads back the ASIO driver name // ("none" → WasapiOnly, anything else → BothIndependent). private void ApplyAsioMode() { var requestedMode = settings.LoadAudioMode(); var driver = settings.LoadAsioDriverName(); var resolvedMode = requestedMode; // Sanity: an ASIO mode without a driver demotes to WasapiOnly. Should be unreachable // through normal UI flow (the listbox is disabled when there are no drivers). if (ModeUsesAsio(requestedMode) && string.IsNullOrWhiteSpace(driver)) { resolvedMode = AudioMode.WasapiOnly; } var asioDriverArg = ModeUsesAsio(resolvedMode) ? driver : null; try { sender.SetAudioMode(resolvedMode, asioDriverArg); receiver.SetAudioMode(resolvedMode, asioDriverArg); logFile.Event(resolvedMode == AudioMode.WasapiOnly ? "audio backend: WASAPI only (fast path)" : $"audio backend: WASAPI + ASIO driver \"{asioDriverArg}\" (independent lanes, no mix)"); } catch (Exception ex) { logFile.Event($"backend switch failed: {ex.GetType().Name}: {ex.Message}"); } // List visibility per mode. BothIndependent shows both WASAPI and ASIO lists — user // needs to assign devices to each lane. WasapiOnly hides the ASIO lists. var wasapiListsVisible = ModeUsesWasapi(resolvedMode); var asioListsVisible = ModeUsesAsio(resolvedMode); // Driver picker stays visible whenever at least one ASIO driver is installed — that // way the user can turn ASIO on (by picking a driver) or off (by selecting "(none)") // without it disappearing on them. BuildAudioIOTab already omits the picker entirely // on machines with zero ASIO drivers (hasAnyAsioDriverInstalled false), in which case // both the listbox and its label are null-or-hidden and these lines are no-ops. asioDriverBox.Visible = hasAnyAsioDriverInstalled; if (asioDriverLabel is not null) asioDriverLabel.Visible = hasAnyAsioDriverInstalled; receiveOutputDevicesList.Visible = wasapiListsVisible; receiveOutputDevicesStatusLabel.Visible = wasapiListsVisible; if (receiveOutputDevicesLabel is not null) receiveOutputDevicesLabel.Visible = wasapiListsVisible; sendOutputDevicesList.Visible = wasapiListsVisible; sendOutputDevicesStatusLabel.Visible = wasapiListsVisible; if (sendOutputDevicesLabel is not null) sendOutputDevicesLabel.Visible = wasapiListsVisible; sendInputDevicesList.Visible = wasapiListsVisible; sendInputDevicesStatusLabel.Visible = wasapiListsVisible; if (sendInputDevicesLabel is not null) sendInputDevicesLabel.Visible = wasapiListsVisible; asioReceiveOutputDevicesList.Visible = asioListsVisible; asioReceiveOutputDevicesStatusLabel.Visible = asioListsVisible; if (asioReceiveOutputDevicesLabel is not null) asioReceiveOutputDevicesLabel.Visible = asioListsVisible; asioSendDevicesList.Visible = asioListsVisible; asioSendDevicesStatusLabel.Visible = asioListsVisible; if (asioSendDevicesLabel is not null) asioSendDevicesLabel.Visible = asioListsVisible; // Force list refresh — ASIO list content depends on which driver is loaded. asioSendDevicesSignature = string.Empty; asioReceiveOutputDevicesSignature = string.Empty; RefreshAudioDeviceLists(); // Clear ticks in hidden lists so they don't contribute ghost specs. Track whether we // actually wiped anything for the log line; the re-apply below runs unconditionally // because the new backend instance has no source/output state regardless. var wipedSomething = false; try { suppressDeviceCheckChange = true; if (!wasapiListsVisible) { for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++) if (receiveOutputDevicesList.GetItemChecked(i)) { receiveOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } for (var i = 0; i < sendOutputDevicesList.Items.Count; i++) if (sendOutputDevicesList.GetItemChecked(i)) { sendOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } for (var i = 0; i < sendInputDevicesList.Items.Count; i++) if (sendInputDevicesList.GetItemChecked(i)) { sendInputDevicesList.SetItemChecked(i, false); wipedSomething = true; } } if (!asioListsVisible) { for (var i = 0; i < asioSendDevicesList.Items.Count; i++) if (asioSendDevicesList.GetItemChecked(i)) { asioSendDevicesList.SetItemChecked(i, false); wipedSomething = true; } for (var i = 0; i < asioReceiveOutputDevicesList.Items.Count; i++) if (asioReceiveOutputDevicesList.GetItemChecked(i)) { asioReceiveOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } } } finally { suppressDeviceCheckChange = false; } // Always re-apply send sources and receive outputs after a mode change. The new // composite instance was built fresh — even if no ticks got wiped (e.g. WasapiOnly → // Both, where existing WASAPI ticks survive), the new backend has empty internal state // and needs the current spec/device list pushed to it. Without this, a user mid-session // who picks a different audio mode would silently lose their receive output and have // to re-tick to get audio back. ApplyAudioRuntime(); ApplyReceiveDevices(); if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks"); } /// /// Unchecks every input and output on every soundcard — WASAPI and ASIO, send and receive — /// in a single press. Idempotent: it only ever clears ticks, so pressing it again when things /// are already unchecked is a harmless no-op. Provided because the device lists can run long /// enough that it's hard to remember what's selected; this is the quick "start from nothing". /// Suppresses the per-item ItemCheck handler during the sweep, then applies the now-empty /// selection once so audio actually stops, marks the profile dirty, and resets the status /// labels so a screen reader hears the cleared state. /// private void UncheckAllDevices() { var lists = new[] { receiveOutputDevicesList, asioReceiveOutputDevicesList, sendOutputDevicesList, sendInputDevicesList, asioSendDevicesList, }; try { suppressDeviceCheckChange = true; foreach (var list in lists) for (var i = 0; i < list.Items.Count; i++) if (list.GetItemChecked(i)) list.SetItemChecked(i, false); } finally { suppressDeviceCheckChange = false; } // Also reset the ASIO driver to "(none)" (row 0): the button now does a full reset to a // clean WASAPI-only, nothing-selected state. Setting the index fires the driver-change // handler, which saves the change and rebuilds the engine in WASAPI-only mode. if (asioDriverBox.SelectedIndex > 0) asioDriverBox.SelectedIndex = 0; ApplyAudioRuntime(); ApplyReceiveDevices(); MarkProfileDirty(); receiveOutputDevicesStatusLabel.Text = "No output device selected."; sendOutputDevicesStatusLabel.Text = "No output device selected."; sendInputDevicesStatusLabel.Text = "No input device selected."; asioReceiveOutputDevicesStatusLabel.Text = "No ASIO receive channel selected."; asioSendDevicesStatusLabel.Text = "No ASIO send channel selected."; logFile.Event("user pressed 'Uncheck all inputs and outputs on all soundcards'"); } /// /// Called by 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. /// private void OnSystemResume() { try { if (IsDisposed) return; BeginInvoke(ReinitAudioBackendsForResume); } catch (ObjectDisposedException) { /* form torn down — nothing to do */ } catch (InvalidOperationException) { /* handle not created yet — same */ } } /// /// 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 /// always tears down and rebuilds its render backend, which is exactly the reset we /// want. The sender's 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 WasapiOnly /// first to force the ASIO driver to be disposed, then /// 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. /// 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. ClearAsioProbeCache(); 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. Run on // a thread-pool thread for the same reason as the other UPnP entry points: the // NatUtility teardown + restart inside Refresh() can block for tens of seconds // on unusual networks, and we're on the UI thread during the resume handler. // 2026-05-23. if (AppConfig.Load().UpnpEnabled) { Task.Run(() => { 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() { knownPeers.Clear(); // Discovered peers go in first so manual peers added by IP don't shadow them. foreach (var peer in discovery.Peers) knownPeers[peer.InstanceId] = peer; foreach (var peer in manualPeers.Values) knownPeers[peer.InstanceId] = peer; // Dedupe by endpoint (address:port). When a manual peer (typed by IP) and a discovered // peer (broadcasting hostname) point to the same machine, drop the manual entry and // forward any active selection to the discovered peer so the user doesn't lose it. // Prefer entries whose Name is NOT just the IP — those are real hostnames. var byEndpoint = new Dictionary(StringComparer.OrdinalIgnoreCase); var redirectedSelections = new List<(Guid From, Guid To)>(); foreach (var peer in knownPeers.Values.ToList()) { var key = $"{peer.Address}:{peer.AudioPort}"; if (!byEndpoint.TryGetValue(key, out var existing)) { byEndpoint[key] = peer; continue; } // Prefer the one with a real hostname (Name != IP-as-string). var existingIsIp = existing.Name == existing.Address.ToString(); var peerIsIp = peer.Name == peer.Address.ToString(); var winner = existingIsIp && !peerIsIp ? peer : existing; var loser = winner == existing ? peer : existing; byEndpoint[key] = winner; // Move loser's selection (if any) to winner so the checkbox state survives. if (selectedPeerEndpoints.ContainsKey(loser.InstanceId)) { redirectedSelections.Add((loser.InstanceId, winner.InstanceId)); } } foreach (var (from, to) in redirectedSelections) { if (selectedPeerEndpoints.Remove(from, out var endpoint)) { selectedPeerEndpoints[to] = endpoint; if (selectedPeerLabels.Remove(from, out var label)) { selectedPeerLabels[to] = label; } // The "manual peer" that lost out should be removed from manualPeers too, // otherwise the next discovery refresh re-creates the duplicate. manualPeers.Remove(from); } } knownPeers.Clear(); foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer; // If a selected peer's announced address changed (DHCP renewal, network switch), // update the cached endpoint so the sender follows the new IP — BUT only when the // endpoint we're currently using has actually stopped working. // // Why the guard: a peer reachable at two addresses at once — e.g. a VPN address AND a // LAN address — announces itself from both, and discovery reports whichever it heard // last. Blindly following that made the tracked endpoint ping-pong between the two // every couple of seconds. Because this one endpoint feeds the audio sender, the // heartbeat, AND the receiver's allow-list, the ping-pong meant a chunk of audio was // aimed at — or accepted only from — an address that doesn't actually reach the peer, // heard as heavy crackle (Tech Singer's Win7-over-VPN report, 2026-05-31). Keeping the // endpoint pinned while it's still passing heartbeats stops the thrash. A genuine move // (DHCP renewal, Wi-Fi switch) makes the old endpoint go unreachable first, at which // point the guard lets the move through; TryAdoptLiveHeartbeatAddress backs it up. foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList()) { if (!knownPeers.TryGetValue(id, out var peer)) continue; var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort); if (!newEndpoint.Equals(oldEndpoint) && !IsEndpointHeartbeatHealthy(oldEndpoint)) { selectedPeerEndpoints[id] = newEndpoint; logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint not healthy)"); } selectedPeerLabels[id] = peer.Name; } // Endpoints may have moved (DHCP/announcement-update path above) or selections may have // been redirected (manual-peer-merged-into-discovered above). Push the latest set down // to the receiver's allow-list so we don't keep accepting from a stale endpoint we no // longer recognise as a selected peer. PushAllowedReceiveSenders(); } private void SelectPeer(PeerAnnouncement peer) => SelectPeer(peer, fromProfileRestore: false); private void SelectPeer(PeerAnnouncement peer, bool fromProfileRestore) { selectedPeerEndpoints[peer.InstanceId] = new IPEndPoint(peer.Address, peer.AudioPort); selectedPeerLabels[peer.InstanceId] = peer.Name; logFile.Event($"peer selected: {peer.Name} {peer.Address}:{peer.AudioPort}"); InvalidateAutoTuneHistory(); PushAllowedReceiveSenders(); // fromProfileRestore=true means the call originated from auto-reconnect at startup; // we don't want that to flag the profile as dirty. User-initiated selects do. if (!fromProfileRestore) MarkProfileDirty(); } private void DeselectPeer(Guid instanceId) { if (selectedPeerEndpoints.Remove(instanceId)) { selectedPeerLabels.TryGetValue(instanceId, out var label); selectedPeerLabels.Remove(instanceId); logFile.Event($"peer deselected: {label ?? instanceId.ToString()}"); InvalidateAutoTuneHistory(); PushAllowedReceiveSenders(); MarkProfileDirty(); } } /// /// Tells the receiver which sender endpoints are allowed to play audio. Same set as the /// peers we're sending to (the checkbox controls both directions). Called whenever the /// user selects/deselects a peer, and once at startup so the receiver is in a known state. /// Without this, anyone who can reach our UDP port (e.g. a peer who chose us first) would /// auto-play to our speakers — we want explicit consent via the checkbox. /// private void PushAllowedReceiveSenders() { receiver.SetAllowedSenders(SelectedSendEndpoints()); } /// True if the heartbeat currently considers healthy — /// i.e. we're getting pongs back from exactly that address+port right now. Used to keep the /// audio target pinned to a proven-good endpoint instead of chasing a multi-homed peer's /// other (possibly unreachable) advertised address every discovery refresh. 2026-05-31. private bool IsEndpointHeartbeatHealthy(IPEndPoint endpoint) { if (heartbeatService is null) return false; foreach (var h in heartbeatService.GetAllPeerHealth()) { if (h.State == PeerHealthState.Healthy && h.AudioEndpoint.Equals(endpoint)) { return true; } } return false; } /// /// Stale-address recovery. When exactly one tracked peer has gone Unreachable (its /// resolved address — often a stale DNS answer — has no host behind it) and exactly one /// OTHER address is actively heartbeat-pinging us, that address is almost certainly the /// same peer at its real location. Re-point the audio sender, heartbeat tracking and the /// receiver allow-list at the live address. /// /// Deliberately conservative — it fires only on the unambiguous one-unreachable-and-one- /// live case, only for private-range (RFC1918) live addresses (so a relay's public source /// address can never hijack the sender), and with a 10 s cooldown so it can't thrash. The /// messier multi-peer case is left for the user to sort out by hand. Runs once per second /// from the status ticker. 2026-05-15. /// private void TryAdoptLiveHeartbeatAddress() { if (heartbeatService is null || !connected) return; // Cooldown: adoption re-points the sender; give a freshly-adopted endpoint time to // prove healthy (or fail) before another swap can fire. if (DateTime.UtcNow - lastAddressAdoptionUtc < TimeSpan.FromSeconds(10)) return; var unreachable = heartbeatService.GetAllPeerHealth() .Where(h => h.State == PeerHealthState.Unreachable) .ToList(); if (unreachable.Count != 1) return; // 0 = nothing wrong; 2+ = ambiguous var liveSources = heartbeatService.GetUntrackedPingSources(); if (liveSources.Count != 1) return; // 0 = no candidate; 2+ = ambiguous var deadEp = unreachable[0].AudioEndpoint; var liveAddr = liveSources[0]; if (liveAddr.Equals(deadEp.Address)) return; // same machine — nothing to adopt if (!IsPrivateLanAddress(liveAddr)) return; // never adopt a public / relay source // Find the selected-peer entry whose endpoint is the dead one. var match = selectedPeerEndpoints .FirstOrDefault(kv => kv.Value.Address.Equals(deadEp.Address) && kv.Value.Port == deadEp.Port); if (match.Key == Guid.Empty) return; // Reuse the dead endpoint's port — a peer that moved on the LAN keeps its audio port. var newEp = new IPEndPoint(liveAddr, deadEp.Port); selectedPeerEndpoints[match.Key] = newEp; var label = selectedPeerLabels.GetValueOrDefault(match.Key, deadEp.Address.ToString()); logFile.Event($"heartbeat: adopted live address for \"{label}\": {deadEp} unreachable, peer is pinging from {newEp}"); lastAddressAdoptionUtc = DateTime.UtcNow; // ApplyAudioRuntime re-points BOTH the audio sender (SetReceivers) and heartbeat // tracking (SetTrackedPeers); PushAllowedReceiveSenders re-points the receiver // allow-list; PushDiscoveryUnicastHints feeds the new address to discovery too. ApplyAudioRuntime(); PushAllowedReceiveSenders(); PushDiscoveryUnicastHints(); } /// True if is an IPv4 RFC1918 private-range address /// (10/8, 172.16/12, 192.168/16). Gates stale-address adoption so a relay's public /// source address can never be mistaken for a peer that moved on the LAN. private static bool IsPrivateLanAddress(IPAddress addr) { if (addr.AddressFamily != AddressFamily.InterNetwork) return false; var b = addr.GetAddressBytes(); return b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); } /// /// Wipes the rolling max-gap window and pushes forward, /// so the next continuous auto-tune tick has nothing to react to. Called whenever a user /// action (peer (de)selection, source list toggle, manually moving the latency slider) is /// likely to produce a measured "gap" that doesn't reflect the network — e.g. the user /// reselecting localhost after a 5 s pause records a 5 s inter-arrival gap, which would /// otherwise pin the auto-tune to its 200 ms cap for half a minute. /// private void InvalidateAutoTuneHistory() { recentMaxGaps.Clear(); recentRenderCbGaps.Clear(); lastSourceChangeUtc = DateTime.UtcNow; } private IPEndPoint[] SelectedSendEndpoints() { // Collapse duplicates by ip:port so the same address isn't targeted twice. return selectedPeerEndpoints.Values .GroupBy(ep => $"{ep.Address}:{ep.Port}") .Select(g => g.First()) .ToArray(); } private async Task ResolvePeerAddressAsync(string text) { // Strip any host:port suffix before resolving; the port is parsed separately by the // caller via TrySplitHostPort. var (hostOnly, _) = TrySplitHostPort(text); if (IPAddress.TryParse(hostOnly, out var direct)) return direct; try { var addresses = await Dns.GetHostAddressesAsync(hostOnly); return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses.FirstOrDefault(); } catch { return null; } } /// /// Parse "host:port" or just "host" / "ipv4:port" / IPv4. Returns (host, port?) where port /// is null when the user didn't include one. IPv6 literals are not supported in the manual /// peer field today; if/when they are, they'll need bracket syntax. Bare numeric strings are /// treated as hosts (no port). /// internal static (string host, int? port) TrySplitHostPort(string text) { if (string.IsNullOrWhiteSpace(text)) return (text ?? string.Empty, null); text = text.Trim(); var colon = text.LastIndexOf(':'); if (colon <= 0 || colon == text.Length - 1) return (text, null); var maybeHost = text[..colon]; var maybePort = text[(colon + 1)..]; // If there's another colon earlier, it's likely an IPv6 literal — leave the whole thing // as the host. (Manual peer entry doesn't formally support IPv6 today, but don't // misinterpret one as host:port and resolve garbage.) if (maybeHost.Contains(':')) return (text, null); if (!int.TryParse(maybePort, out var port)) return (text, null); if (port < 1 || port > 65535) return (text, null); return (maybeHost, port); } private PeerAnnouncement CreateManualPeer(string entry, IPAddress address) { var (_, parsedPort) = TrySplitHostPort(entry); var label = string.IsNullOrWhiteSpace(entry) ? address.ToString() : entry.Trim(); return new PeerAnnouncement( Guid.NewGuid(), label, parsedPort ?? RemPacket.DefaultPeerDialPort, CanSend: true, CanReceive: true, DateTime.UtcNow, address); } private async Task AddManualPeerAsync(string text) { if (string.IsNullOrWhiteSpace(text)) { MessageBox.Show(this, "Enter an IP address or hostname for the other computer.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var address = await ResolvePeerAddressAsync(text); if (address is null) { MessageBox.Show(this, "Could not resolve that IP address or hostname.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } var rememberedEntries = settings.LoadRememberedPeers() .Select(static value => value.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); rememberedEntries.Add(text.Trim()); settings.SaveRememberedPeers(rememberedEntries); var peer = CreateManualPeer(text, address); manualPeers[peer.InstanceId] = peer; rememberedPeerInstanceIds[text.Trim()] = peer.InstanceId; SelectPeer(peer); // New peer in remembered/manual list → tell discovery to start unicasting announcements // at this address so they discover us back across VPN/WAN. PushDiscoveryUnicastHints(); logFile.Event($"manual peer added {address}:{peer.AudioPort} ({text.Trim()})"); RefreshKnownPeers(); ApplyAudioRuntime(); } private void LoadRememberedPeersFromSettings() { // No checkboxes on the form for these — they live in the dialog. We just remember them. rememberedPeerInstanceIds.Clear(); } /// /// Adds a peer's identity to the persisted Remembered list (if not already present), and /// records the entry → instance-id mapping so the Remembered dialog can display it. Used /// when connecting via the Discovered list — per Ed's spec, "Remembered" is the long /// history of every peer ever connected to, not just manually-added ones. /// private void EnsurePeerRemembered(PeerAnnouncement peer) { var entry = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString() ? peer.Address.ToString() : peer.Name; var existing = settings.LoadRememberedPeers().ToList(); if (existing.Any(e => string.Equals(e, entry, StringComparison.OrdinalIgnoreCase))) { // Already remembered — make sure the id mapping is current so // SyncDialogRememberedPeerList correctly hides this entry while the peer is connected. rememberedPeerInstanceIds[entry] = peer.InstanceId; PushDiscoveryUnicastHints(); return; } existing.Add(entry); settings.SaveRememberedPeers(existing); rememberedPeerInstanceIds[entry] = peer.InstanceId; PushDiscoveryUnicastHints(); } /// /// Tells the discovery service which IPs to send unicast announcements to. LAN broadcast /// alone doesn't reach peers across a VPN (Tailscale, WireGuard, etc.) — so we explicitly /// announce to every remembered + manual peer IP on top of broadcast. Anyone in our /// remembered list who's running RemSound and reachable will then appear in Discovered, /// regardless of physical network. Sending to an offline peer is a no-op. /// private void PushDiscoveryUnicastHints() { // Snapshot the UI-thread-owned inputs HERE, then resolve hostnames OFF the UI thread. // // The comment that used to live here claimed Dns.GetHostAddresses "returns near-instantly". // It does for a parsed IP or an already-cached name — but for a remembered HOSTNAME that // can't currently resolve (an offline peer, or a Tailscale/WireGuard name while the VPN is // down) it BLOCKS for the system DNS timeout, seconds per entry. This method runs on the UI // thread on every connect / disconnect / add-peer (it's how discovery learns its VPN unicast // targets), so that block froze the whole window for a few seconds — which a screen-reader // user experiences as the entire machine locking up (issue #10). Same class of bug as the // v3.0.1 UPnP-on-the-UI-thread hang, in a newer feature. // // SetUnicastPeerAddresses just swaps a snapshot reference and fires an announcement, and is // already called from the discovery receive loop's own thread, so it's safe to call from a // background thread here. The hints are advisory and re-pushed frequently, so a slightly // stale result from an overlapping resolution is harmless. var seedAddresses = manualPeers.Values.Select(p => p.Address).ToList(); var rememberedEntries = settings.LoadRememberedPeers().ToList(); Task.Run(() => { var hints = new HashSet(seedAddresses); foreach (var entry in rememberedEntries) { if (string.IsNullOrWhiteSpace(entry)) continue; if (IPAddress.TryParse(entry, out var direct)) { hints.Add(direct); continue; } try { foreach (var addr in Dns.GetHostAddresses(entry)) { if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) { hints.Add(addr); } } } catch { // Not resolvable right now — skip; re-pushed next time this method runs. } } discovery.SetUnicastPeerAddresses(hints); }); } /// /// After deleting an item from a CheckedListBox, focus the next sensible item so NVDA /// announces the new selection. If something exists at the same index that the deleted /// item occupied, focus that (it's the next-down). Otherwise drop back to the last item. /// Empty list = no focus change. /// private static void FocusListItemAfterDelete(CheckedListBox list, int prevIndex) { if (list.IsDisposed) return; var count = list.Items.Count; if (count == 0) return; var target = Math.Clamp(prevIndex, 0, count - 1); list.SelectedIndex = target; if (!list.Focused) list.Focus(); } private void RemoveSelectedRememberedPeer(CheckedListBox list) { if (list.SelectedItem is not RememberedPeerItem selected) return; if (rememberedPeerInstanceIds.TryGetValue(selected.Entry, out var pid)) { manualPeers.Remove(pid); DeselectPeer(pid); rememberedPeerInstanceIds.Remove(selected.Entry); } var remaining = settings.LoadRememberedPeers().Where(e => !string.Equals(e, selected.Entry, StringComparison.OrdinalIgnoreCase)); settings.SaveRememberedPeers(remaining); RefreshKnownPeers(); ApplyAudioRuntime(); PushDiscoveryUnicastHints(); } // ===================== Mode-change warnings ===================== // ShowBothModeWarning + its TaskDialog retired 2026-05-11. The popup warned about the // ~45 ms latency penalty of classic mixed-Both mode. Classic Both is no longer reachable // from the UI (only WasapiOnly and BothIndependent are produced now, both fast-path), so // the warning has nothing to fire on. AppConfig.BothModeWarningSuppressed is kept on disk // for backward-compat — old config files still deserialise, new code just ignores it. /// /// Confirmation popup after Save (Ctrl+S / File → Save) overwrites the current profile. /// Native TaskDialog, NVDA reads /// the heading + body automatically, verification checkbox is part of the tab order so /// "Do not show me this again" is reachable without a mouse. Once ticked the preference /// lives in remsound.config.json as /// ; it's only consulted from /// the in-place Save path — Save As never reaches here (its own dialog is the /// confirmation). /// private void ShowSaveConfirmationDialog(string title) { var verification = new TaskDialogVerificationCheckBox("Do not show me this message again"); var page = new TaskDialogPage { Caption = AppName, Heading = "Profile saved", Text = $"\"{title}\" has been saved.", 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.SaveProfileConfirmationSuppressed = true; cfg.Save(); logFile.Event("save-profile confirmation suppressed by user (saved to remsound.config.json)"); } } // ===================== Status / log ===================== private void UpdateStatus() { var since = connected ? (DateTime.UtcNow - connectedSinceUtc).ToString(@"h\:mm\:ss") : "0:00:00"; var sendText = sender.IsRunning ? $"sending {sender.PacketsSent} packets ({sender.BytesSent / 1024} KB) codec={sender.Codec} from \"{sender.CaptureDeviceName}\"" : (IsSendEnabled && !HasCheckedSendDevice() ? "not sending — tick a capture device" : "not sending"); var receiveText = receiver.IsRunning ? $"receiving {receiver.PacketsReceived} packets, buffer {receiver.CurrentBufferMs} ms (target {receiver.TargetLatencyMs} ms), underruns {receiver.Underruns}, drops {receiver.Drops} on \"{receiver.OutputDeviceName}\"" : "not receiving"; var peerCount = knownPeers.Count; var hbSummary = heartbeatService?.GetHealthSummary() ?? "no peers"; statusLabel.Text = $"Connected for {since}. {peerCount} peer(s) known. {sendText}. {receiveText}. Heartbeat: {hbSummary}."; healthLabel.Text = connected ? sender.IsRunning || receiver.IsRunning ? "Health: streaming" : "Health: idle" : "Health: disconnected"; } private void SnapshotLogIfDue() { if (DateTime.UtcNow - lastSnapshotUtc < TimeSpan.FromMilliseconds(950)) return; lastSnapshotUtc = DateTime.UtcNow; // Prune any sessions on the receiver that haven't received packets in a while. This is // serialised on the network-thread lock inside the receiver, so doing it from the UI // tick is safe. receiver.PruneIdleSessions(); // Re-evaluate which selected peers are reachable enough to receive the audio stream. // Drops long-unreachable endpoints from the high-rate send list (heartbeat keeps probing // them so they auto-recover). Cheap no-op when nothing changed. 1 Hz is plenty. RefreshAudioReceivers(); // Refresh the tray icon's hover tooltip so it reflects the current peer count and // send / receive routing (WASAPI / ASIO / both). 1 Hz cadence is fine — the user is // hovering, not staring at a counter — and BuildTrayTooltip is allocation-cheap. trayController.SetTooltip(BuildTrayTooltip()); // Surface any password mismatch / out-of-date peer the receiver has spotted (once per // change). Cheap when everything matches. CheckPeerSecurity(); // Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main) // explicitly avoids gen2 collections to keep audio scheduling smooth — but that same // suppression means finalizers for IDisposable wrappers that didn't get explicit // Dispose calls also never run. Most paths have been fixed (StreamSession, // OpusEncoderState, AudioRecorder all call decoder/encoder Dispose now), but this // serves as a belt-and-braces backstop for any future code path we forget to wire, // and for cleaning up any per-call native scratch allocations that Concentus.Native // (or any other library) might accumulate. Forced gen2 every 5 minutes (300 ticks at // 1 Hz) on a background thread so the gen2 work doesn't hitch the UI thread; audio // threads are separate and unaffected. Andre's v3.0.1 receive session showed the // unmanaged working set climbing 83 MB → 3.5 GB over 23 hours; this caps it. nativeReaperTickCount++; if (nativeReaperTickCount >= 300) { nativeReaperTickCount = 0; Task.Run(() => { try { GC.Collect(2, GCCollectionMode.Optimized, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); } catch { /* GC pass is best-effort — never let it crash the snapshot tick */ } }); } // Detect peer health transitions and play connect/disconnect cues. DetectAndAnnouncePeerHealthTransitions(); // If neither logs nor auto-tune is active we have nothing to do — neither audience // wants the diag work. When logs are off but auto-tune is on (the gate is on for // auto-tune), we fall through and run the snapshot+drain so the auto-tune feed // (recentMaxGaps / recentRenderCbGaps at the bottom of this method) gets fresh // data. The logFile.Snapshot and logFile.Event calls below are themselves cheap // no-ops when logFile.Enabled is false, so we don't need to wrap individual writes. if (!DiagnosticsGate.Enabled) return; // Handle-TYPE breakdown — names which kind of handle is leaking (Event / Section / Key / // Thread / …), the piece the plain handles= count can't give us. It walks the whole // system handle table, so it's far heavier than the per-tick meter: run it about once a // minute, only when logging is actually on (the diag gate can be open for auto-tune with // logging off), and off the UI thread. logFile.Event is thread-safe. 2026-06-07. handleProbeTickCount++; if (handleProbeTickCount >= 60 && logFile.Enabled) { handleProbeTickCount = 0; Task.Run(() => { try { // Always log — Summarize returns a "probe-error …" string on failure rather // than empty, so a blank build vs a silently-failing probe can never again be // confused (that cost us a run on 2026-06-07). logFile.Event($"handle-types: {HandleTypeProbe.Summarize()}"); } catch { /* probe is best-effort — never let it disturb the tick */ } }); } // SNAP latency columns: in classic modes the legacy MaxLatencyMs / TargetLatencyMs // pair holds the only route's value (Mixed). In BothIndependent we map them to the // WASAPI lane (= the lane the existing slider drives) and emit the ASIO lane in the // appended ASIO columns. That keeps the existing columns meaningful — they still // represent "what the main slider shows" — and the appended columns expose the // second lane to anyone reading the log file. var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; var primaryMaxMs = inBothIndependent ? receiver.MaxLatencyMsFor(RenderRoute.WasapiLane) : receiver.MaxLatencyMs; var primaryTargetMs = inBothIndependent ? receiver.TargetLatencyMsFor(RenderRoute.WasapiLane) : receiver.TargetLatencyMs; var asioMaxMs = inBothIndependent ? receiver.MaxLatencyMsFor(RenderRoute.AsioLane) : 0; var asioTargetMs = inBothIndependent ? receiver.TargetLatencyMsFor(RenderRoute.AsioLane) : 0; logFile.Snapshot( connected: connected, sendRunning: sender.IsRunning, receiveRunning: receiver.IsRunning, codec: SnapshotCodecLabel(), maxLatencyMs: primaryMaxMs, targetLatencyMs: primaryTargetMs, bufferMs: receiver.CurrentBufferMs, senderPackets: sender.PacketsSent, senderBytes: sender.BytesSent, senderDevice: sender.CaptureDeviceName, receiverPackets: receiver.PacketsReceived, receiverBytes: receiver.BytesReceived, underruns: receiver.Underruns, drops: receiver.Drops, receiveDevice: receiver.OutputDeviceName, heartbeat: heartbeatService?.GetHealthSummary() ?? "no peers", opusFecRecoveries: receiver.OpusFecRecoveries, opusUnrecoveredGaps: receiver.OpusUnrecoveredGaps, maxLatencyMsAsio: asioMaxMs, targetLatencyMsAsio: asioTargetMs); // First-of-kind events make it easy to see in the log where the chain breaks. if (sender.IsRunning) { if (!firstCaptureCallbackLogged && sender.CaptureCallbacks > 0) { firstCaptureCallbackLogged = true; logFile.Event($"first capture callback received ({sender.CaptureBytes} bytes, format {sender.CaptureFormatDescription ?? "?"})"); } if (!firstSenderPacketLogged && sender.PacketsSent > 0) { firstSenderPacketLogged = true; logFile.Event($"first packet sent ({sender.BytesSent} bytes total)"); } // If capture isn't producing samples, repeat the warning every 5 s so it's visible. if (sender.CaptureCallbacks == 0 && DateTime.UtcNow - lastCaptureZeroLogUtc > TimeSpan.FromSeconds(5)) { lastCaptureZeroLogUtc = DateTime.UtcNow; var err = sender.LastCaptureError; logFile.Event($"sender running but no capture callbacks yet (device=\"{sender.CaptureDeviceName}\", format=\"{sender.CaptureFormatDescription ?? "?"}\", error=\"{err ?? "none"}\")"); } } else { firstCaptureCallbackLogged = false; firstSenderPacketLogged = false; } // Diag block runs if EITHER side is active. The original gate was `receiver.IsRunning` // only, which was correct for the typical bidirectional case but silently dropped the // diag line on send-only machines (no receiver bound, but the sender's capture-callback // gap is exactly what we want to log there). Adding `|| sender.IsRunning` lets the // send-only branch below actually emit. if (receiver.IsRunning || sender.IsRunning) { if (receiver.IsRunning && !firstReceiverPacketLogged && receiver.PacketsReceived > 0) { firstReceiverPacketLogged = true; logFile.Event($"first packet received ({receiver.BytesReceived} bytes total)"); } // Sub-second diagnostics — tells us what's actually happening at audio-rate // resolution rather than guessing from a 1 Hz buffer reading. Look for: // bufMin near 0 or maxGapMs > 30 → network burstiness or thread starvation // bufAvg << target → clock drift, adaptive rate should compensate // inputRate drifting from 48000 → adaptive rate is actively compensating // maxReadMs much bigger than 15 → WASAPI is gulping more than expected var diag = receiver.IsRunning ? receiver.TakeDiagnosticsSnapshot() : default; // Pull sendCbGapMs unconditionally so it always resets cleanly between log emissions. // We log it on whichever line we end up emitting — the receiver's diag line if the // receiver has activity, otherwise a sender-only line. Skipping the call when the // receiver is idle would leave the sender's max growing forever, never resetting. var sendCbGapMs = sender.TakeMaxCaptureCallbackGapMs(); if (diag.BufferSampleCount > 0 || diag.RenderReadCount > 0) { // pcmRej / pcmDiscard let us see if PCM frames are being lost in assembly // (out-of-order parts, mismatched parts, partial frame discarded). Both are // cumulative since the stream session started — non-zero growing values during // a steady-state run indicate the network/USB stack is jumbling PCM packet pairs. // sendCbGapMs = sender's worst capture-callback gap since the last log. // High value here (e.g. > 10 ms with ASIO buffer ≤ 5 ms) means the LOCAL // capture path stalled — GC pause, USB driver hiccup, scheduler delay. The // emitted audio will contain a discontinuity at that moment, which the peer // can't detect (no packets lost, just audio with a hole). When this metric // and the receiver's own maxGapMs both spike together, suspect the network; // when only sendCbGapMs spikes, suspect this machine's audio stack. // renderCbGapMs = worst gap between consecutive audio-render callbacks on THIS // machine. Healthy = sub-ms variance from the audio buffer's natural period // (e.g. ~5 ms for a 256-sample ASIO buffer at 48 kHz). Spikes here mean Windows // scheduled the audio-output thread late, which causes the audio device's // hardware buffer to underrun even though RemSound's playout buffer was full — // RemSound's "Underruns" counter would NOT see this, so it can be the smoking // gun for clicks-with-everything-else-clean. // // Drop-cause split (Codex's catch — the legacy `Drops` rolled up several // unrelated mechanisms): // trimB = bytes deliberately dropped by the smoothness-knob click-trim // trimN = number of times that trim fired (so we can see frequency) // drainB = bytes dropped on a one-shot drain (knob change) // ovfB = ringbuffer-overflow / catastrophic-cap drops (everything else) // pktRej = malformed/unknown-type packets we rejected at the network edge var trimBytes = receiver.TrimDropBytes; var trimFires = receiver.TrimFireCount; var drainBytes = receiver.DrainDropBytes; var ovfBytes = receiver.RingbufferOverflowDropBytes; var pktRej = receiver.PacketsRejectedMalformed; // Diag legend (post-Phase-3 cleanup): // driftDrop / driftRep = Phase-2 drift correction counters. Each event = one // stereo frame dropped (sender clock faster) or repeated (sender clock // slower) = 21 µs of audio at 48 kHz with crossfade smoothing, designed to // be inaudible. Healthy: one or the other slowly climbing at a few/sec rate. // trimB / trimN / drainB / ovfB = the click-trim safety net + drain on knob // change + ringbuffer overflow. All should stay near zero in normal // operation now that the drift corrector handles steady drift. // spikesN = adaptive second-derivative outlier count. Music-content invariant. // >0 = real anomalous samples in RemSound's output. ~0 = clean output. // sampleStepMax = raw peak step magnitude (false-positive prone on bright // music; informational only). // driftDrops / driftReps / driftAccumulator readings removed 2026-05-23 along // with their dead accessors. The Phase-4 fixed-ratio resampler design never // increments those counters; the columns were always zero. filteredErrorFrames // below is the still-useful "where the buffer is sitting on average" signal — // computed every Read by the active LP filter. var concealNow = receiver.ConcealmentFires; var shortReadNow = receiver.ShortReadFires; var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow; var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow; // Device-gulp short-reads this second — the inaudible, on-target partial reads the // cause-aware auto-tune ignores. High devGulpΔ alongside a near-zero concealΔ is the // onboard-Realtek chunky-render-callback fingerprint we built the split to catch. var deviceGulpNow = receiver.DeviceGulpUnderruns; var deviceGulpDeltaDiag = deviceGulpNow - prevDiagDeviceGulp; prevDiagDeviceGulp = deviceGulpNow; var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires; // Live state — current LP-filtered drift error. Negative = buffer running below // target on average; positive = above. var filteredErrorFrames = receiver.FilteredDriftErrorFrames; // 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) // rxDispMs = receiver's worst time-in-onPacket dispatch (after kernel receive) // If observed maxGapMs is large but all three of these are sub-ms, the variance // is between sender SendTo-return and receiver ReceiveFrom-return — i.e. the // network or the kernel TX/RX path. If one of them spikes alongside maxGapMs, // that's where our code is taking the time. 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 reading + column removed 2026-05-23. The FanOutSource was retired // mid-May (each lane reads its own filtered PlayoutEngine source directly); the // measurement always returned 0 and surfaced an unhelpful diag column. // 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 // 30–50 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; // Process-wide self-meter (item 1 + 3 of RemSoundefficiency.md). Single // snapshot covers CPU%, managed heap MB, working set MB, allocation rate. var selfMeter = processSelfMeter.Take(); // Per-thread work-time (item 2 of RemSoundefficiency.md). Each is the // milliseconds of CPU that thread (or thread group) consumed in the last // second; in a clean steady-state session they should all be small. The // four categories follow the request: capture, send, receive, render. // captureMs covers ASIO + WASAPI capture bodies and the MixingEngine tick; // sendMs is encode + sendto on the audio thread; recvMs is the network // thread's packet handler; renderMs is the audio render thread's mix + // limiter + pack work. var captureMs = sender.TakeCaptureWorkMs(); var sendMs = sender.TakeSendWorkMs(); var recvMs = receiver.TakeReceiveWorkMs(); var renderMs = receiver.TakeRenderWorkMs(); // 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. // // 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 stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer(); var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer(); var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB; var capPeak = sender.TakeMaxSenderPreEncodePeak(); var sndAudFr = sender.TakeSenderAudioFramesSent(); var clippedNow = sender.ClippedSampleCount; var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow; 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 // 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} rxNetGapMs={rxNetGapMs} " + $"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " + $"cpu={selfMeter.CpuPercentOneCore:0.0}% memMB={selfMeter.ManagedHeapMb:0.0} wsMB={selfMeter.WorkingSetMb:0.0} allocKBps={selfMeter.AllocatedKbPerSecond:0.0} " + $"privMB={selfMeter.PrivateBytesMb:0.0} gcHeapMB={selfMeter.GcHeapMb:0.0} gcFragMB={selfMeter.GcFragmentedMb:0.0} gcCommitMB={selfMeter.GcCommittedMb:0.0} handles={selfMeter.HandleCount} threads={selfMeter.ThreadCount} " + $"captureMs={captureMs:0.0} sendMs={sendMs:0.0} recvMs={recvMs:0.0} renderMs={renderMs:0.0} " + $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + $"concealΔ={concealDelta} shortReadΔ={shortReadDelta} devGulpΔ={deviceGulpDeltaDiag} " + $"filtErr={filteredErrorFrames:0.0}f " + $"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} 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}"); } else if (sender.IsRunning) { // Send-only machine (no receive output ticked). Emit a sender-side diag line so // 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(); // Per-lane pre-encode probes — see the full-diag comment above for the // rationale (per-lane fixes the cross-stream artefact in BothIndependent). // 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 stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer(); var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer(); var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB; var clippedNow = sender.ClippedSampleCount; var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow; // 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; // Process self-meter + per-thread work-time on the send-only side too. // captureMs covers the WASAPI / ASIO callback bodies; sendMs is the encode // + sendto work; recvMs / renderMs stay at 0 (no playback on this machine // by definition for the send-only branch). See item 1, 2, 3 of // RemSoundefficiency.md. var selfMeter = processSelfMeter.Take(); var captureMs = sender.TakeCaptureWorkMs(); var sendMs = sender.TakeSendWorkMs(); // capPeak = loudest sample reaching the encoder; sndAudFrΔ = audio frames that // actually left the socket this second. Together they split "mic captured but // nothing sent" (drop at encode/encrypt) from "mic captured and sent" — the // measurement the "mic only works in ASIO" report needs, now on the talker side too. var capPeak = sender.TakeMaxSenderPreEncodePeak(); var sndAudFr = sender.TakeSenderAudioFramesSent(); logFile.Event( $"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " + $"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} " + $"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} " + $"cpu={selfMeter.CpuPercentOneCore:0.0}% memMB={selfMeter.ManagedHeapMb:0.0} wsMB={selfMeter.WorkingSetMb:0.0} allocKBps={selfMeter.AllocatedKbPerSecond:0.0} " + $"privMB={selfMeter.PrivateBytesMb:0.0} gcHeapMB={selfMeter.GcHeapMb:0.0} gcFragMB={selfMeter.GcFragmentedMb:0.0} gcCommitMB={selfMeter.GcCommittedMb:0.0} handles={selfMeter.HandleCount} threads={selfMeter.ThreadCount} " + $"captureMs={captureMs:0.0} sendMs={sendMs:0.0} " + $"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}"); } // Synthesised end-to-end one-way latency estimate. Sums: // * sender_accumulator: half the codec frame size (avg packet wait) // * wire_one_way: lowest active peer's heartbeat RTT / 2 // * receiver_queue: bufAvg from diag (the real measured queue depth, 0 on send-only) // * render_buffer: rough estimate per audio mode // Logged whenever either side is active so we capture the latency picture even when // the local machine is send-only. if ((diag.BufferSampleCount > 0 || diag.RenderReadCount > 0) || sender.IsRunning) { var senderAccumulatorMs = SenderAccumulatorEstimateMs(); var wireOneWayMs = LowestPeerRttMs() / 2.0; var renderBufferMs = RenderBufferEstimateMs(); var totalMs = senderAccumulatorMs + wireOneWayMs + diag.BufferAvgMs + renderBufferMs; logFile.Event($"latency-probe estimated one-way ≈ {totalMs:0.0}ms " + $"(send-accum={senderAccumulatorMs:0.0}, wire={wireOneWayMs:0.0}, recv-queue={diag.BufferAvgMs}, render={renderBufferMs:0.0})"); } // If a new stream session opened since the last SNAP tick, flush the gap windows. // The diag.MaxArrivalGapMs we're about to enqueue is bounded inside this tick by // ReceiverDiagnostics.ResetGapMeasurements() (called from AudioReceiver when the // session opens), but any previously-queued entries are stale relative to the new // session. Bumping lastSourceChangeUtc also makes the auto-tune defer for one // interval, letting the new session's measurements populate the window before any // recommendation fires. var openCount = receiver.SessionsOpenedCount; if (openCount > lastObservedSessionsOpenedCount) { lastObservedSessionsOpenedCount = openCount; recentMaxGaps.Clear(); recentRenderCbGaps.Clear(); lastSourceChangeUtc = DateTime.UtcNow; } // Push this second's max-gap reading into the rolling window the continuous // auto-tune samples from. Capped at RecentMaxGapWindowSeconds entries so older // readings naturally fall out as conditions evolve. if (diag.PacketCount > 0) { recentMaxGaps.Enqueue(diag.MaxArrivalGapMs); while (recentMaxGaps.Count > RecentMaxGapWindowSeconds) recentMaxGaps.Dequeue(); // Mirror window for actual render-callback period. Same windowing so they age // out together; auto-tune uses the max of this for an honest formula. recentRenderCbGaps.Enqueue(diag.MaxRenderCallbackGapMs); while (recentRenderCbGaps.Count > RecentMaxGapWindowSeconds) recentRenderCbGaps.Dequeue(); } } else { firstReceiverPacketLogged = false; } } private void AppendLogEntry(string message) { // No on-form log box now (kept just-in-status-line). Leaving this method to make the call sites // future-proof; if we re-add a visible log box, AppendLogEntry is the single hook point. logFile.Event(message); } // ===================== Tray ===================== private void ToggleTrayFromHotkey() { BeginInvoke(() => trayController.Toggle()); } /// /// Most Alt+letter shortcuts are wired via the WinForms `&` mnemonic on the relevant /// control's Text (Buttons, CheckBoxes) or its paired Label (ListBoxes, NumericUpDowns, /// ComboBoxes — see for the label-→target dispatch). The /// framework's built-in ProcessMnemonic walk handles those automatically: when the user /// presses Alt+letter, only controls on the visible tab respond, which gives us per-tab /// shortcut isolation as a free side-effect of how WinForms scopes mnemonics. /// protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { // 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); } /// Defensive gate for global menu shortcuts that change state. See the comment /// in for the full rationale. 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 ===================== /// Called once from Shown after device lists are populated. Applies the /// control-state portion of the loaded profile (device ticks, send/receive checkboxes, /// volume) — settings-shaped fields were applied earlier in the constructor via /// settings.ApplyProfile(). Devices in the profile that don't exist on this machine /// are silently skipped (the matching CheckedListBox simply won't have them ticked). private void ApplyPendingProfileToControls() { if (pendingProfile is null) return; var p = pendingProfile; applyingProfile = true; // Silence the generic checkbox tick/untick for the whole bulk apply. The per-box Focused // gate isn't enough on launch: the box we tick in code is often the focused control, so it // would click. This + the applyingProfile guard on the send/receive cue keep a profile load // down to just the startup and (real) connect cues. CheckSoundService.Suppressed = true; try { // Volume first — affects what's audible during the rest of this method. volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum); // Push volume + mute to the engine. Assigning .Value does NOT fire the Scroll handler, so // without this a profile saved at e.g. 50% would show 50% but play at full volume until // the slider was nudged. Mute is restored from the saved state for the same reason. receiver.Volume = volumeBar.Value / 100f; receiver.IsMuted = p.Muted; // Tick checkboxes. Order matters: setting Checked fires runtime apply paths // (Connect/Disconnect) so the side-effect cascade has to happen here, not in // the constructor where the engines aren't fully wired up yet. ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs); // Seed remembered receive-output intent from the profile so a selected card that's // absent now (or unplugged later) is re-ticked + re-opened when it appears (issue #5). rememberedReceiveOutputIds.Clear(); foreach (var rid in p.SelectedWasapiReceiveOutputs) rememberedReceiveOutputIds.Add(rid); ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs); ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs); ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs); ApplyTicksToList(asioSendDevicesList, p.SelectedAsioSendInputs); receiveAudioCheckbox.Checked = p.ReceiveAudioOn; sendMyAudioCheckbox.Checked = p.SendAudioOn; // Re-establish previously-connected peers. Each entry is re-resolved + re-selected // exactly as if the user had typed it into the manual-peer field. Discovered peers // (no longer reachable / different IP) just fail gracefully — no popup. ReconnectSavedPeers(p.SelectedConnectedPeers); } catch (Exception ex) { AppendLogEntry($"profile apply: error applying \"{p.Title}\": {ex.GetType().Name}: {ex.Message}"); } finally { // Don't re-apply on subsequent device-list refreshes. The user's later ticks are // captured by save-profile from current control state; we don't keep pulling from // the original profile forever. pendingProfile = null; applyingProfile = false; CheckSoundService.Suppressed = false; } } /// Tick the items in whose DeviceId appears in /// . Items not in the wanted set are unticked. Items in the /// wanted set that don't exist on this machine are silently dropped (this is how the /// profile system handles missing-hardware portability). private static void ApplyTicksToList(CheckedListBox list, IReadOnlyList wantedIds) { if (list.Items.Count == 0) return; var wanted = new HashSet(wantedIds, StringComparer.OrdinalIgnoreCase); for (var i = 0; i < list.Items.Count; i++) { if (list.Items[i] is not AudioDeviceChoice choice || choice.DeviceId is null) continue; var shouldBeChecked = wanted.Contains(choice.DeviceId); if (list.GetItemChecked(i) != shouldBeChecked) { list.SetItemChecked(i, shouldBeChecked); } } } /// 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 /// "RemSound — New profile" (a fresh, unsaved session). 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". private string FormatWindowTitle(string? loadedTitle) { var readOnlySuffix = currentProfileReadOnly ? " (read-only)" : ""; return string.IsNullOrEmpty(loadedTitle) ? $"{AppName} — New profile{readOnlySuffix}" : $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}"; } /// Update existing profile button. Overwrites the active profile with current /// state. No prompt — user explicitly chose this button to commit. Hidden when no /// profile is loaded. private void UpdateExistingProfile() { if (profileStore is null || string.IsNullOrEmpty(currentProfileTitle)) { // Defensive — button should be hidden in this case. return; } SaveProfileTo(currentProfileTitle); } /// Save profile as button. Always prompts for a (new) name. From a blank /// template this is the only way to create the first profile; from a loaded profile this /// forks a copy under a new name and switches to that copy as the active profile. private void SaveProfileAs() { if (profileStore is null) { MessageBox.Show(this, "Profile system not active in this run.", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // Real Windows Save As dialog (2026-05-10) — picks an arbitrary path with the standard // filename + folder picker, instead of the previous text-only "Profile name" prompt. // Default folder is the active profiles folder. Saving inside that folder produces a // profile that's loadable from File → Open profile next launch; saving outside is an // export the user is responsible for managing (RemSound only auto-discovers profiles // in AppConfig.ProfilesDirectory, so external saves don't appear in the picker). using var dialog = new SaveFileDialog { Title = "Save profile as", Filter = "RemSound profiles (*.json)|*.json", DefaultExt = "json", AddExtension = true, OverwritePrompt = true, InitialDirectory = profileStore.BaseDirectory, FileName = string.IsNullOrEmpty(currentProfileTitle) ? "" : currentProfileTitle + ".json", }; if (dialog.ShowDialog(this) != DialogResult.OK) return; var path = dialog.FileName; var title = Path.GetFileNameWithoutExtension(path); if (string.IsNullOrWhiteSpace(title)) return; try { var profile = BuildCurrentProfile(title); var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json); 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; AppendLogEntry($"profile saved: \"{title}\" → {path}"); unsavedChanges = false; // A freshly created profile has no password yet, and encryption is always on — so // ask for one now and write it straight into the file we just saved. OK requires a // non-empty password (requireNonEmpty); Cancel still leaves it passwordless and the // streaming gate will ask again when needed. if (string.IsNullOrEmpty(currentProfilePassword)) { var pw = ProfilePasswordDialog.Show(title, "", requireNonEmpty: true); if (!string.IsNullOrEmpty(pw)) { currentProfilePassword = pw; RecomputeAudioCrypto(); PersistPasswordOnly(pw); AppendLogEntry($"profile password set on creation for \"{title}\""); } } // No confirmation popup here. The Save-As dialog the user just dismissed is itself // the explicit, user-driven "I am saving to this path" — a follow-up "Saved." popup // is pure friction (one more Enter press, one more NVDA read of the same fact). // The window title updates to the new name, the file appears on disk, and the // baseline diff resets — all the silent affordances the user actually needs. } catch (Exception ex) { MessageBox.Show(this, $"Could not save profile: {ex.Message}", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } /// Build a Profile POCO from the current MainForm control state. Used by the /// Save / Save as / SerializeCurrentStateAsProfile paths so the snapshotting logic lives /// in one place. private Profile BuildCurrentProfile(string title) { var profile = new Profile { Title = title }; settings.CopyTo(profile); // Carry the active read-only (lock) flag into the saved snapshot. Without this line a // deliberate save of a locked profile — e.g. the "save through the lock" flow Andre // uses — would write the profile back with ReadOnly defaulting to false, silently // UNLOCKING it on disk. Next launch the profile would no longer be read-only, the // unsaved-changes prompt would start firing again, and (worse) that prompt could block // an unattended auto-update from restarting. The lock flag must survive every save. profile.ReadOnly = currentProfileReadOnly; // Likewise the encryption password (stored scrambled) — carried through every save so a // routine Save never wipes it (same bug class the ReadOnly line above fixes). profile.Password = RemSoundCrypto.Obfuscate(currentProfilePassword); profile.Volume = volumeBar.Value; profile.Muted = receiver.IsMuted; profile.ReceiveAudioOn = receiveAudioCheckbox.Checked; profile.SendAudioOn = sendMyAudioCheckbox.Checked; profile.SelectedWasapiReceiveOutputs = ExtractCheckedDeviceIds(receiveOutputDevicesList); profile.SelectedAsioReceiveOutputs = ExtractCheckedDeviceIds(asioReceiveOutputDevicesList); profile.SelectedWasapiSendOutputs = ExtractCheckedDeviceIds(sendOutputDevicesList); profile.SelectedWasapiSendInputs = ExtractCheckedDeviceIds(sendInputDevicesList); profile.SelectedAsioSendInputs = ExtractCheckedDeviceIds(asioSendDevicesList); profile.SelectedConnectedPeers = GatherSelectedPeerEntries(); return profile; } /// Common save body — gathers all current state into a Profile and writes it. /// On success, becomes the active profile (sets currentProfileTitle, updates window /// title, refreshes button visibility, and shows a confirmation popup). private void SaveProfileTo(string title) => SaveProfileTo(title, showConfirmation: true); private void SaveProfileTo(string title, bool showConfirmation) { if (profileStore is null) return; try { SaveCurrentStateToProfileFile(title); AppendLogEntry($"profile saved: \"{title}\""); // Save cue (2026-05-28): fires after any successful save — Save AND Save As, since // both routes funnel through this single method. Honours the EnableSaveCue per- // profile flag; the cue is silent if the user has unticked it in Preferences or if // sounds\save.wav doesn't exist and no custom override has been set. if (settings.LoadEnableSaveCue()) saveSound?.Play(); unsavedChanges = false; if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed) { // Explicit confirmation. Without this the only feedback is the silent // baseline-diff reset; sighted users miss it, screen-reader users only catch // it on the next focus event. TaskDialog (not MessageBox) so we can attach a // "Do not show me this again" verification checkbox — NVDA reads the checkbox // as part of the dialog tab order, and once ticked the preference persists in // remsound.config.json. Suppressed entirely when invoked from the close- // confirmation flow (the user already confirmed save+exit; extra Enter = friction). ShowSaveConfirmationDialog(title); } } catch (Exception ex) { MessageBox.Show(this, $"Could not save profile: {ex.Message}", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); } } /// Builds a Profile from the current control state and writes it via the store. /// Doesn't touch UI feedback — that's the caller's job. Throws on store failure. private void SaveCurrentStateToProfileFile(string title) { if (profileStore is null) return; var profile = BuildCurrentProfile(title); // If the active profile has a tracked path (set by Save As or by startup load), // write to that exact location — even if it's outside BaseDirectory. Otherwise // (no path tracked, e.g. blank-template-direct-save edge case) fall through to the // store's BaseDirectory-relative save. if (!string.IsNullOrEmpty(currentProfilePath)) { var dir = Path.GetDirectoryName(currentProfilePath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(currentProfilePath, json); } else { profileStore.Save(profile); currentProfilePath = profileStore.PathFor(title); } currentProfileTitle = title; Text = FormatWindowTitle(title); AccessibleName = Text; } /// Mark the profile as having unsaved user changes. No-op while a profile is /// being applied programmatically (otherwise loading a profile would immediately mark /// itself dirty). Hooked from peer (de)selection plus a few other key paths; the close /// path also does a JSON-state diff as a safety net to catch settings we forgot to hook. private void MarkProfileDirty() { if (applyingProfile) return; unsavedChanges = true; } /// 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 . /// 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. 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)"}\""); } /// Write JUST the ReadOnly flag back to the profile file on disk, without /// touching any of the user's in-session edits. Used by /// 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. 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(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); } 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}"); } } /// File → Change this profile's password. Shows the current password (plain text, /// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST /// the password back to the profile file straight away — same immediate-persist approach as /// the lock flag, since the password needs to be there next time the profile is loaded. /// Requires a saved profile (a password is meaningless on the blank template, which has no /// file to attach it to). 2026-05-31. private void ChangeProfilePassword() { if (string.IsNullOrEmpty(currentProfileTitle) || string.IsNullOrEmpty(currentProfilePath)) { MessageBox.Show(this, "There's no saved profile to attach a password to yet. Save the current setup as a profile first (File → Save as), then set its password.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var entered = ProfilePasswordDialog.Show(currentProfileTitle, currentProfilePassword); if (entered is null) return; // cancelled currentProfilePassword = entered; RecomputeAudioCrypto(); PersistPasswordOnly(entered); AppendLogEntry($"profile password changed for \"{currentProfileTitle}\" (now {(entered.Length == 0 ? "cleared" : "set")})"); } /// Write JUST the (scrambled) password back to the profile file, leaving every /// other in-session edit untouched — the same carve-out /// uses for the lock flag, so changing the password doesn't silently flush unrelated unsaved /// changes. Read the JSON, set one field, write it back. Blank-template (no path) is a no-op. private void PersistPasswordOnly(string plaintextPassword) { if (string.IsNullOrEmpty(currentProfilePath)) return; if (!File.Exists(currentProfilePath)) return; try { var json = File.ReadAllText(currentProfilePath); var profile = JsonSerializer.Deserialize(json); if (profile is null) return; profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword); var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(currentProfilePath, newJson); } catch (Exception ex) { AppendLogEntry($"failed to persist profile password: {ex.GetType().Name}: {ex.Message}"); } } /// Derive the audio key + fingerprint from the current profile password (cached so /// the slow derivation only runs when the password actually changes) and push them down to /// the sender and receiver. No password → null key → no audio flows (encryption is /// mandatory). Called on password change, when audio is (re)configured, and on the streaming /// gate. 2026-05-31. private void RecomputeAudioCrypto() { if (currentProfilePassword != lastDerivedPassword) { if (string.IsNullOrEmpty(currentProfilePassword)) { currentAudioKey = null; currentAudioFingerprint = null; } else { currentAudioKey = RemSoundCrypto.DeriveKey(currentProfilePassword); currentAudioFingerprint = RemSoundCrypto.Fingerprint(currentProfilePassword); } lastDerivedPassword = currentProfilePassword; } sender.AudioKey = currentAudioKey; sender.AudioFingerprint = currentAudioFingerprint; receiver.AudioKey = currentAudioKey; receiver.AudioFingerprint = currentAudioFingerprint; } /// The "you need a password before any audio can flow" gate. Called when the user /// ticks Send my audio or Receive audio. If the active profile has no password, prompt for /// one; if they give one, set it (and offer to save it to the profile); if they cancel, /// un-tick the box. Returns true if streaming may proceed. private bool EnsureStreamingPassword(AccessibleCheckBox box) { if (!box.Checked) return true; // turning OFF never needs a password if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle; var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true); if (string.IsNullOrEmpty(entered)) { // No password → can't stream. Put the box back without re-firing this gate. suppressStreamingPasswordGate = true; box.Checked = false; suppressStreamingPasswordGate = false; return false; } currentProfilePassword = entered; RecomputeAudioCrypto(); // Offer to remember it on the profile (if we're on a saved one). if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath)) { var save = ForegroundDialog.Show(owner => MessageBox.Show(owner, $"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?", AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question)); if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword); } return true; } /// Send/receive checkbox handler with the password gate in front of it. Replaces the /// bare HandleCapabilityChange + MarkProfileDirty wiring. private void OnStreamingCheckboxChanged(AccessibleCheckBox box) { if (suppressStreamingPasswordGate) return; if (!EnsureStreamingPassword(box)) return; HandleCapabilityChange(); MarkProfileDirty(); // Audible feedback for the toggle, whether the user clicked the checkbox or pressed the // mute hotkey (the hotkey flips .Checked, which routes through here too). Skipped while a // profile is being applied programmatically — loading a profile that has send/receive on // shouldn't blast the cues; only a genuine user toggle should. if (!applyingProfile) PlayStreamToggleCue(box); } /// Play the send/receive turned-on / turned-off cue for a streaming checkbox toggle. /// Machine-wide cues (enable flags in AppConfig); silent when the cue is unticked or absent. private void PlayStreamToggleCue(AccessibleCheckBox box) { var on = box.Checked; var cfg = AppConfig.Load(); if (box == sendMyAudioCheckbox) { if (on) { if (cfg.EnableSendOnCue) sendOnSound?.Play(); } else { if (cfg.EnableSendOffCue) sendOffSound?.Play(); } } else if (box == receiveAudioCheckbox) { if (on) { if (cfg.EnableReceiveOnCue) receiveOnSound?.Play(); } else { if (cfg.EnableReceiveOffCue) receiveOffSound?.Play(); } } } /// Play the minimise(hide) or restore(show) cue. Called by the tray controller when the /// window actually transitions to/from hidden. Machine-wide enable flags; silent when unticked /// or the cue WAV is absent. public void PlayWindowVisibilityCue(bool show) { var cfg = AppConfig.Load(); if (show) { if (cfg.EnableShowCue) showSound?.Play(); } else { if (cfg.EnableHideCue) hideSound?.Play(); } } /// True while a peer-security warning dialog is on screen — set so the 1 Hz status /// tick that raises it can't re-enter and stack (or churn the UI under) a second one. private bool securityWarningShowing; /// Once a second, surface any password mismatch / out-of-date peer the receiver has /// detected from peers' advertised fingerprints — once per change, not every tick — so a /// silent encrypted stream is never an unexplained mystery. private void CheckPeerSecurity() { if (securityWarningShowing) return; // a warning is already up — don't re-enter or stack foreach (var kv in receiver.GetPeerSecurityStatuses()) { var addr = kv.Key; var status = kv.Value; if (!IsSelectedPeerAddress(addr)) continue; if (status is PeerSecurityStatus.Secure or PeerSecurityStatus.Unknown) { lastSecurityWarned.Remove(addr); continue; } if (lastSecurityWarned.TryGetValue(addr, out var warned) && warned == status) continue; lastSecurityWarned[addr] = status; AppendLogEntry($"security: {status} with {addr}"); var msg = status == PeerSecurityStatus.PasswordMismatch ? $"You and {addr} have different passwords, so no audio will pass between you.\n\nMake sure you've both set the same password (File → Change this profile's password)." : $"{addr} is running an older version of RemSound that can't connect securely. They need to update before audio can flow between you."; // Show it front-and-centre, and FREEZE the 1 Hz status tick while it's up. This dialog is // raised FROM that tick; left running, the tick keeps firing into the modal loop and // re-runs the peer-list rebuild (SyncAllPeerLists) UNDER the dialog, which knocks it out // of the foreground — it "flashed away before I could click OK" (Ed, 2026-06-11). The // timer-stop + re-entry guard make exactly one warning show and stay put until dismissed. securityWarningShowing = true; statusTimer.Stop(); try { ForegroundDialog.Show(owner => MessageBox.Show(owner, msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning)); } finally { statusTimer.Start(); securityWarningShowing = false; } return; // one warning per tick; a second affected peer surfaces on the next tick } } private bool IsSelectedPeerAddress(System.Net.IPAddress addr) => selectedPeerEndpoints.Values.Any(e => e.Address.Equals(addr)); /// Options → Profile passwords. Opens the password-manager list; if the user changed /// any password, re-sync the active profile's password from disk (the manager wrote it there) /// and re-derive the audio key so the live session uses the new password immediately. private void OpenProfilePasswordManager() { if (profileStore is null) { MessageBox.Show(this, "Profile system not active in this run.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); return; } var changed = ProfilePasswordManagerDialog.Show(this, profileStore); if (!changed) return; if (!string.IsNullOrEmpty(currentProfilePath) && File.Exists(currentProfilePath)) { try { var profile = JsonSerializer.Deserialize(File.ReadAllText(currentProfilePath)); currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password); RecomputeAudioCrypto(); AppendLogEntry("active profile password refreshed from the password manager"); } catch { /* benign — worst case the change applies on next load */ } } } private static List ExtractCheckedDeviceIds(CheckedListBox list) { var result = new List(); for (var i = 0; i < list.Items.Count; i++) { if (!list.GetItemChecked(i)) continue; if (list.Items[i] is AudioDeviceChoice choice && !string.IsNullOrEmpty(choice.DeviceId)) { result.Add(choice.DeviceId); } } return result; } /// Collect the currently-connected peers as their original entry text (the /// user's typed string, e.g. "remote.ednun.com:47830" or "192.168.1.2"). Stored in the /// profile so a profile reload re-resolves the hostname (in case the IP has changed) /// and reconnects via the same code path the user uses for manual peer adds. Falls back /// to "address:port" when we don't have the original text — happens for peers that /// arrived via discovery rather than a manual add. private List GatherSelectedPeerEntries() { var result = new List(); foreach (var (instanceId, endpoint) in selectedPeerEndpoints) { // Preferred: original text the user typed (preserves hostnames vs IPs). string? entry = null; foreach (var (text, id) in rememberedPeerInstanceIds) { if (id == instanceId) { entry = text; break; } } if (string.IsNullOrEmpty(entry)) { // Fall back to the discovery label, then to address:port literal. if (selectedPeerLabels.TryGetValue(instanceId, out var label) && !string.IsNullOrWhiteSpace(label)) { entry = label; } else { entry = $"{endpoint.Address}:{endpoint.Port}"; } } if (!result.Contains(entry, StringComparer.OrdinalIgnoreCase)) result.Add(entry); } return result; } /// Re-establish the connections that were active when the profile was saved. /// Mirrors but quieter — failures (DNS, empty entry) /// log to the diagnostic file instead of popping a MessageBox, because we don't want /// a startup-time profile load to fire several modal dialogs at the user. Selected /// peers that resolve become connected exactly as if the user had typed them. private void ReconnectSavedPeers(IReadOnlyList entries) { foreach (var entry in entries) { if (string.IsNullOrWhiteSpace(entry)) continue; _ = ReconnectOneSavedPeerAsync(entry); } } private async Task ReconnectOneSavedPeerAsync(string entry) { try { var address = await ResolvePeerAddressAsync(entry); if (address is null) { AppendLogEntry($"profile reconnect: could not resolve \"{entry}\"; skipping"); return; } var rememberedEntries = settings.LoadRememberedPeers() .Select(static value => value.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); rememberedEntries.Add(entry.Trim()); settings.SaveRememberedPeers(rememberedEntries); var peer = CreateManualPeer(entry, address); manualPeers[peer.InstanceId] = peer; rememberedPeerInstanceIds[entry.Trim()] = peer.InstanceId; SelectPeer(peer, fromProfileRestore: true); PushDiscoveryUnicastHints(); logFile.Event($"profile reconnect: \"{entry}\" → {address}:{peer.AudioPort}"); RefreshKnownPeers(); // CRITICAL: SelectPeer alone only updates the receiver's allow-list and the // selectedPeerEndpoints dictionary; it does NOT engage the audio sender's outbound // peer list. Without this ApplyAudioRuntime call, profile-restored peers showed // up "ticked" in the UI but the sender never actually transmitted to them, leaving // the user staring at "pending" heartbeat for ~40-60 s until the relay's stale-slot // timeout expired (or until the user manually unticked + re-ticked, which DOES // route through the runtime apply). Observed in logs from 2026-05-05. ApplyAudioRuntime(); } catch (Exception ex) { AppendLogEntry($"profile reconnect: \"{entry}\" failed: {ex.GetType().Name}: {ex.Message}"); } } // OpenManageProfilesDialog and ProfileManagementDialog removed in Phase 4 of the // 2026-05-06 UI refactor. Profile management lives inline on the Profiles & preferences // tab — see BuildProfilesPrefsTab + SwitchSelectedProfile / RenameSelectedProfile / // DeleteSelectedProfile. /// Builds the live tooltip shown over the system-tray icon — sums up peer count /// and send / receive routing into a single readable line. Kept under the 127-character /// NotifyIcon limit by construction; the tray controller truncates with an ellipsis as a /// belt-and-braces if a future addition ever pushes it over. /// /// Examples: /// * RemSound — not connected /// * RemSound — recording for 2:34 — not connected /// * RemSound — 2 peers, sending (WASAPI), receiving (WASAPI) /// * RemSound — recording for 1:23:45, 2 peers, sending (WASAPI), receiving (WASAPI) /// private string BuildTrayTooltip() { // Healthy-peer count. Heartbeats define "connected" — a peer ticked in the list but // never reachable doesn't count, because the user cares about who they can actually // talk to right now, not who they intend to. var healthyPeers = 0; if (heartbeatService is not null) { foreach (var ph in heartbeatService.GetAllPeerHealth()) { if (ph.State == PeerHealthState.Healthy) healthyPeers++; } } // Recording status — a plain "recording" flag while a capture is running, with no // elapsed timer. A live timer would have to rewrite the tooltip every second, which // fights the tray icon's re-stamp-on-change behaviour: it would either flicker the // icon once a second or leave a screen reader announcing a stale time next to the live // one. Recording starting and stopping are real state changes that re-stamp cleanly; // the second-by-second count is intentionally left to the main window. Slots in right // after the "RemSound" leader so it reads as a status on the app itself rather than a // property of the peer list. string? recordingPart = recordingController.IsRecording ? "recording" : null; if (healthyPeers == 0 && !sendMyAudioCheckbox.Checked && !receiveAudioCheckbox.Checked) { return recordingPart is null ? "RemSound — not connected" : $"RemSound — {recordingPart} — not connected"; } var parts = new List { "RemSound" }; if (recordingPart is not null) parts.Add(recordingPart); var peerText = healthyPeers switch { 0 => "no peers", 1 => "1 peer", _ => $"{healthyPeers} peers", }; parts.Add(peerText); // Direction lines — only added when the corresponding direction is actually on. The // lane label (WASAPI / ASIO / WASAPI + ASIO) is derived from which device-list ticks // are active, NOT from the audio-mode setting alone: in BothIndependent a user can // still have ticked WASAPI inputs only, in which case the tray should honestly say // "WASAPI" rather than "WASAPI + ASIO". if (sendMyAudioCheckbox.Checked) { var hasWasapiSend = AnyChecked(sendInputDevicesList) || AnyChecked(sendOutputDevicesList); var hasAsioSend = AnyChecked(asioSendDevicesList); parts.Add($"sending ({DescribeLanes(hasWasapiSend, hasAsioSend)})"); } if (receiveAudioCheckbox.Checked) { var hasWasapiReceive = AnyChecked(receiveOutputDevicesList); var hasAsioReceive = AnyChecked(asioReceiveOutputDevicesList); parts.Add($"receiving ({DescribeLanes(hasWasapiReceive, hasAsioReceive)})"); } return string.Join(", ", parts); } /// Convenience for BuildTrayTooltip — true if the given CheckedListBox has at /// least one ticked item. Defensive against null lists (the layout builders run async on /// startup, so the tooltip refresh CAN fire one tick before they exist). private static bool AnyChecked(CheckedListBox? list) { if (list is null) return false; return list.CheckedItems.Count > 0; } /// Pretty-print "WASAPI", "ASIO", "WASAPI + ASIO", or "no devices" depending /// on which of the two flags are set. "no devices" covers the awkward case where the /// user has the send/receive checkbox on but hasn't ticked anything for the engine to /// chew on — better to say so in the tooltip than imply silent activity. private static string DescribeLanes(bool wasapi, bool asio) => (wasapi, asio) switch { (true, true) => "WASAPI + ASIO", (true, false) => "WASAPI", (false, true) => "ASIO", _ => "no devices", }; /// Well-known cue identifiers used as keys into /// . Stable strings — don't rename without writing /// a migration, because users' Preferences-set custom paths are stored under these keys /// in remsound.config.json. Centralised here so the Preferences dialog and the /// MainForm load path agree on the spellings. internal static class CueId { public const string Connect = "connect"; public const string Disconnect = "disconnect"; public const string RecordStart = "record-start"; public const string RecordStop = "record-stop"; public const string Save = "save"; public const string ProfileSwitch = "profile-switch"; public const string ProfileMenuOpen = "profile-menu-open"; public const string Update = "update"; // Startup cue is special: it plays from Program.cs before any profile loads, so its // enable flag and custom-path live machine-wide in AppConfig, not the per-profile // settings store. This id is still used by the Preferences cue list for display/keying. public const string Startup = "startup"; // Send/receive toggle + minimise(hide)/restore(show) cues (2026-06-13). Also machine-wide // (enable flags + custom paths in AppConfig) - app-level feedback, not per-profile audio. public const string SendOn = "send-on"; public const string SendOff = "send-off"; public const string ReceiveOn = "receive-on"; public const string ReceiveOff = "receive-off"; public const string Hide = "hide"; public const string Show = "show"; // Played on every checkbox tick/untick across the whole app (CheckSoundService). public const string CheckboxOn = "checkbox-on"; public const string CheckboxOff = "checkbox-off"; // Played whenever the user switches tabs anywhere in the app (TabSwitchSoundService, // fired from QuietTabControl). The shipped WAVs are "tab switch 1.wav" etc. public const string TabSwitch = "tab-switch"; } /// Load one cue sound. Resolution order: /// (1) if the active profile has a custom path for AND the /// referenced file exists, use that — the user-supplied override. /// (2) otherwise the default WAV in sounds\ next to RemSound.exe, named /// . /// (3) otherwise null — the cue silently doesn't play. New cues without a shipped /// default WAV (e.g. save.wav and profile.wav before the project owner supplies /// them) land here and the rest of the app keeps working. /// /// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so /// each profile can carry its own cue palette. The settings cache mirrors the active /// profile's CustomCuePaths dictionary and is the runtime source of truth. private void TryLoadCueSound(string cueId, string defaultFileName, out CuePlayer? player) { player = null; try { string? path = null; var customPath = settings.LoadCustomCuePath(cueId); if (string.IsNullOrWhiteSpace(customPath) && AppConfig.Load().MachineCueCustomPaths.TryGetValue(cueId, out var machinePath)) { // Machine-wide cues (send/receive/hide/show) keep their custom override in AppConfig. customPath = machinePath; } if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath)) { path = customPath; logFile.Event($"cue sound '{cueId}': using custom path {customPath}"); } else { // The cue ships as numbered variants ("connect 1.wav", "connect 2.wav", ...); // resolve the machine-wide chosen default (or the first variant) for this cue. var defaultPath = CueSounds.ResolveDefaultPath(cueId, defaultFileName, AppConfig.Load()); if (defaultPath is not null && File.Exists(defaultPath)) { path = defaultPath; } else { logFile.Event($"cue sound '{cueId}': no default variant found in sounds\\ and no custom override set — cue will be silent"); return; } } // CuePlayer reads + plays the file on demand (NAudio), so no pre-load step — and it // copes with any format, including the 96 kHz / 24-bit cue WAVs and custom user files. player = new CuePlayer(path); } catch (Exception ex) { logFile.Event($"cue sound load failed for '{cueId}': {ex.GetType().Name}: {ex.Message}"); } } /// Re-load all cue sounds. Called by PreferencesDialog after the user picks a /// new custom WAV for any cue — re-runs for the lot so /// the cached SoundPlayer instances point at the right file from the next play onward. /// public void ReloadAllCueSounds() { TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound); TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound); TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound); TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound); TryLoadCueSound(CueId.Save, "save.wav", out saveSound); TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound); TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound); TryLoadCueSound(CueId.Update, "update.wav", out updateSound); TryLoadCueSound(CueId.SendOn, "send on.wav", out sendOnSound); TryLoadCueSound(CueId.SendOff, "send off.wav", out sendOffSound); TryLoadCueSound(CueId.ReceiveOn, "recieve on.wav", out receiveOnSound); TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound); TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound); TryLoadCueSound(CueId.Show, "maximise.wav", out showSound); // The app-wide checkbox tick/untick and tab-switch sounds live in their own services; keep // them in step. CheckSoundService.Reload(); TabSwitchSoundService.Reload(); // After a reload (e.g. the user changed a cue in Preferences), warn about any cue that's // switched on but whose sound file is missing. Skipped during construction (no window yet); // OnShown does the first-launch pass. CheckForMissingEnabledCues(); } private readonly HashSet reportedMissingCues = new(StringComparer.OrdinalIgnoreCase); /// For every cue that's switched ON but whose sound file couldn't be found (player is /// null - e.g. a custom WAV was deleted, or a chosen sound is gone), turn the cue off and tell /// the user, front-most, once per cue. They re-enable it by choosing a sound in Preferences. private void CheckForMissingEnabledCues() { if (!IsHandleCreated) return; // need a window so the warning can surface var cfg = AppConfig.Load(); (CuePlayer? Player, bool Enabled, Action Disable, string Name, string When)[] cues = { (connectSound, settings.LoadEnableConnectCue(), () => settings.SaveEnableConnectCue(false), "connect", "a peer connects"), (disconnectSound, settings.LoadEnableDisconnectCue(), () => settings.SaveEnableDisconnectCue(false), "disconnect", "a peer disconnects"), (recordStartSound, settings.LoadEnableRecordStartCue(), () => settings.SaveEnableRecordStartCue(false), "recording start", "you start recording"), (recordStopSound, settings.LoadEnableRecordStopCue(), () => settings.SaveEnableRecordStopCue(false), "recording stop", "you stop recording"), (saveSound, settings.LoadEnableSaveCue(), () => settings.SaveEnableSaveCue(false), "profile saved", "you save a profile"), (profileSwitchSound, settings.LoadEnableProfileSwitchCue(), () => settings.SaveEnableProfileSwitchCue(false), "profile switched", "you switch profile"), (profileMenuOpenSound, settings.LoadEnableProfileMenuOpenCue(), () => settings.SaveEnableProfileMenuOpenCue(false), "profile menu open", "the quick profile menu opens"), (updateSound, settings.LoadEnableUpdateCue(), () => settings.SaveEnableUpdateCue(false), "update", "an update is about to install"), (sendOnSound, cfg.EnableSendOnCue, () => SetMachineCueEnabled(c => c.EnableSendOnCue = false), "send turned on", "you turn sending on"), (sendOffSound, cfg.EnableSendOffCue, () => SetMachineCueEnabled(c => c.EnableSendOffCue = false), "send turned off", "you turn sending off"), (receiveOnSound, cfg.EnableReceiveOnCue, () => SetMachineCueEnabled(c => c.EnableReceiveOnCue = false), "receive turned on", "you turn receiving on"), (receiveOffSound, cfg.EnableReceiveOffCue, () => SetMachineCueEnabled(c => c.EnableReceiveOffCue = false), "receive turned off", "you turn receiving off"), (hideSound, cfg.EnableHideCue, () => SetMachineCueEnabled(c => c.EnableHideCue = false), "minimise", "RemSound minimises to the tray"), (showSound, cfg.EnableShowCue, () => SetMachineCueEnabled(c => c.EnableShowCue = false), "restore", "RemSound is restored from the tray"), }; foreach (var c in cues) { if (c.Enabled && c.Player is null) { c.Disable(); ReportMissingCueSound(c.Name, c.When); } } } private static void SetMachineCueEnabled(Action set) { var c = AppConfig.Load(); set(c); try { c.Save(); } catch { /* harmless — the cue is silent regardless this session */ } } /// Surface the "couldn't find a cue's sound file" message, front-most even when RemSound /// is minimised, once per cue per session. The cue has already been turned off by the caller. private void ReportMissingCueSound(string name, string when) { if (!reportedMissingCues.Add(name)) return; logFile.Event($"cue sound '{name}': enabled but file missing — cue turned off, informing the user"); // A --silent (automated) launch turns the cue off quietly and never pops a dialog at the user. if (CuePlayer.GloballyMuted) return; BeginInvoke(() => { try { RestoreFromTray(); } catch { /* surfacing is best-effort */ } MessageBox.Show(this, $"RemSound was unable to find the {name} sound file used when {when}. " + "RemSound has set this particular audio cue to not play for now, until a new sound file is specified.", "RemSound — missing sound file", MessageBoxButtons.OK, MessageBoxIcon.Warning); }); } /// /// Compares current peer-health states to the last-seen states and plays a connect / /// disconnect cue on the relevant transitions. Driven from the 1 Hz snapshot tick. Rules: /// • Any state → Healthy: play connect cue (first connection, or a stale/unreachable peer /// came back). /// • Healthy or Stale → Unreachable: play disconnect cue. We deliberately do NOT fire a /// disconnect cue for Unknown → Unreachable — that's "we typed an address but never got /// a single heartbeat reply", which is a connect-failed event, not a connect-then-lost /// event. Playing a disconnect ding for a peer that never connected is jarring and was /// observed at jam-session start when the relay/peer hadn't paired yet. /// • Tracked peer disappeared from the list (deselected): play disconnect if the peer was /// Healthy at the last observation — quiet otherwise. /// Stale is ignored (it's a transient between Healthy and Unreachable). /// private void DetectAndAnnouncePeerHealthTransitions() { if (heartbeatService is null) return; var current = heartbeatService.GetAllPeerHealth(); var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); // How recently audio must have arrived to count the peer as "audibly connected". Audio // arrives hundreds of times a second, so a 3-second gap is a genuine interruption, not // jitter. Disconnect also requires the heartbeat to be Unreachable (5 s of no reply), so // the heartbeat is the slower gate for a real, total loss. var audioWindow = TimeSpan.FromSeconds(3); foreach (var ph in current) { var key = $"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"; seenKeys.Add(key); var audioFlowing = receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, audioWindow); // Connected the moment audio arrives OR the heartbeat is solidly healthy; lost only // when audio has stopped AND the heartbeat has gone unreachable. The middle ground // (heartbeat Stale, or audio briefly paused) holds the previous state — hysteresis, // so a heartbeat blip while audio keeps playing never fires a false disconnect. var isConnected = audioFlowing || ph.State == PeerHealthState.Healthy; var isLost = !audioFlowing && ph.State == PeerHealthState.Unreachable; var wasConnected = peerConnectedState.TryGetValue(key, out var w) && w; if (isConnected && !wasConnected) { var enabled = settings.LoadEnableConnectCue(); if (enabled) connectSound?.Play(); logFile.Event($"peer connected: {ph.AudioEndpoint} (audio={audioFlowing}, heartbeat={ph.State}) — connect cue {CueOutcome(enabled, connectSound)}"); peerConnectedState[key] = true; } else if (isLost && wasConnected) { var enabled = settings.LoadEnableDisconnectCue(); if (enabled) disconnectSound?.Play(); logFile.Event($"peer disconnected: {ph.AudioEndpoint} (audio stopped, heartbeat={ph.State}) — disconnect cue {CueOutcome(enabled, disconnectSound)}"); peerConnectedState[key] = false; } else if (!peerConnectedState.ContainsKey(key)) { // First sighting and neither clearly connected nor lost (e.g. address typed but // no audio/pong yet) — seed the state without playing a cue. peerConnectedState[key] = isConnected; } } // Peers that vanished from tracking entirely (user deselected). Play disconnect only if // they were connected when last seen — a peer that never connected stays quiet. foreach (var key in peerConnectedState.Keys.Where(k => !seenKeys.Contains(k)).ToList()) { if (peerConnectedState[key]) { var enabled = settings.LoadEnableDisconnectCue(); if (enabled) disconnectSound?.Play(); logFile.Event($"peer disconnected: {key} (deselected while connected) — disconnect cue {CueOutcome(enabled, disconnectSound)}"); } peerConnectedState.Remove(key); } } /// Describes what actually happened to a cue, for honest logging: "played", "muted /// in settings", or "enabled but sound not loaded" — so the log never claims a cue rang when /// no sound came out. 2026-06-02. private static string CueOutcome(bool enabled, CuePlayer? sound) => !enabled ? "muted in settings" : sound is null ? "enabled but sound not loaded" : "played"; /// /// Append each configurable global hotkey to the accessible description of the control or menu /// item it drives, so NVDA reads e.g. "Start recording … press Control+Shift+Alt+R anywhere" /// when you land on it. Unset hotkeys clear the hint. Re-run whenever a hotkey is rebound (via /// hotkeyController.OnHotkeyChanged) so the announcement always matches the current binding. /// NVDA reads AccessibleDescription after the name/role/state by default. Hotkeys with no /// dedicated on-screen control (tray show/hide, the remote-control and system-volume keys) have /// no place to announce and are simply listed in the Keyboard shortcuts dialog. /// private void UpdateHotkeyAnnouncements() { sendMyAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.SendMuteHotkey); receiveAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.ReceiveMuteHotkey); if (startStopRecordingMenuItem is not null) { startStopRecordingMenuItem.AccessibleDescription = DescribeHotkey(hotkeyController.ToggleRecordingHotkey); } // The received-sound volume slider is driven by two hotkeys (up and down). var up = hotkeyController.VolumeUpHotkey; var down = hotkeyController.VolumeDownHotkey; var parts = new List(2); if (!up.IsUnset) parts.Add($"press {up} anywhere for volume up"); if (!down.IsUnset) parts.Add($"press {down} anywhere for volume down"); volumeBar.AccessibleDescription = parts.Count == 0 ? "" : string.Join("; ", parts); } private static string DescribeHotkey(HotkeyInfo hotkey) => hotkey.IsUnset ? "" : $"press {hotkey} anywhere"; private void NudgeVolume(int deltaPercent) { BeginInvoke(() => { var newValue = Math.Clamp(volumeBar.Value + deltaPercent, volumeBar.Minimum, volumeBar.Maximum); if (newValue == volumeBar.Value) return; volumeBar.Value = newValue; receiver.Volume = volumeBar.Value / 100f; }); } /// /// Send a remote-control Control packet to every currently-tracked peer. Triggered by the /// global hotkeys configured in the Keyboard shortcuts dialog. The local volume / mute /// state on THIS machine is deliberately not touched — only peers that have ticked their /// "Accept remote volume commands from peers" box honour the request. Use case: I'm /// NVDA-Remote'd into another PC and want to nudge listening volume on the laptop I'm /// physically at without breaking out of the session. /// /// VolumeUp / VolumeDown / MuteToggle. /// Percent-point delta (signed). Ignored for MuteToggle. private void SendRemoteControl(RemoteControlKind kind, sbyte delta) { if (!connected) return; var endpoints = SelectedSendEndpoints(); if (endpoints.Length == 0) return; Span packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.ControlPayloadSize]; // streamId 0xFFFE for control packets (heartbeat already uses 0xFFFF). Distinct value // makes diag logs easier to read; the receiver doesn't actually filter on it. var seq = unchecked((uint)Interlocked.Increment(ref remoteControlSequence)); RemPacket.WriteHeader(packet, RemPacketType.Control, 0xFFFE, seq); RemPacket.WriteControlPayload(packet[RemPacket.HeaderSize..], kind, delta); var bytes = packet.ToArray(); var sentTo = 0; foreach (var ep in endpoints) { try { if (sender.SendVia(bytes, bytes.Length, ep)) sentTo++; } catch (Exception ex) { logFile.Event($"remote-control send to {ep} failed: {ex.GetType().Name}: {ex.Message}"); } } logFile.Event($"remote-control sent kind={kind} delta={delta} seq={seq} peers={sentTo}/{endpoints.Length}"); } private int remoteControlSequence; /// /// Handler for incoming Control packets. Runs on the network thread — marshal to UI before /// touching controls. Gates on (a) the user's /// preference and (b) the audio allow-list (the sender must already be a ticked peer). /// We deliberately don't gate on receive-audio-enabled: the volume / mute state is meaningful /// even when playback is currently off, because the next time the user enables receive /// they'll hear it at the right level. /// private void HandleRemoteControlPacket(RemoteControlKind kind, sbyte delta, IPEndPoint remote) { // Allow-list match by IP only — the sender's source port is their ephemeral outbound, // not their announced audio port. var allowed = false; foreach (var ep in selectedPeerEndpoints.Values) { if (ep.Address.Equals(remote.Address)) { allowed = true; break; } } if (!allowed) { logFile.Event($"remote-control IGNORED (not in allow-list) kind={kind} delta={delta} from={remote}"); return; } if (!settings.LoadAcceptRemoteVolumeCommands()) { logFile.Event($"remote-control IGNORED (Accept remote volume commands is off) kind={kind} delta={delta} from={remote}"); return; } BeginInvoke(() => { switch (kind) { case RemoteControlKind.VolumeUp: case RemoteControlKind.VolumeDown: var nudge = kind == RemoteControlKind.VolumeUp ? Math.Abs((int)delta) // positive : -Math.Abs((int)delta); // negative var newValue = Math.Clamp(volumeBar.Value + nudge, volumeBar.Minimum, volumeBar.Maximum); if (newValue != volumeBar.Value) { volumeBar.Value = newValue; receiver.Volume = volumeBar.Value / 100f; } logFile.Event($"remote-control APPLIED kind={kind} delta={delta} new-volume={volumeBar.Value} from={remote}"); break; case RemoteControlKind.MuteToggle: receiver.IsMuted = !receiver.IsMuted; logFile.Event($"remote-control APPLIED kind=MuteToggle muted={receiver.IsMuted} from={remote}"); break; case RemoteControlKind.SystemVolumeUp: { var ok = SystemVolumeHelper.TryStepUp(); var st = SystemVolumeHelper.TryReadState(); logFile.Event($"remote-control APPLIED kind=SystemVolumeUp ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); break; } case RemoteControlKind.SystemVolumeDown: { var ok = SystemVolumeHelper.TryStepDown(); var st = SystemVolumeHelper.TryReadState(); logFile.Event($"remote-control APPLIED kind=SystemVolumeDown ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); break; } case RemoteControlKind.SystemMuteToggle: { var ok = SystemVolumeHelper.TryToggleMute(); var st = SystemVolumeHelper.TryReadState(); logFile.Event($"remote-control APPLIED kind=SystemMuteToggle ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); break; } } }); } // === Latency probe helpers === /// Average send-side accumulator wait — half the active codec frame size. PCM /// 5 ms → 2.5 ms typical; PCM 2.5 ms → 1.25 ms; Opus 20 ms → 10 ms; tight-latency PCM in /// AsioOnly bypasses the accumulator entirely so this estimate is an upper bound there. private double SenderAccumulatorEstimateMs() { if (codecBox.SelectedItem is not CodecChoice item) return 2.5; var rate = settings.LoadSendRate(); if (item.Codec == AudioTransportCodec.Opus) { // EffectiveOpusFrameSamples is samples-per-channel at 48 kHz; ÷ 48 → ms, ÷ 2 → half-frame. return EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate) / 96.0; } // PCM if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly) { return 0.5; // per-callback ASIO send → ~one ASIO buffer, hard to know without driver introspection } return rate == SendRate.Tight ? 1.25 : 2.5; } /// Lowest healthy peer's heartbeat RTT. Used as the wire-time estimate. Returns 0 /// if no peers are healthy. private double LowestPeerRttMs() { if (heartbeatService is null) return 0; var min = double.MaxValue; foreach (var ph in heartbeatService.GetAllPeerHealth()) { if (ph.State != PeerHealthState.Healthy || ph.RttMs is not { } rtt) continue; if (rtt < min) min = rtt; } return min == double.MaxValue ? 0 : min; } /// Rough render-side buffer estimate. WASAPI shared-mode is ~10 ms typical. /// BothIndependent has no tee — both lanes run at their native callback rate — so the /// worse of the two governs perceived delay. ASIO depends on driver buffer settings /// we don't query, but is always lower than WASAPI in practice, so the WASAPI estimate /// is what governs in both modes. private double RenderBufferEstimateMs() => 10; /// /// Translates a codec choice + the user's Send Rate into the effective Opus frame size in /// samples-per-channel at 48 kHz. PCM frame size is set separately in AudioSender.SetSendRate. /// Standard returns the codec's natural frame; Tight halves it (Opus 960 → 480 → 240 → 120 /// floored). Floor is 120 samples = 2.5 ms = standard libopus's RESTRICTED_LOWDELAY minimum. /// private static int EffectiveOpusFrameSamples(AudioTransportCodec codec, int opusFrameSamples, SendRate rate) { if (codec != AudioTransportCodec.Opus) return opusFrameSamples; return rate == SendRate.Tight ? Math.Max(120, opusFrameSamples / 2) : opusFrameSamples; } /// /// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM", /// "Opus 10ms", "Opus 20ms", "Opus 2.5ms". Input is samples-per-channel at 48 kHz; the /// label derives ms from samples / 48 with up to one decimal place. Uses the same /// EffectiveOpusFrameSamples the encoder uses so the label reflects the actually-encoded /// frame size, not the codec menu choice. /// private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameSamples) { return codec switch { AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameSamples) / 48.0:0.##}ms", AudioTransportCodec.Pcm => "PCM", _ => codec.ToString(), }; } /// /// Codec value for the SNAP log's Codec column. Reports the codec actually in use rather /// than the dormant send-codec setting: when receiving, the incoming stream's wire codec /// (what we're decoding); when sending only, the send codec; and "tx=…/rx=…" when a /// full-duplex node is sending and receiving with different codecs. This fixes the old /// behaviour where a receive-only node logged its idle send setting (e.g. "Pcm") while /// actually decoding "PCM over Opus" — which is exactly what masked a receive session as /// PCM during the 2026-06 memory-leak investigation. /// private string SnapshotCodecLabel() { var rx = receiver.IsRunning ? receiver.ActiveReceiveCodec : null; if (rx is AudioTransportCodec rxCodec) { return sender.IsRunning && sender.Codec != rxCodec ? $"tx={sender.Codec} rx={rxCodec}" : rxCodec.ToString(); } return sender.Codec.ToString(); } /// Snap an integer to the nearest 5. Used to keep RTT chatter in the per-peer /// listbox line low — single-millisecond drift no longer re-announces under NVDA. private static int RoundToFive(int value) => ((value + 2) / 5) * 5; /// Re-applies the codec/Opus-frame setting after the user changes Send Rate. The /// PCM frame size is updated by AudioSender.SetSendRate directly; for Opus we have to /// re-init the encoder via ConfigureCodec. private void ApplySendRateToOpus(SendRate rate) { if (codecBox.SelectedItem is CodecChoice item && item.Codec == AudioTransportCodec.Opus) { var effectiveSamples = EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate); sender.ConfigureCodec(item.Codec, effectiveSamples); logFile.Event($"send rate changed to {rate} → Opus frame {effectiveSamples / 48.0:0.##}ms"); } else { logFile.Event($"send rate changed to {rate} (PCM)"); } } private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameSamples) { if (codec == AudioTransportCodec.Pcm) return 0; // Opus 120 (2.5 ms — live latency) = index 2. Anything else (including the retired // 10 ms middle (480) and the never-exposed 5 ms (240)) collapses to index 1 // (broadcast quality / 20 ms), the safer default — losing a little latency is the // less surprising outcome on upgrade than losing loss tolerance. v2.x profiles that // saved OpusFrameMilliseconds=10 (which the settings store migrates to 480 samples // via the <120 sentinel) land here on the broadcast side; users who specifically // want low latency re-pick "live latency" from the dropdown. return opusFrameSamples switch { 120 => 2, _ => 1, }; } // ===================== Auto-tune ===================== /// (Re)configures the continuous-tune timer based on the current checkbox / combo /// state held in / . /// Called whenever either changes (in the dialog) or at startup. The timer fires when /// either lane has auto-tune enabled — in classic modes that's just the single WASAPI/ /// 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. /// 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. private bool AnyAutoTuneEnabled() { var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; var asioOn = inBothIndependent && continuousTuneAsioBox.Checked; return continuousTuneEnabled || asioOn; } private void ApplyContinuousTuneTimer() { continuousTuneTimer.Stop(); var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; var asioEnabled = inBothIndependent && settings.LoadContinuousAutoTuneAsioEnabled(); // Auto-tune needs the per-second diag snapshot (arrival-gap and render-callback-gap // history) to make its recommendation. Make sure the engine's instrumentation is on // whenever either lane's continuous tune is active, even if the Enable-logs checkbox // is off. UpdateDiagnosticsGate(); if (!continuousTuneEnabled && !asioEnabled) return; continuousTuneTimer.Interval = Math.Max(1000, continuousTuneIntervalSec * 1000); continuousTuneTimer.Start(); } /// Recompute from every reason the engine /// might need its instrumentation on: the user-facing Enable-logs checkbox, plus either /// continuous-auto-tune toggle. Auto-tune reads diag.MaxArrivalGapMs / /// diag.MaxRenderCallbackGapMs from the per-second snapshot to size the latency /// target, so its data has to keep flowing even when logs are off; the user shouldn't /// have to enable logging just to make auto-tune work. private void UpdateDiagnosticsGate() { var asioContinuous = settings.LoadAudioMode() == AudioMode.BothIndependent && settings.LoadContinuousAutoTuneAsioEnabled(); DiagnosticsGate.Enabled = logFile.Enabled || continuousTuneEnabled || asioContinuous; } /// Which route the legacy "Audio latency / WASAPI latency" slider operates on. /// In classic modes that's the Mixed route (only sessions in play). In BothIndependent /// the slider has been relabeled to "WASAPI latency" and drives the WasapiLane route. private RenderRoute MaxLatencyBoxRoute => settings.LoadAudioMode() == AudioMode.BothIndependent ? RenderRoute.WasapiLane : RenderRoute.Mixed; /// /// Continuous-tune tick. Computes a recommended target from the rolling max-gap window and /// adjusts the slider, with several robustness rules learned from real-world testing: /// /// 1. **Max over a long lookback window.** Earlier we used p95 of the recent few seconds, /// but with very few samples that's mathematically the same as the max anyway, and /// bad events aged out of the window in seconds — so auto-tune could drop the target /// below the level that had just earned the user a pop. Now we take the worst gap /// across the last seconds, so a bad event keeps target /// elevated long enough to cover the long-tail of the same disturbance. /// 2. **Cap auto-tune recommendations at .** Beyond /// that the user is in "I want a huge buffer for terrible network" territory — they can /// drag the slider there manually; the auto-tuner shouldn't go there on its own. /// 3. **Asymmetric step.** Raising the target on observed jitter happens immediately. Lowering /// is rate-limited to per tick so a brief good window /// doesn't undo the protection a bad event just earned us. /// 4. **Skip tuning while underruns are growing.** If the buffer is currently underrunning, /// the system isn't in steady state. Tuning now would react to broken stats. /// 5. **Skip if the user just touched the slider** — see . /// private void ContinuousTuneTick() { if (!receiver.IsRunning) return; var frameMs = receiver.ActiveStreamFrameMs; if (frameMs is null) return; if (recentMaxGaps.Count < 2) return; // Same deferral when the source list changed: the freshly-added capture's first packets // can land slightly off-cadence as its ring buffer fills, and we don't want that // transient to influence the recommendation. Applied to every per-route tick. var intervalSec = continuousTuneIntervalSec; if (DateTime.UtcNow - lastSourceChangeUtc < TimeSpan.FromSeconds(intervalSec)) return; // Dispatch per route. Classic modes drive only the Mixed route (the legacy single-knob // world). BothIndependent ticks both routes — each respecting its own enable flag, // slider, last-user-move timestamp and underrun delta — so the WASAPI lane's distress // can't make the ASIO lane's auto-tune defer (and vice versa). if (settings.LoadAudioMode() == AudioMode.BothIndependent) { // Skip ticking a lane that has no active sessions. The shared recentMaxGaps // window is populated by every incoming packet regardless of lane, so without // this gate a route with no audio would still react to the OTHER route's // gap signal and silently inflate its target before any of its own audio has // arrived. if (continuousTuneEnabled && receiver.HasSessionsForRoute(RenderRoute.WasapiLane)) { TickRoute(RenderRoute.WasapiLane, maxLatencyBox, "WASAPI", ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking, lastUserSliderMoveUtc, intervalSec, frameMs.Value); } if (settings.LoadContinuousAutoTuneAsioEnabled() && receiver.HasSessionsForRoute(RenderRoute.AsioLane)) { TickRoute(RenderRoute.AsioLane, maxLatencyAsioBox, "ASIO", ref lastObservedUnderrunCountAsio, ref lastObservedDeviceGulpCountAsio, ref suppressUserAsioSliderMoveTracking, lastUserAsioSliderMoveUtc, intervalSec, frameMs.Value); } } else { if (continuousTuneEnabled) { TickRoute(RenderRoute.Mixed, maxLatencyBox, "", ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking, lastUserSliderMoveUtc, intervalSec, frameMs.Value); } } } // Per-route auto-tune-tick state. lastObservedUnderrunCount + suppress flag are the // existing single-route fields; the *Asio variants below are their BothIndependent // counterparts. The ref-pass into TickRoute keeps the existing field-update semantics // (atomic delta computation, suppress-flag lifecycle) for both routes without needing // a heap-allocated state object on the hot path. private long lastObservedUnderrunCountAsio; private bool suppressUserAsioSliderMoveTracking; // Per-route "device-gulp underruns at last tick" — the inaudible, more-buffer-won't-fix // partial short-reads the cause-aware skip gate deliberately ignores. Tracked only so the // auto-tune log can show how many were ignored; shared between Mixed and the WASAPI lane the // same way lastObservedUnderrunCount is. private long lastObservedDeviceGulpCount; private long lastObservedDeviceGulpCountAsio; /// /// Per-route auto-tune tick body. Same algorithm as the pre-2026-05-11 single-route /// version, generalised to operate on a route + slider pair passed by the caller. The /// gap and render-callback histories ( / /// ) are still shared across routes — the network signal /// is one signal, both lanes ride the same UDP socket — but the underrun delta, the /// last-user-slider-move timestamp, and the slider itself are per-route so each lane /// settles at its own native latency. Logs include the route name so the diagnostic /// trail makes which lane was tuned obvious. /// private void TickRoute( RenderRoute route, NumericUpDown slider, string routeLabel, ref long lastObservedUnderruns, ref long lastObservedDeviceGulps, ref bool suppressFlag, DateTime lastUserMoveUtc, int intervalSec, int frameMs) { // Render period was a hardcoded 10ms here (sized for shared-mode WASAPI). On ASIO // with a small buffer (32 samples = 0.67ms callback) the real value is 1-2ms, and // the constant inflated every recommendation by 8ms+ for ASIO users. Now derived // from the actual render-callback measurements over the same lookback as the gap // measurement. const int RenderPeriodFloorMs = 2; const int SafetyMarginMs = 5; const int HysteresisMs = 5; const int AutoTuneRecommendationCapMs = 200; const int MaxDecreasePerTickMs = 5; const int LookbackSeconds = 15; // Defer to user's manual change — wait at least one tick interval before overriding. if (DateTime.UtcNow - lastUserMoveUtc < TimeSpan.FromSeconds(intervalSec)) return; // Per-route underrun delta — but the CAUSE-AWARE kind (2026-06-13). We gate on // tune-blocking underruns only: the full-empty / producer-starved short-reads that // genuinely mean "the buffer is too thin". A steady trickle of inaudible device-gulp // partials — a chunky onboard-Realtek render callback asking for an oversized block on an // otherwise on-target ring — is deliberately NOT counted here, so it can no longer pin the // target high forever by making every tick skip. The recommendation below still folds in // the render-callback gap, so even when we're free to lower we can never lower below what // the device structurally needs; it just settles to that floor instead of overshooting up. var currentUnderruns = route == RenderRoute.Mixed ? receiver.TuneBlockingUnderruns : receiver.TuneBlockingUnderrunsFor(route); var underrunDelta = currentUnderruns - lastObservedUnderruns; lastObservedUnderruns = currentUnderruns; // Device-gulp delta is tracked for the diagnostic trail only — it never gates. var currentDeviceGulps = route == RenderRoute.Mixed ? receiver.DeviceGulpUnderruns : receiver.DeviceGulpUnderrunsFor(route); var deviceGulpDelta = currentDeviceGulps - lastObservedDeviceGulps; lastObservedDeviceGulps = currentDeviceGulps; if (underrunDelta > 0) { // Route label slots into the message body when present, omitted entirely in classic // modes so the legacy "continuous auto-tune: skipping (N new underruns...)" wording // is preserved bit-for-bit. The trailing-space + colon ordering is what gave the // pre-fix line its weird "continuous auto-tune : skipping" formatting when the // label was empty. devGulp shows how many inaudible device-gulp partials were ignored // this tick — a high devGulp with a small underrunDelta is the Realtek fingerprint. var prefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}"; logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick, devGulp={deviceGulpDelta} ignored)"); return; } var sampleCount = Math.Min(LookbackSeconds, recentMaxGaps.Count); var skip = recentMaxGaps.Count - sampleCount; // Track the TWO highest arrival-gap seconds, not just the worst. A single transient spike — // one bad second from an OS/driver hiccup that doesn't recur — used to drive the whole // recommendation (one 1046ms gap pushed the buffer straight to the 200ms cap and shed a big // trim burst). Using the SECOND-highest requires the jitter to persist across >=2 seconds // before it counts, while still honouring sustained jitter at full speed. The true peak is // still logged for diagnosis; we fall back to the single value when there's only one sample. int gapPeak = 0, gapSecond = 0; var i = 0; foreach (var gap in recentMaxGaps) { if (i++ < skip) continue; if (gap > gapPeak) { gapSecond = gapPeak; gapPeak = gap; } else if (gap > gapSecond) { gapSecond = gap; } } var observedGap = sampleCount >= 2 ? gapSecond : gapPeak; // Same lone-spike rejection for the render-callback gap. int rcbPeak = RenderPeriodFloorMs, rcbSecond = RenderPeriodFloorMs; var rcbSkip = recentRenderCbGaps.Count - sampleCount; var rcbI = 0; foreach (var rcb in recentRenderCbGaps) { if (rcbI++ < rcbSkip) continue; if (rcb > rcbPeak) { rcbSecond = rcbPeak; rcbPeak = rcb; } else if (rcb > rcbSecond) { rcbSecond = rcb; } } var observedRenderCb = sampleCount >= 2 ? rcbSecond : rcbPeak; var codecFloor = (int)Math.Ceiling(1.5 * frameMs); var jitterBased = observedGap + observedRenderCb + SafetyMarginMs; var recommended = Math.Max(codecFloor, jitterBased); var capped = Math.Min(recommended, AutoTuneRecommendationCapMs); var current = (int)slider.Value; int target; if (capped > current) { target = capped; } else { target = Math.Max(capped, current - MaxDecreasePerTickMs); } var clamped = Math.Clamp(target, (int)slider.Minimum, (int)slider.Maximum); if (Math.Abs(clamped - current) < HysteresisMs) return; suppressFlag = true; try { slider.Value = clamped; } finally { suppressFlag = false; } var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}"; logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms devGulp={deviceGulpDelta}"); } // UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button. // The continuous auto-tune toggle on the Audio profile tab is the live successor. // ===================== Accessibility helpers (CheckedListBox status labels) ===================== private void WireCheckedListAccessibility(CheckedListBox list, Label statusLabel, string itemKind) { // Tick/untick sound for the inputs/outputs AND peer lists. Gated on the list being focused so // a real user click/spacebar clicks, but EVERY programmatic (un)check stays silent — via the // list-focus gate, CheckSoundService.Suppressed during profile apply, and SuppressingCheckSounds // which covers both the device-list mutations and the three peer-list rebuilds (each rebuild // fires ItemCheck for its pre-checked rows). Without the peer-list half, a saved-peer reconnect // rebuilding the focused connected-peers list at startup would click a checkbox sound. Only a // genuine user toggle (no suppression flag set) should click. list.ItemCheck += (_, e) => { if (list.Focused && !SuppressingCheckSounds) CheckSoundService.Play(e.NewValue == CheckState.Checked); }; list.SelectedIndexChanged += (_, _) => { if (list.SelectedIndex >= 0) lastFocusedListIndices[list] = list.SelectedIndex; UpdateCheckedListStatus(list, statusLabel, itemKind); }; list.Enter += (_, _) => RestoreListFocus(list, statusLabel, itemKind); list.GotFocus += (_, _) => RestoreListFocus(list, statusLabel, itemKind); list.MouseDown += (_, args) => { var index = list.IndexFromPoint(args.Location); if (index >= 0) { list.SelectedIndex = index; lastFocusedListIndices[list] = index; } }; // First-letter navigation: highlights the matching item without ever toggling its check. // Default CheckedListBox key handling has been observed to (sometimes) toggle the check // when a single-letter prefix uniquely matches one item. Bypass that by handling KeyDown // ourselves and suppressing the default key processing for letters/digits. Spacebar still // falls through to the default handler so users can still toggle with Space. list.KeyDown += (_, args) => { if (args.Modifiers != Keys.None) return; char ch; if (args.KeyCode >= Keys.A && args.KeyCode <= Keys.Z) ch = (char)('a' + (args.KeyCode - Keys.A)); else if (args.KeyCode >= Keys.D0 && args.KeyCode <= Keys.D9) ch = (char)('0' + (args.KeyCode - Keys.D0)); else if (args.KeyCode >= Keys.NumPad0 && args.KeyCode <= Keys.NumPad9) ch = (char)('0' + (args.KeyCode - Keys.NumPad0)); else return; var startIdx = list.SelectedIndex < 0 ? 0 : list.SelectedIndex + 1; for (var offset = 0; offset < list.Items.Count; offset++) { var idx = (startIdx + offset) % list.Items.Count; var text = list.Items[idx]?.ToString() ?? string.Empty; if (text.Length > 0 && char.ToLowerInvariant(text[0]) == ch) { list.SelectedIndex = idx; break; } } // Always swallow letter/digit keys so the default handler can't toggle anything. args.Handled = true; args.SuppressKeyPress = true; }; list.ItemCheck += (_, args) => { void Update() { if (list.IsDisposed || statusLabel.IsDisposed) return; var checkedNow = args.NewValue == CheckState.Checked; UpdateCheckedListStatus(list, statusLabel, itemKind, args.Index, checkedNow); } if (list.IsHandleCreated) list.BeginInvoke((MethodInvoker)Update); else Update(); }; UpdateCheckedListStatus(list, statusLabel, itemKind); } private void RestoreListFocus(CheckedListBox list, Label statusLabel, string itemKind) { if (list.Items.Count == 0) { UpdateCheckedListStatus(list, statusLabel, itemKind); return; } var target = list.SelectedIndex >= 0 ? list.SelectedIndex : lastFocusedListIndices.TryGetValue(list, out var saved) ? Math.Clamp(saved, 0, list.Items.Count - 1) : 0; void Restore() { if (list.IsDisposed || list.Items.Count == 0) return; target = Math.Clamp(target, 0, list.Items.Count - 1); list.SelectedIndex = target; list.TopIndex = Math.Max(0, target); lastFocusedListIndices[list] = target; UpdateCheckedListStatus(list, statusLabel, itemKind); // Force-fire EVENT_OBJECT_FOCUS once the SelectedIndex and AccessibleDescription // have been set, so NVDA re-announces the list with its current item state. This is // the same load-bearing pattern that fixed the CheckBox state-change announcement. WinEventNotifier.NotifyFocus(list); } if (list.IsHandleCreated) list.BeginInvoke((MethodInvoker)Restore); else Restore(); } private static void UpdateCheckedListStatus(CheckedListBox list, Label statusLabel, string itemKind, int? overrideIndex = null, bool? overrideChecked = null) { if (list.Items.Count == 0) { var emptyText = $"No {itemKind}s available."; statusLabel.Text = emptyText; list.AccessibleDescription = emptyText; return; } var index = overrideIndex ?? (list.SelectedIndex >= 0 ? list.SelectedIndex : 0); index = Math.Clamp(index, 0, list.Items.Count - 1); var isChecked = overrideChecked ?? list.GetItemChecked(index); var checkedText = isChecked ? "checked" : "not checked"; var itemText = list.Items[index]?.ToString() ?? itemKind; var text = $"{checkedText}, {itemText}. Item {index + 1} of {list.Items.Count}. Press Space to toggle."; statusLabel.Text = text; list.AccessibleDescription = text; statusLabel.AccessibleDescription = text; } /// /// Makes a NumericUpDown's text content fully selected whenever the control receives focus, /// so the user's first typed digit replaces the existing value rather than being inserted /// into it. Without this, tabbing into a spinner showing "80" and typing "10" produces /// "8010" — the WinForms default that nobody wants. Hooks both Enter (keyboard / Tab) and /// the underlying TextBox's GotFocus (mouse-click into the field). The Select(0, length) /// targets the inner TextBox via NumericUpDown.Select. /// private static void SelectAllOnFocus(NumericUpDown box) { void SelectAll() => box.Select(0, box.Text.Length); box.Enter += (_, _) => SelectAll(); // The inner TextBox's own GotFocus also fires when the user clicks directly into the // text portion of the spinner. Subscribe defensively to it as well. foreach (Control c in box.Controls) { if (c is TextBox tb) { tb.GotFocus += (_, _) => SelectAll(); break; } } } private void FocusControl(Control control) { if (!control.CanFocus) return; control.Focus(); if (control is ComboBox combo && combo.Items.Count > 0 && combo.SelectedIndex < 0) combo.SelectedIndex = 0; // Same defensive pre-select for ListBox so NVDA reads the current item on first focus // (otherwise an unselected list is announced as just "list" with no item). if (control is ListBox listBox && listBox.Items.Count > 0 && listBox.SelectedIndex < 0) listBox.SelectedIndex = 0; // 2026-05-06: removed the WinEventNotifier.NotifyFocus(control) call here. It was // forcing NVDA to re-announce on every Focus() — and I now suspect that's why NVDA // sometimes reads "tab control" before the focused control: the explicit focus // event triggers a fresh role-context announcement. Andre's app doesn't fire any // such events. Trying without it. } private void FocusListControl(CheckedListBox list) { // Pre-select an item BEFORE calling Focus(). The previous order was Focus() → then // RestoreListFocus → BeginInvoke → SelectedIndex = N. That defers the selection past // NVDA's first focus-event announcement, so NVDA reads only the list's name and not // the current item. Setting SelectedIndex synchronously here means the focus event // fires with the list already pointing at item N, so NVDA reads ", list, item N // of M: , " in one go. var statusLabel = list == sendOutputDevicesList ? sendOutputDevicesStatusLabel : list == sendInputDevicesList ? sendInputDevicesStatusLabel : list == receiveOutputDevicesList ? receiveOutputDevicesStatusLabel : list == asioSendDevicesList ? asioSendDevicesStatusLabel : list == asioReceiveOutputDevicesList ? asioReceiveOutputDevicesStatusLabel : new Label(); var itemKind = list == sendOutputDevicesList ? "output device" : list == sendInputDevicesList ? "input device" : list == receiveOutputDevicesList ? "receive output device" : list == asioSendDevicesList ? "ASIO send channel" : list == asioReceiveOutputDevicesList ? "ASIO receive channel" : "item"; if (list.Items.Count > 0 && list.SelectedIndex < 0) { var target = lastFocusedListIndices.TryGetValue(list, out var saved) ? Math.Clamp(saved, 0, list.Items.Count - 1) : 0; list.SelectedIndex = target; list.TopIndex = Math.Max(0, target); lastFocusedListIndices[list] = target; } UpdateCheckedListStatus(list, statusLabel, itemKind); list.Focus(); WinEventNotifier.NotifyFocus(list); } /// Prompt the user to save unsaved profile changes before exiting. Skipped when /// the close is a profile-switch / folder-change reload (Program.cs handles re-launching /// the form on the new profile, and we don't want to nag during that handoff). The /// MessageBox is Yes/No/Cancel: Yes = save (save-as flow on blank template), No = exit /// without saving, Cancel = stay in the form. protected override void OnFormClosing(FormClosingEventArgs e) { // Skip the prompt during profile-switch handoff or forced reload — those are // 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. // // 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 || LoadBlankTemplateNext || currentProfileReadOnly || updatingInProgress; if (!skipPrompt && profileStore is not null && unsavedChanges) { // Originally this also did a JSON-state diff against a baseline snapshot as a // backstop for hooks we forgot to wire. Removed 2026-05-05 because it caused // false-positive prompts: continuous auto-tune routinely nudges MaxLatencyMs while // the user just listens, and the diff would catch those auto-internal changes as // "user changes". Now we trust the dirty flag exclusively. The risk of missing a // hook (false-NEGATIVE — user changes something via an unhooked path, no prompt // on close) is acceptable; the previous false-POSITIVE behaviour was nagging. { var result = MessageBox.Show(this, "You have unsaved changes to your profile. Save them before exiting?\n\n" + "Yes — save and exit.\nNo — exit without saving.\nCancel — keep RemSound open.", "RemSound — unsaved changes", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question, MessageBoxDefaultButton.Button3); if (result == DialogResult.Cancel) { e.Cancel = true; return; // stay; don't fire base.OnFormClosing or the cleanup chain. } if (result == DialogResult.Yes) { if (string.IsNullOrEmpty(currentProfileTitle)) { // Blank template — need a name. Save-as prompt; if the user cancels // the prompt, treat that as "I changed my mind, don't exit either". var title = ProfileSaveAsPrompt.Show(this, profileStore, null); if (string.IsNullOrEmpty(title)) { e.Cancel = true; return; } SaveProfileTo(title, showConfirmation: false); } else { SaveProfileTo(currentProfileTitle, showConfirmation: false); } } // result == No falls through to a normal close. } } // 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); } }