From dd70613017dbb45965b8b6bccaa067b5606c9ab4 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:12:11 +0100 Subject: [PATCH] v3.4: handle-leak fix, Realtek ASIO block, device notifications, quick profile switch, hotkey announcements Freezes the v3.4 feature set (everything since the v3.3 public release): - Fix the receiver handle leak: the 3-second device-refresh timer reopened the configured ASIO driver every tick, and Realtek's ASIO driver leaks Event+Mutant handles on every open. Cache the ASIO probe per driver so it is opened once. - Realtek ASIO block: detect a Realtek ASIO driver, offer once to disable it, and never touch it again if disabled; Options-menu toggle to reverse. Global config. - Device hot-plug is event-driven (AudioDeviceChangeNotifier) instead of a 3s poll; debounced refresh, falls back to polling if registration fails. - Quick profile switch: new global hotkey opens an NVDA-friendly popup of all profiles (current marked); Enter/click switches; plays a new "profile menu open" cue with Preferences mute + custom-sound. - Announce assigned global hotkeys on the controls/menu items they drive (NVDA reads "press X anywhere"). File > Open already had Ctrl+O. - Held-back changes folded in: config-folder migration, codec-column fix, Tailscale endpoint network-prune, empty-password guard, and the handle-leak diagnostics (ProcessSelfMeter, HandleTypeProbe). Version bumped to 3.4.0. Co-Authored-By: Claude Opus 4.8 --- build-release.ps1 | 6 +- readme.html | 4 + sounds/Profile menu open.wav | Bin 0 -> 182048 bytes src/RemSound.App/AudioDeviceChangeNotifier.cs | 47 ++ src/RemSound.App/HandleTypeProbe.cs | 230 +++++++ src/RemSound.App/MainForm.cs | 624 ++++++++++++++++-- src/RemSound.App/MainFormHotkeyController.cs | 26 +- src/RemSound.App/PreferencesDialog.cs | 3 + src/RemSound.App/ProcessSelfMeter.cs | 48 +- src/RemSound.App/ProfilePasswordDialog.cs | 37 +- src/RemSound.App/Program.cs | 39 ++ src/RemSound.App/QuickProfileSwitchDialog.cs | 151 +++++ src/RemSound.App/RemSound.App.csproj | 8 +- src/RemSound.Core/AppConfig.cs | 100 ++- src/RemSound.Core/Profile.cs | 6 + src/RemSound.Core/ProfileStore.cs | 4 +- src/RemSound.Core/RemSoundSettingsStore.cs | 26 + src/RemSound.Receiver/AudioReceiver.cs | 30 + 18 files changed, 1325 insertions(+), 64 deletions(-) create mode 100644 sounds/Profile menu open.wav create mode 100644 src/RemSound.App/AudioDeviceChangeNotifier.cs create mode 100644 src/RemSound.App/HandleTypeProbe.cs create mode 100644 src/RemSound.App/QuickProfileSwitchDialog.cs diff --git a/build-release.ps1 b/build-release.ps1 index 9d463e2..3a2aed1 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -103,7 +103,10 @@ if (Test-Path $syncScript) { # Anything matching these must NEVER appear in a release. Folders by name; files by # extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are # legitimate app files and are deliberately NOT matched (different names). -$forbiddenFolders = @('logs', 'profiles', 'recordings') +# 'config' added 2026-06-07: the config\ folder now holds global config (config\global config.json) +# AND profiles (config\profiles\), so forbidding the whole folder catches both in one rule. The +# legacy 'profiles' and 'remsound.config.json' rules stay for any pre-migration leftovers. +$forbiddenFolders = @('logs', 'profiles', 'recordings', 'config') function Test-Forbidden([string]$path) { $p = $path -replace '\\', '/' foreach ($f in $forbiddenFolders) { @@ -111,6 +114,7 @@ function Test-Forbidden([string]$path) { } if ($p -match '\.log$') { return $true } if ($p -match '(^|/)remsound\.config\.json$') { return $true } + if ($p -match '(^|/)global config\.json$') { return $true } return $false } diff --git a/readme.html b/readme.html index 626a2c0..9c12c8b 100644 --- a/readme.html +++ b/readme.html @@ -67,6 +67,10 @@ ul, ol { padding-left: 1.4em; }

There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.

+
+RemSound on Android (receiver): there is a companion app that lets a phone or tablet receive RemSound audio — handy for listening on the move. It's a separate community project built and maintained by Aryan Choudhary, who is a screen-reader user himself and has tuned the app for TalkBack; it is not part of RemSound and is not maintained by us. Get the signed app from its releases page (download the latest app-release.apk): RemSound Android — Releases. +
+

2. Quick start

Let's assume you and a friend both have RemSound running, and that your two computers can reach each other on the network (the same Wi-Fi, the same Tailscale account, and so on).

