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 0000000..7b62282 Binary files /dev/null and b/sounds/Profile menu open.wav differ 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 +