diff --git a/sounds/Profile menu open.wav b/sounds/Profile menu open.wav new file mode 100644 index 0000000000000000000000000000000000000000..7b622827bab32e15b3cc032360c7c42795a13479 GIT binary patch literal 182048 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 LCwP2#h2{SMMvT6G literal 0 HcmV?d00001 diff --git a/src/RemSound.App/AudioDeviceChangeNotifier.cs b/src/RemSound.App/AudioDeviceChangeNotifier.cs new file mode 100644 index 0000000..a4f4265 --- /dev/null +++ b/src/RemSound.App/AudioDeviceChangeNotifier.cs @@ -0,0 +1,47 @@ +using NAudio.CoreAudioApi; +using NAudio.CoreAudioApi.Interfaces; + +namespace RemSound.App; + +/// +/// Fires a callback whenever the set of Windows audio endpoints changes — a device is added, +/// removed, changes state (plugged / unplugged / enabled / disabled), or the default device +/// changes. This replaces the pre-v3.4 3-second polling of the device lists: instead of +/// re-enumerating every tick whether or not anything changed, RemSound re-reads the device lists +/// only when Windows actually tells us the device set changed. +/// +/// The callbacks arrive on a COM thread, so the consumer () marshals to the +/// UI thread and debounces — a single hot-plug typically fires several of these in quick +/// succession (state-changed + added + default-changed), which collapse into one refresh. +/// +/// Property-value changes (volume nudges, format tweaks) are deliberately ignored: they fire +/// constantly and never change the device set, so reacting to them would defeat the +/// whole point of going event-driven. +/// +internal sealed class AudioDeviceChangeNotifier : IMMNotificationClient, IDisposable +{ + private readonly MMDeviceEnumerator enumerator = new(); + private readonly Action onChange; + private bool registered; + + public AudioDeviceChangeNotifier(Action onChange) + { + this.onChange = onChange; + enumerator.RegisterEndpointNotificationCallback(this); + registered = true; + } + + public void OnDeviceStateChanged(string deviceId, DeviceState newState) => onChange(); + public void OnDeviceAdded(string pwstrDeviceId) => onChange(); + public void OnDeviceRemoved(string deviceId) => onChange(); + public void OnDefaultDeviceChanged(DataFlow flow, Role role, string defaultDeviceId) => onChange(); + public void OnPropertyValueChanged(string pwstrDeviceId, PropertyKey key) { /* ignore — too chatty, no set change */ } + + public void Dispose() + { + try { if (registered) enumerator.UnregisterEndpointNotificationCallback(this); } + catch { /* COM teardown race on shutdown — harmless */ } + registered = false; + try { enumerator.Dispose(); } catch { /* ignore */ } + } +} diff --git a/src/RemSound.App/HandleTypeProbe.cs b/src/RemSound.App/HandleTypeProbe.cs new file mode 100644 index 0000000..2eeacc8 --- /dev/null +++ b/src/RemSound.App/HandleTypeProbe.cs @@ -0,0 +1,230 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace RemSound.App; + +/// +/// Counts THIS process's open OS handles grouped by type (Event, Section, File, Key, Thread, …). +/// Added 2026-06-07 to pin down the receiver handle leak: the diag line's handles= column +/// proved the leak is OS handles, but not WHICH kind — and the kind names the culprit (Event ⇒ +/// a waitable-object leak, Section ⇒ a WASAPI buffer/audio-client leak, Key ⇒ a CNG/AesGcm leak, +/// Thread ⇒ thread-handle leak, etc.). +/// +/// Primary mechanism: NtQueryInformationProcess(ProcessHandleInformation) returns ONLY the calling +/// process's handle table — small, fast, and it never touches the system-wide handle table. The +/// original system-wide walk via NtQuerySystemInformation(SystemExtendedHandleInformation) returned +/// STATUS_ACCESS_VIOLATION (0xC0000005) on Andre's ASUS-Realtek machine, so it is now only a +/// fallback for any box where the per-process class is unavailable. Type names are resolved per +/// distinct ObjectTypeIndex ONCE via NtQueryObject(ObjectTypeInformation) on a sample handle — +/// class 2 is safe on our own handles (the known NtQueryObject hang only affects ObjectName- +/// Information, class 1, on synchronous pipes, which we never request). Everything is wrapped in +/// try/catch and unmanaged buffers are always freed, so a failure degrades to a probe-error string +/// rather than disturbing the very memory we're measuring. x64 only (the shipped runtime). +/// +internal static class HandleTypeProbe +{ + private const int ProcessHandleInformation = 51; // PROCESSINFOCLASS + private const int SystemExtendedHandleInformation = 0x40; // SYSTEM_INFORMATION_CLASS + private const int ObjectTypeInformation = 2; + private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; + + // PROCESS_HANDLE_SNAPSHOT_INFORMATION (x64): NumberOfHandles (ULONG_PTR) +0, Reserved +8, + // then PROCESS_HANDLE_TABLE_ENTRY_INFO[] at +16. Each entry is 40 bytes: + // HandleValue +0, HandleCount +8, PointerCount +16, GrantedAccess +24, + // ObjectTypeIndex (ULONG) +28, HandleAttributes +32, Reserved +36. + private const int PhHeaderSize = 16; + private const int PhEntrySize = 40; + private const int PhOffHandleValue = 0; + private const int PhOffObjectTypeIndex = 28; + + // 64-bit SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX is 40 bytes; UniqueProcessId at +8, HandleValue at + // +16, ObjectTypeIndex (USHORT) at +30. Header is 16 bytes (NumberOfHandles + Reserved). + private const int SysHeaderSize = 16; + private const int SysEntrySize = 40; + private const int SysOffUniqueProcessId = 8; + private const int SysOffHandleValue = 16; + private const int SysOffObjectTypeIndex = 30; + + // Cap any snapshot so a pathological system can't make us allocate without bound (we're + // hunting a leak — don't become one). 128 MB covers well over a million handles. + private const int MaxBufferBytes = 128 * 1024 * 1024; + + private static readonly nint CurrentProcessPseudoHandle = (nint)(-1); + private static readonly Dictionary TypeNameByIndex = new(); + private static readonly int OwnPid = Process.GetCurrentProcess().Id; + + /// + /// Returns "Event=120345 Section=15234 File=210 … total=N" — the most + /// common handle types owned by this process. Tries the per-process query first, falls back to + /// the system-wide walk, and returns a probe-error string (never throws) if both fail. + /// Heavier than the per-tick meter, so call it on a slow cadence, not every diag line. + /// + public static string Summarize(int topN = 10) + { + var own = TryOwnProcess(topN, out var ownStatus); + if (own != null) return own; + var sys = TrySystemWide(topN, out var sysStatus); + if (sys != null) return sys; + return $"probe-error proc-status=0x{ownStatus:X8} sys-status=0x{sysStatus:X8}"; + } + + /// + /// Primary path: query only THIS process's handle table. Returns the formatted summary, or null + /// on any failure (with set to the NTSTATUS for diagnostics). + /// + private static string? TryOwnProcess(int topN, out uint status) + { + nint buffer = 0; + status = 0; + try + { + var size = 1 << 18; // 256 KB — our own table is small even when leaking. + while (true) + { + buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size); + status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out var needed); + if (status != STATUS_INFO_LENGTH_MISMATCH) break; + size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes); + if (size >= MaxBufferBytes) { status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out _); break; } + } + if (status != 0) return null; + + var count = Marshal.ReadInt64(buffer); // NumberOfHandles + var counts = new Dictionary(); + var entryBase = buffer + PhHeaderSize; + for (long i = 0; i < count; i++) + { + var entry = entryBase + (nint)(i * PhEntrySize); + var typeIndex = (ushort)Marshal.ReadInt32(entry + PhOffObjectTypeIndex); + counts.TryGetValue(typeIndex, out var c); + counts[typeIndex] = c + 1; + if (!TypeNameByIndex.ContainsKey(typeIndex)) + { + var handle = Marshal.ReadIntPtr(entry + PhOffHandleValue); + TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex); + } + } + return counts.Count == 0 ? null : Format(counts, topN); + } + catch + { + return null; + } + finally + { + if (buffer != 0) Marshal.FreeHGlobal(buffer); + } + } + + /// + /// Fallback path: walk the whole system handle table and filter to our PID. Returns null on any + /// failure (with set). Kept for machines where the per-process class + /// is unavailable; on Andre's box this path returns STATUS_ACCESS_VIOLATION, which is exactly + /// why is tried first. + /// + private static string? TrySystemWide(int topN, out uint status) + { + nint buffer = 0; + status = 0; + try + { + var size = 1 << 20; // 1 MB to start; grow on mismatch. + while (true) + { + buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size); + status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out var needed); + if (status != STATUS_INFO_LENGTH_MISMATCH) break; + size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes); + if (size >= MaxBufferBytes) { status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out _); break; } + } + if (status != 0) return null; + + var count = Marshal.ReadInt64(buffer); // NumberOfHandles + var counts = new Dictionary(); + var entryBase = buffer + SysHeaderSize; + for (long i = 0; i < count; i++) + { + var entry = entryBase + (nint)(i * SysEntrySize); + var pid = (int)Marshal.ReadInt64(entry + SysOffUniqueProcessId); + if (pid != OwnPid) continue; + var typeIndex = (ushort)Marshal.ReadInt16(entry + SysOffObjectTypeIndex); + counts.TryGetValue(typeIndex, out var c); + counts[typeIndex] = c + 1; + if (!TypeNameByIndex.ContainsKey(typeIndex)) + { + var handle = Marshal.ReadIntPtr(entry + SysOffHandleValue); + TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex); + } + } + return counts.Count == 0 ? null : Format(counts, topN); + } + catch + { + return null; + } + finally + { + if (buffer != 0) Marshal.FreeHGlobal(buffer); + } + } + + private static string Format(Dictionary counts, int topN) + { + var ordered = new List>(counts); + ordered.Sort((a, b) => b.Value.CompareTo(a.Value)); + + var sb = new StringBuilder(); + var total = 0; + var shown = 0; + foreach (var kv in ordered) + { + total += kv.Value; + if (shown < topN) + { + if (sb.Length > 0) sb.Append(' '); + sb.Append(TypeNameByIndex.TryGetValue(kv.Key, out var n) ? n : $"Type#{kv.Key}").Append('=').Append(kv.Value); + shown++; + } + } + sb.Append(" total=").Append(total); + return sb.ToString(); + } + + private static string ResolveTypeName(nint handle, ushort index) + { + nint info = 0; + try + { + const int len = 4096; + info = Marshal.AllocHGlobal(len); + var status = NtQueryObject(handle, ObjectTypeInformation, info, len, out _); + if (status != 0) return $"Type#{index}"; + // OBJECT_TYPE_INFORMATION starts with UNICODE_STRING TypeName { USHORT Length; + // USHORT MaximumLength; PWSTR Buffer; } — Length at +0, Buffer (ptr) at +8 on x64. + var nameLen = (ushort)Marshal.ReadInt16(info); + var namePtr = Marshal.ReadIntPtr(info + 8); + if (namePtr == 0 || nameLen == 0) return $"Type#{index}"; + var name = Marshal.PtrToStringUni(namePtr, nameLen / 2); + return string.IsNullOrEmpty(name) ? $"Type#{index}" : name; + } + catch + { + return $"Type#{index}"; + } + finally + { + if (info != 0) Marshal.FreeHGlobal(info); + } + } + + [DllImport("ntdll.dll")] + private static extern uint NtQueryInformationProcess(nint processHandle, int processInformationClass, nint processInformation, uint processInformationLength, out uint returnLength); + + [DllImport("ntdll.dll")] + private static extern uint NtQuerySystemInformation(int systemInformationClass, nint systemInformation, uint systemInformationLength, out uint returnLength); + + [DllImport("ntdll.dll")] + private static extern uint NtQueryObject(nint handle, int objectInformationClass, nint objectInformation, int objectInformationLength, out int returnLength); +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 1b50e31..652f3c7 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -97,6 +97,9 @@ public sealed class MainForm : Form 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; @@ -329,6 +332,7 @@ public sealed class MainForm : Form // Save As; Profile fires immediately after a profile finishes loading in MainForm. private CuePlayer? saveSound; private CuePlayer? profileSwitchSound; + private CuePlayer? profileMenuOpenSound; private CuePlayer? updateSound; // 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 @@ -362,21 +366,23 @@ public sealed class MainForm : Form private readonly Dictionary lastFocusedListIndices = []; private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 }; - // Periodic re-enumeration of WASAPI devices so USB hot-plug/unplug shows up in the lists - // within a second of plugging. Cost per tick in the no-change case is just two COM - // enumerations + a string compare — a few ms on the UI thread, no impact on the audio - // threads (which run on separate MMCSS-boosted threads). The listbox itself is only - // rebuilt when the (id, name) signature actually changes, so NVDA isn't pestered on every - // tick — only when a device truly came or went. - // 3 s interval (was 1 s pre-2026-05-23). Item 4 of RemSoundefficiency.md — when an ASIO - // driver is configured, each tick calls AsioDeviceProbe.ProbeDriverInfo which briefly - // opens the driver to enumerate channel names. That's measurable CPU (~1.6 % of one core - // in the test we ran) for a check that only matters when a USB audio device is hot- - // plugged. 3 s is the value the existing RefreshAudioDeviceLists docstring already - // claimed; the actual timer just hadn't been bumped to match. Hot-plug latency goes from - // up-to-1 s to up-to-3 s, which is fine for the device-list-refresh use case (nobody - // pulls a device and stares at the menu in the next second waiting for it to drop off). - private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 3000 }; + // 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; // 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), @@ -387,6 +393,17 @@ public sealed class MainForm : Form 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; @@ -394,6 +411,21 @@ public sealed class MainForm : Form 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; @@ -402,6 +434,9 @@ public sealed class MainForm : Form // 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; @@ -648,7 +683,8 @@ public sealed class MainForm : Form // fixed by Windows. Hold the hotkey for bigger jumps. () => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0), () => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0), - () => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0)); + () => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0), + ShowQuickProfileSwitch); // 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: @@ -663,7 +699,12 @@ public sealed class MainForm : Form // 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; + 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 @@ -720,6 +761,13 @@ public sealed class MainForm : Form // (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 (Alt+&U)"; + uncheckAllDevicesButton.AccessibleName = "Uncheck all inputs and outputs on all soundcards"; + 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 @@ -727,9 +775,23 @@ public sealed class MainForm : Form // driver picker is hidden entirely in BuildAudioIOTab and the form runs WASAPI-only. var asioDriverNames = AsioDeviceProbe.EnumerateDriverNames(); hasAnyAsioDriverInstalled = asioDriverNames.Count > 0; - logFile.Event($"asio drivers enumerated at startup: [{string.Join(", ", asioDriverNames.Select(n => $"\"{n}\""))}]"); + // 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) asioDriverBox.Items.Add(name); + 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 @@ -769,6 +831,7 @@ public sealed class MainForm : Form 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 @@ -916,6 +979,7 @@ public sealed class MainForm : Form 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); LoadAudioDevices(); @@ -1038,7 +1102,11 @@ public sealed class MainForm : Form }; // --- Hot-swap device watcher --- - deviceRefreshTimer.Tick += (_, _) => RefreshAudioDeviceLists(); + deviceRefreshTimer.Tick += (_, _) => + { + if (deviceRefreshOneShot) deviceRefreshTimer.Stop(); // debounce: one refresh per change burst + RefreshAudioDeviceLists(); + }; BuildLayout(); LoadRememberedPeersFromSettings(); @@ -1047,6 +1115,9 @@ public sealed class MainForm : Form // 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 @@ -1066,6 +1137,7 @@ public sealed class MainForm : Form 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 @@ -1191,9 +1263,32 @@ public sealed class MainForm : Form // If the user opted in, show the About box once on the first launch after an update // installed, so they see what's new. BeginInvoke so it opens after Shown completes. BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate)); + + // Offer once to disable a handle-leaking Realtek ASIO driver if one is installed. + // BeginInvoke so the TaskDialog opens after Shown completes (and after the what's-new + // box, if that fired). + BeginInvoke(new Action(MaybeWarnAboutRealtekAsio)); }; 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(); } @@ -1473,15 +1568,28 @@ public sealed class MainForm : Form }; profilePasswordsItem.Click += (_, _) => OpenProfilePasswordManager(); - optionsMenu.DropDownItems.AddRange(new ToolStripItem[] + // 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) + { + realtekToggle = new ToolStripMenuItem { AccessibleName = "Toggle Realtek ASIO driver in RemSound" }; + realtekToggle.Click += (_, _) => ToggleRealtekAsio(); + realtekAsioToggleItem = realtekToggle; + UpdateRealtekAsioMenuItemText(); + } + + var optionItems = new List { recordingSettingsItem, keyboardItem, startupBehaviourItem, profilePasswordsItem, - new ToolStripSeparator(), - prefsItem, - }); + }; + 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 @@ -1607,6 +1715,50 @@ public sealed class MainForm : Form 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)) + { + // No-ops if it's already the current profile; otherwise reloads into the chosen one, + // which plays the profile-switch cue on the relaunch. + SwitchToRecentProfile(chosen); + } + } + catch (Exception ex) + { + logFile.Event($"quick profile switch failed: {ex.GetType().Name}: {ex.Message}"); + } + } + /// 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 @@ -2401,7 +2553,7 @@ public sealed class MainForm : Form Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 2, - RowCount = 10, + RowCount = 11, AutoScroll = true, }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); @@ -2422,12 +2574,17 @@ public sealed class MainForm : Form // 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, 0); - panel.Controls.Add(asioDriverBox, 1, 0); + panel.Controls.Add(asioDriverLabel, 0, 1); + panel.Controls.Add(asioDriverBox, 1, 1); } else { @@ -2441,16 +2598,16 @@ public sealed class MainForm : Form // 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, 1); - receiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 2, "WASAPI outputs for received sound (Alt+&3)", receiveOutputDevicesList, receiveOutputDevicesStatusLabel, FocusListControl); - asioReceiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 3, "ASIO outputs for received sound (Alt+&1)", asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, FocusListControl); - FormLayoutRows.AddRow(panel, 4, "Set volume for all received audio (Alt+&V)", volumeBar, FocusControl); + 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, 5); - sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 6, "WASAPI outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl); - sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl); - asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "ASIO inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl); + 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); } @@ -2887,7 +3044,8 @@ public sealed class MainForm : Form sb.AppendLine("Uptime: 0 seconds."); } - sb.Append($"Receiving {rxKbs:0.0} kB/s; sending {txKbs:0.0} kB/s."); + 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(); } @@ -3250,12 +3408,19 @@ public sealed class MainForm : Form { if (!connected) return; - var endpoints = SelectedSendEndpoints(); - sender.SetReceivers(endpoints); + 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 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. @@ -3327,6 +3492,62 @@ public sealed class MainForm : Form } } + /// + /// 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; + + // Endpoints the heartbeat currently considers long-dead, keyed by "ip:port". + 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) + { + (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(); + + // Safety net: never silence EVERY peer through pruning. If the whole set looks dead (a + // total network drop), keep sending to all — the wasted SendTo per dead endpoint is + // cheaper than masking a global outage or missing the recovery. + if (armed.Length == 0) armed = all; + + 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 (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) @@ -3388,13 +3609,34 @@ public sealed class MainForm : Form } } + /// + /// 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; + 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. - /// Driven by at 3 s intervals so USB hot-plug / unplug - /// shows up without an app restart. 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. + /// 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() { @@ -3425,16 +3667,16 @@ public sealed class MainForm : Form wasapiInputs = AudioDeviceCatalog.LoadInputs(); var currentMode = settings.LoadAudioMode(); - if (ModeUsesAsio(currentMode) && settings.LoadAsioDriverName() is { } asioDriver && !string.IsNullOrWhiteSpace(asioDriver)) + if (ModeUsesAsio(currentMode) && settings.LoadAsioDriverName() is { } asioDriver && !string.IsNullOrWhiteSpace(asioDriver) && !disabledAsioDrivers.Contains(asioDriver)) { - var info = AsioDeviceProbe.ProbeDriverInfo(asioDriver); - if (info.InputChannelCount >= 0 && info.OutputChannelCount >= 0) + 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 + 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 @@ -3483,6 +3725,161 @@ public sealed class MainForm : Form } } + 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; + } + + /// + /// 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() + { + 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. You can re-enable it any time from the Options menu.", + 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 TaskDialog.ShowDialog(this, 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)); + realtekAsioToggleItem.Text = 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 @@ -3864,6 +4261,44 @@ public sealed class MainForm : Form 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; } + + 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 @@ -3917,6 +4352,7 @@ public sealed class MainForm : Form // 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"); @@ -4463,6 +4899,10 @@ public sealed class MainForm : Form // 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. @@ -4504,6 +4944,29 @@ public sealed class MainForm : Form // 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 @@ -4519,7 +4982,7 @@ public sealed class MainForm : Form connected: connected, sendRunning: sender.IsRunning, receiveRunning: receiver.IsRunning, - codec: sender.Codec.ToString(), + codec: SnapshotCodecLabel(), maxLatencyMs: primaryMaxMs, targetLatencyMs: primaryTargetMs, bufferMs: receiver.CurrentBufferMs, @@ -4765,6 +5228,7 @@ public sealed class MainForm : Form $"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} " + @@ -4838,6 +5302,7 @@ public sealed class MainForm : Form $"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}"); } @@ -5149,11 +5614,12 @@ public sealed class MainForm : Form catch { /* baseline failure shouldn't block save */ } 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. Skipping (empty - // or Cancel) leaves it passwordless; the streaming gate will ask again when needed. + // 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(this, title, ""); + var pw = ProfilePasswordDialog.Show(this, title, "", requireNonEmpty: true); if (!string.IsNullOrEmpty(pw)) { currentProfilePassword = pw; @@ -5440,7 +5906,7 @@ public sealed class MainForm : Form if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle; - var entered = ProfilePasswordDialog.Show(this, label, ""); + var entered = ProfilePasswordDialog.Show(this, label, "", requireNonEmpty: true); if (string.IsNullOrEmpty(entered)) { // No password → can't stream. Put the box back without re-firing this gate. @@ -5761,6 +6227,7 @@ public sealed class MainForm : Form 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"; } @@ -5823,6 +6290,7 @@ public sealed class MainForm : Form 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); } @@ -5907,6 +6375,35 @@ public sealed class MainForm : Form 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(() => @@ -6105,6 +6602,27 @@ public sealed class MainForm : Form }; } + /// + /// 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; diff --git a/src/RemSound.App/MainFormHotkeyController.cs b/src/RemSound.App/MainFormHotkeyController.cs index 5ed6fa9..82f6d8f 100644 --- a/src/RemSound.App/MainFormHotkeyController.cs +++ b/src/RemSound.App/MainFormHotkeyController.cs @@ -30,6 +30,7 @@ internal sealed class MainFormHotkeyController : IDisposable private readonly Action sendSystemVolumeUp; private readonly Action sendSystemVolumeDown; private readonly Action sendSystemMuteToggle; + private readonly Action quickProfileSwitch; private Form? owner; private HotkeyInfo sendMuteHotkey; private HotkeyInfo receiveMuteHotkey; @@ -43,6 +44,7 @@ internal sealed class MainFormHotkeyController : IDisposable private HotkeyInfo systemVolumeUpHotkey; private HotkeyInfo systemVolumeDownHotkey; private HotkeyInfo systemMuteToggleHotkey; + private HotkeyInfo quickProfileSwitchHotkey; private GlobalHotkey? sendMuteGlobalHotkey; private GlobalHotkey? receiveMuteGlobalHotkey; private GlobalHotkey? trayGlobalHotkey; @@ -55,6 +57,7 @@ internal sealed class MainFormHotkeyController : IDisposable private GlobalHotkey? systemVolumeUpGlobalHotkey; private GlobalHotkey? systemVolumeDownGlobalHotkey; private GlobalHotkey? systemMuteToggleGlobalHotkey; + private GlobalHotkey? quickProfileSwitchGlobalHotkey; /// Optional log sink. MainForm wires this to logFile.Event(...) so each /// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X @@ -83,7 +86,8 @@ internal sealed class MainFormHotkeyController : IDisposable Action sendRemoteMuteToggle, Action sendSystemVolumeUp, Action sendSystemVolumeDown, - Action sendSystemMuteToggle) + Action sendSystemMuteToggle, + Action quickProfileSwitch) { this.settingsStore = settingsStore; this.toggleSend = toggleSend; @@ -98,6 +102,7 @@ internal sealed class MainFormHotkeyController : IDisposable this.sendSystemVolumeUp = sendSystemVolumeUp; this.sendSystemVolumeDown = sendSystemVolumeDown; this.sendSystemMuteToggle = sendSystemMuteToggle; + this.quickProfileSwitch = quickProfileSwitch; sendMuteHotkey = settingsStore.LoadSendMuteHotkey(); receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey(); trayHotkey = settingsStore.LoadTrayHotkey(); @@ -110,6 +115,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey(); systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey(); systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey(); + quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey(); } public void Initialize(Form ownerForm) @@ -127,6 +133,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm); systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm); systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm); + quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm); sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend); receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive); trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray); @@ -139,6 +146,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp); systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown); systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle); + quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch); RegisterSendMuteHotkey(); RegisterReceiveMuteHotkey(); RegisterTrayHotkey(); @@ -151,6 +159,7 @@ internal sealed class MainFormHotkeyController : IDisposable RegisterSystemVolumeUpHotkey(); RegisterSystemVolumeDownHotkey(); RegisterSystemMuteToggleHotkey(); + RegisterQuickProfileSwitchHotkey(); } public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner) @@ -259,6 +268,7 @@ internal sealed class MainFormHotkeyController : IDisposable list.Items.Add($"Send Windows global volume up to peers: {systemVolumeUpHotkey}"); list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}"); list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}"); + list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}"); if (prev >= 0 && prev < list.Items.Count) { list.SelectedIndex = prev; @@ -295,6 +305,7 @@ internal sealed class MainFormHotkeyController : IDisposable case 9: ChangeSystemVolumeUpHotkey(dialog); break; case 10: ChangeSystemVolumeDownHotkey(dialog); break; case 11: ChangeSystemMuteToggleHotkey(dialog); break; + case 12: ChangeQuickProfileSwitchHotkey(dialog); break; default: return; } RefreshList(); @@ -326,6 +337,7 @@ internal sealed class MainFormHotkeyController : IDisposable case 9: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break; case 10: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break; case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break; + case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break; default: return; } RefreshList(); @@ -405,6 +417,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemVolumeUpGlobalHotkey?.Dispose(); systemVolumeDownGlobalHotkey?.Dispose(); systemMuteToggleGlobalHotkey?.Dispose(); + quickProfileSwitchGlobalHotkey?.Dispose(); } public HotkeyInfo SendMuteHotkey => sendMuteHotkey; @@ -419,6 +432,7 @@ internal sealed class MainFormHotkeyController : IDisposable public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey; public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey; public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey; + public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey; /// Open the capture dialog, log what came back, and (on a successful capture) /// run with the captured hotkey. Centralises the boilerplate @@ -550,6 +564,13 @@ internal sealed class MainFormHotkeyController : IDisposable settingsStore.SaveSystemMuteToggleHotkey(h); }); + private void ChangeQuickProfileSwitchHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "quick-profile-switch", h => + { + quickProfileSwitchHotkey = h; + RegisterQuickProfileSwitchHotkey(); + settingsStore.SaveQuickProfileSwitchHotkey(h); + }); + // Hotkeys come in two flavours and need different Windows-side registration: // * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back // and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire. @@ -575,6 +596,9 @@ internal sealed class MainFormHotkeyController : IDisposable private void RegisterSystemVolumeUpHotkey() => RegisterIfSet(systemVolumeUpGlobalHotkey, systemVolumeUpHotkey, "send Windows global volume up", allowRepeat: true); private void RegisterSystemVolumeDownHotkey() => RegisterIfSet(systemVolumeDownGlobalHotkey, systemVolumeDownHotkey, "send Windows global volume down", allowRepeat: true); private void RegisterSystemMuteToggleHotkey() => RegisterIfSet(systemMuteToggleGlobalHotkey, systemMuteToggleHotkey, "send Windows global mute toggle"); + // Quick profile switch is a one-shot (press → open the popup); MOD_NOREPEAT (the default) keeps + // a held key from re-opening it repeatedly. + private void RegisterQuickProfileSwitchHotkey() => RegisterIfSet(quickProfileSwitchGlobalHotkey, quickProfileSwitchHotkey, "quick profile switch"); private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false) { diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 9889671..522d170 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -102,6 +102,8 @@ internal sealed class PreferencesDialog : Form s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)), new("Profile switched sound", MainForm.CueId.ProfileSwitch, s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)), + new("Profile menu open sound", MainForm.CueId.ProfileMenuOpen, + s => s.LoadEnableProfileMenuOpenCue(), (s, v) => s.SaveEnableProfileMenuOpenCue(v)), new("Update sound", MainForm.CueId.Update, s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)), ]; @@ -615,6 +617,7 @@ internal sealed class PreferencesDialog : Form MainForm.CueId.RecordStop => "record stop.wav", MainForm.CueId.Save => "save.wav", MainForm.CueId.ProfileSwitch => "profile.wav", + MainForm.CueId.ProfileMenuOpen => "profile menu open.wav", MainForm.CueId.Update => "update.wav", _ => null, }; diff --git a/src/RemSound.App/ProcessSelfMeter.cs b/src/RemSound.App/ProcessSelfMeter.cs index 9fe2159..5eaf569 100644 --- a/src/RemSound.App/ProcessSelfMeter.cs +++ b/src/RemSound.App/ProcessSelfMeter.cs @@ -44,12 +44,35 @@ internal sealed class ProcessSelfMeter /// a leak somewhere in the hot path. /// Wall-clock milliseconds since the previous sample, so the /// caller can sanity-check the delta calculation. Roughly 1000 in steady state. + /// Private committed bytes (PrivateMemorySize64) — managed heap + /// PLUS native heap allocations, minus shared/file-backed pages. The honest "how much memory + /// is this process actually holding" figure. If this climbs while ManagedHeapMb stays flat, + /// the leak is native (NAudio / a driver), not .NET objects. + /// Managed heap size the GC reports right now + /// (GCMemoryInfo.HeapSizeBytes), including free gaps. Climbing here = a managed leak. + /// Free-but-uncompacted bytes inside the managed heap + /// (GCMemoryInfo.FragmentedBytes). Under SustainedLowLatency (no gen2 compaction) this is the + /// signal for "the leak is GC fragmentation" — it would grow while GcHeapMb roughly tracks it + /// and ManagedHeapMb (live set) stays flat. A compacting GC would reclaim it. + /// Total bytes the GC has committed from the OS + /// (GCMemoryInfo.TotalCommittedBytes). The managed share of the working set. + /// Open OS handle count. A steady climb here = a handle leak + /// (e.g. audio clients / events created and never released), which inflates native memory + /// without touching the managed heap. + /// Process thread count. A climb = a thread leak (each thread costs + /// ~1 MB of stack), another way native/working-set memory grows with the managed heap flat. public readonly record struct Snapshot( double CpuPercentOneCore, double ManagedHeapMb, double WorkingSetMb, double AllocatedKbPerSecond, - double ElapsedMs); + double ElapsedMs, + double PrivateBytesMb, + double GcHeapMb, + double GcFragmentedMb, + double GcCommittedMb, + int HandleCount, + int ThreadCount); public Snapshot Take() { @@ -70,6 +93,21 @@ internal sealed class ProcessSelfMeter // size of the heap as the GC knows it. var managedHeapBytes = GC.GetTotalMemory(false); + // Leak-classification extras (added 2026-06-06 for the ONJTOP unmanaged-growth hunt). + // All cheap, all read once per second, all behind the diag/log gate via the caller. + // * PrivateMemorySize64 — committed managed + native, the "is the process really + // holding more" number. Native leak ⇒ this climbs with the managed heap flat. + // * GCMemoryInfo — HeapSize / Fragmented / Committed distinguish a managed leak or + // SustainedLowLatency fragmentation from a genuine native leak. + // * Handle / thread counts — catch a handle or thread leak (e.g. WASAPI clients/events + // created per glitch and never released) that grows native memory off-heap. + var privateBytes = selfProcess.PrivateMemorySize64; + var gcInfo = GC.GetGCMemoryInfo(); + int handleCount; + try { handleCount = selfProcess.HandleCount; } catch { handleCount = 0; } + int threadCount; + try { threadCount = selfProcess.Threads.Count; } catch { threadCount = 0; } + double cpuPercent = 0; double allocKbps = 0; double elapsedMs = 0; @@ -94,6 +132,12 @@ internal sealed class ProcessSelfMeter ManagedHeapMb: managedHeapBytes / (1024.0 * 1024.0), WorkingSetMb: workingSet / (1024.0 * 1024.0), AllocatedKbPerSecond: allocKbps, - ElapsedMs: elapsedMs); + ElapsedMs: elapsedMs, + PrivateBytesMb: privateBytes / (1024.0 * 1024.0), + GcHeapMb: gcInfo.HeapSizeBytes / (1024.0 * 1024.0), + GcFragmentedMb: gcInfo.FragmentedBytes / (1024.0 * 1024.0), + GcCommittedMb: gcInfo.TotalCommittedBytes / (1024.0 * 1024.0), + HandleCount: handleCount, + ThreadCount: threadCount); } } diff --git a/src/RemSound.App/ProfilePasswordDialog.cs b/src/RemSound.App/ProfilePasswordDialog.cs index bb424e3..c596aeb 100644 --- a/src/RemSound.App/ProfilePasswordDialog.cs +++ b/src/RemSound.App/ProfilePasswordDialog.cs @@ -13,7 +13,7 @@ namespace RemSound.App; /// internal static class ProfilePasswordDialog { - public static string? Show(IWin32Window owner, string profileTitle, string currentPassword) + public static string? Show(IWin32Window owner, string profileTitle, string currentPassword, bool requireNonEmpty = false) { using var dialog = new Form { @@ -45,16 +45,45 @@ internal static class ProfilePasswordDialog AutoSize = true, }; - var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; + var okButton = new Button { Text = "OK", AutoSize = true }; var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + + // OK is validated by hand (no auto-close DialogResult) so we can block an empty entry when + // a password is being REQUIRED. The trigger is the OK/Enter action, not typing — and + // deliberately clearing an already-set password to blank is still allowed, because that + // path passes requireNonEmpty: false. So this only catches "was asked for a password, + // entered nothing, pressed OK", which used to silently leave audio dead. + void TryAccept() + { + if (requireNonEmpty && textBox.Text.Trim().Length == 0) + { + var page = new TaskDialogPage + { + Caption = "Password required", + Heading = "Enter a password", + Text = "A password is required before audio can flow. You and the person you're connecting to must use the same one.", + Icon = TaskDialogIcon.Warning, + Buttons = { TaskDialogButton.OK }, + DefaultButton = TaskDialogButton.OK, + AllowCancel = true, + }; + TaskDialog.ShowDialog(dialog, page); + textBox.Focus(); + textBox.SelectAll(); + return; + } + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + } + + okButton.Click += (_, _) => TryAccept(); textBox.KeyDown += (_, args) => { if (args.KeyCode == Keys.Enter) { - dialog.DialogResult = DialogResult.OK; - dialog.Close(); args.Handled = true; args.SuppressKeyPress = true; + TryAccept(); } }; diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 33d9620..0ae546b 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -24,6 +24,11 @@ internal static class Program ApplicationConfiguration.Initialize(); + // Relocate any pre-2026-06-07 config/profiles into config\ before anything reads them. + // Idempotent and best-effort; also upgrades users coming from an older build. The result + // is shown to the user once (after the single-instance guard) if files actually moved. + var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded(); + // Single-instance guard. RemSound must never run as two copies at once: with the // auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two // (then more) copies running, each playing received audio — Andre's "stacked and @@ -74,6 +79,14 @@ internal static class Program // is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free. HelpLauncher.Install(); + // One-time "your settings moved" notice — only the launch that actually relocated files + // shows it (idempotent migration ⇒ MovedAnything is false on every later launch). Shown + // here, after the guard and before the profile picker, so the user reads it once up front. + if (layoutMigration.MovedAnything) + { + ShowLayoutMigrationNotice(layoutMigration); + } + // Outer loop: lets ProfileManagementDialog change the profiles folder mid-session. // When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build // a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile @@ -229,4 +242,30 @@ internal static class Program if (!reloadFromScratch) return; } } + + /// One-time, Windows-native notice telling the user their config/profiles were moved + /// into the new config\ folder. Only called when a real migration happened. TaskDialog + /// (not a hand-rolled Form) so a screen reader reads the whole message automatically. + private static void ShowLayoutMigrationNotice(RemSound.Core.AppConfig.LayoutMigrationResult migration) + { + var moved = new System.Collections.Generic.List(); + if (migration.MovedGlobalConfig) moved.Add("- Your settings are now in: config\\global config.json"); + if (migration.MovedProfiles) moved.Add("- Your saved profiles are now in: config\\profiles\\"); + + var page = new TaskDialogPage + { + Caption = "RemSound settings location", + Heading = "Your settings now live in a \"config\" folder", + Text = "To keep the RemSound folder tidy, this update moved your existing settings into a new " + + "\"config\" folder inside RemSound:\n\n" + + string.Join("\n", moved) + + "\n\nNothing was lost and RemSound works exactly as before. You will only see this message once.", + Icon = TaskDialogIcon.Information, + Buttons = { TaskDialogButton.OK }, + DefaultButton = TaskDialogButton.OK, + AllowCancel = true, + }; + try { TaskDialog.ShowDialog(page); } + catch { /* a notice must never stop RemSound from starting */ } + } } diff --git a/src/RemSound.App/QuickProfileSwitchDialog.cs b/src/RemSound.App/QuickProfileSwitchDialog.cs new file mode 100644 index 0000000..9a20225 --- /dev/null +++ b/src/RemSound.App/QuickProfileSwitchDialog.cs @@ -0,0 +1,151 @@ +using System.Runtime.InteropServices; + +namespace RemSound.App; + +/// +/// Small, NVDA-friendly pop-up that lists every profile and lets the user switch to one from +/// anywhere in Windows — it's opened by the global "Quick profile switch" hotkey. The currently +/// loaded profile is marked "(current)" and pre-selected, so the screen reader announces where you +/// are the moment it opens. Arrow up/down to browse, Enter or click to switch, Escape to close +/// without changing anything. The window forces itself to the foreground so the screen reader lands +/// in it even when RemSound is sitting in the tray or another app has focus. +/// +internal sealed class QuickProfileSwitchDialog +{ + public sealed record ProfileEntry(string Title, string Path, bool IsCurrent); + + /// Shows the popup. Returns the chosen profile's path, or null if the user cancelled + /// (Escape / Close) or there were no profiles to show. + public static string? Show(IReadOnlyList profiles) + { + if (profiles.Count == 0) return null; + + using var dialog = new Form + { + Text = "Quick profile switch", + AccessibleName = "Quick profile switch", + StartPosition = FormStartPosition.CenterScreen, + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false, + ShowInTaskbar = false, + TopMost = true, + KeyPreview = true, + ClientSize = new Size(440, 340), + }; + + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(10), + ColumnCount = 1, + RowCount = 3, // 0 intro, 1 list, 2 close button + }; + root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var intro = new Label + { + Text = "Arrow up and down to a profile, then press Enter to switch to it. Escape closes without changing.", + AutoSize = true, + MaximumSize = new Size(410, 0), + Anchor = AnchorStyles.Left, + }; + root.Controls.Add(intro, 0, 0); + + var list = new ListBox + { + Dock = DockStyle.Fill, + IntegralHeight = false, + // NVDA reads this on first focus, then each item's text as the selection moves. + AccessibleName = "Profiles", + TabIndex = 0, + }; + var currentIndex = 0; + for (var i = 0; i < profiles.Count; i++) + { + var p = profiles[i]; + list.Items.Add(p.IsCurrent ? $"{p.Title} (current)" : p.Title); + if (p.IsCurrent) currentIndex = i; + } + list.SelectedIndex = currentIndex; + root.Controls.Add(list, 0, 1); + + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + Padding = new Padding(0, 8, 0, 0), + }; + var closeButton = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 }; + buttons.Controls.Add(closeButton); + root.Controls.Add(buttons, 0, 2); + + dialog.Controls.Add(root); + dialog.CancelButton = closeButton; + + string? chosenPath = null; + void Commit() + { + var idx = list.SelectedIndex; + if (idx < 0 || idx >= profiles.Count) return; + chosenPath = profiles[idx].Path; + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + } + + // Enter or a click/double-click on a row switches to it (Ed: "clicking on a profile + // switches it"). MouseClick fires after the click has moved the selection, so the clicked + // row is the selected one. + list.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Enter) + { + Commit(); + e.Handled = true; + e.SuppressKeyPress = true; + } + }; + list.DoubleClick += (_, _) => Commit(); + list.MouseClick += (_, _) => Commit(); + + dialog.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + { + dialog.DialogResult = DialogResult.Cancel; + dialog.Close(); + e.Handled = true; + e.SuppressKeyPress = true; + } + }; + + dialog.Shown += (_, _) => + { + BringToForeground(dialog); + list.Focus(); + }; + + var result = dialog.ShowDialog(); + return result == DialogResult.OK ? chosenPath : null; + } + + private static void BringToForeground(Form form) + { + try + { + form.Activate(); + // The hotkey press is recent user input, so the foreground lock lets us call this + // (same handshake the tray "restore" uses). + SetForegroundWindow(form.Handle); + } + catch { /* foreground-lock race — best effort, the window is still TopMost */ } + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(IntPtr hWnd); +} diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index b2fe9f1..51d10b9 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -14,7 +14,7 @@ tag_name on the latest GitHub release; bump it on every public release. The AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version is what the About dialog and the updater both read. --> - 3.3.0 + 3.4.0 @@ -97,6 +97,12 @@ sounds\update.wav PreserveNewest + + + sounds\profile menu open.wav + PreserveNewest +