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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fc451ba3e3
commit
dd70613017
+5
-1
@@ -103,7 +103,10 @@ if (Test-Path $syncScript) {
|
|||||||
# Anything matching these must NEVER appear in a release. Folders by name; files by
|
# 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
|
# extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are
|
||||||
# legitimate app files and are deliberately NOT matched (different names).
|
# 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) {
|
function Test-Forbidden([string]$path) {
|
||||||
$p = $path -replace '\\', '/'
|
$p = $path -replace '\\', '/'
|
||||||
foreach ($f in $forbiddenFolders) {
|
foreach ($f in $forbiddenFolders) {
|
||||||
@@ -111,6 +114,7 @@ function Test-Forbidden([string]$path) {
|
|||||||
}
|
}
|
||||||
if ($p -match '\.log$') { return $true }
|
if ($p -match '\.log$') { return $true }
|
||||||
if ($p -match '(^|/)remsound\.config\.json$') { return $true }
|
if ($p -match '(^|/)remsound\.config\.json$') { return $true }
|
||||||
|
if ($p -match '(^|/)global config\.json$') { return $true }
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ ul, ol { padding-left: 1.4em; }
|
|||||||
|
|
||||||
<p>There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.</p>
|
<p>There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>RemSound on Android (receiver):</strong> there is a companion app that lets a phone or tablet <em>receive</em> 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 <strong>app-release.apk</strong>): <a href="https://github.com/aryanchoudharypro/RemSoundAndroid/releases">RemSound Android — Releases</a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 id="quick-start">2. Quick start</h2>
|
<h2 id="quick-start">2. Quick start</h2>
|
||||||
|
|
||||||
<p>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).</p>
|
<p>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).</p>
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
using NAudio.CoreAudioApi;
|
||||||
|
using NAudio.CoreAudioApi.Interfaces;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="MainForm"/>) 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 <em>set</em>, so reacting to them would defeat the
|
||||||
|
/// whole point of going event-driven.
|
||||||
|
/// </summary>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>handles=</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
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<ushort, string> TypeNameByIndex = new();
|
||||||
|
private static readonly int OwnPid = Process.GetCurrentProcess().Id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns "Event=120345 Section=15234 File=210 … total=N" — the <paramref name="topN"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Primary path: query only THIS process's handle table. Returns the formatted summary, or null
|
||||||
|
/// on any failure (with <paramref name="status"/> set to the NTSTATUS for diagnostics).
|
||||||
|
/// </summary>
|
||||||
|
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<ushort, int>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fallback path: walk the whole system handle table and filter to our PID. Returns null on any
|
||||||
|
/// failure (with <paramref name="status"/> 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 <see cref="TryOwnProcess"/> is tried first.
|
||||||
|
/// </summary>
|
||||||
|
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<ushort, int>();
|
||||||
|
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<ushort, int> counts, int topN)
|
||||||
|
{
|
||||||
|
var ordered = new List<KeyValuePair<ushort, int>>(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);
|
||||||
|
}
|
||||||
+571
-53
@@ -97,6 +97,9 @@ public sealed class MainForm : Form
|
|||||||
private readonly Label asioSendDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO send channel selected." };
|
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 CheckedListBox asioReceiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
||||||
private readonly Label asioReceiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO receive channel selected." };
|
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
|
// 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".
|
// unit when the user toggles "Enable ASIO".
|
||||||
private MnemonicLabel? asioSendDevicesLabel;
|
private MnemonicLabel? asioSendDevicesLabel;
|
||||||
@@ -329,6 +332,7 @@ public sealed class MainForm : Form
|
|||||||
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
||||||
private CuePlayer? saveSound;
|
private CuePlayer? saveSound;
|
||||||
private CuePlayer? profileSwitchSound;
|
private CuePlayer? profileSwitchSound;
|
||||||
|
private CuePlayer? profileMenuOpenSound;
|
||||||
private CuePlayer? updateSound;
|
private CuePlayer? updateSound;
|
||||||
// Labels for the three send/receive device lists, captured at layout time so they can be
|
// 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
|
// 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<CheckedListBox, int> lastFocusedListIndices = [];
|
private readonly Dictionary<CheckedListBox, int> lastFocusedListIndices = [];
|
||||||
|
|
||||||
private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 };
|
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
|
// Device-list refresh. As of v3.4 this is EVENT-DRIVEN, not polled: an
|
||||||
// within a second of plugging. Cost per tick in the no-change case is just two COM
|
// AudioDeviceChangeNotifier registers for Windows audio endpoint-change notifications and
|
||||||
// enumerations + a string compare — a few ms on the UI thread, no impact on the audio
|
// pokes this timer when the device set actually changes (USB hot-plug / unplug, default-device
|
||||||
// threads (which run on separate MMCSS-boosted threads). The listbox itself is only
|
// change). The timer then acts as a one-shot DEBOUNCE — a burst of add/remove/default-changed
|
||||||
// rebuilt when the (id, name) signature actually changes, so NVDA isn't pestered on every
|
// callbacks collapses into a single RefreshAudioDeviceLists ~750 ms after the last one, so the
|
||||||
// tick — only when a device truly came or went.
|
// listboxes (and NVDA) are only touched when something truly changed, and zero work happens
|
||||||
// 3 s interval (was 1 s pre-2026-05-23). Item 4 of RemSoundefficiency.md — when an ASIO
|
// while nothing is being plugged or unplugged. If notification registration ever fails we fall
|
||||||
// driver is configured, each tick calls AsioDeviceProbe.ProbeDriverInfo which briefly
|
// back to the pre-v3.4 periodic poll (deviceRefreshOneShot = false, 3 s). This replaces the old
|
||||||
// opens the driver to enumerate channel names. That's measurable CPU (~1.6 % of one core
|
// 3-second poll that re-enumerated every WASAPI device — and re-opened the ASIO driver — on
|
||||||
// in the test we ran) for a check that only matters when a USB audio device is hot-
|
// every tick regardless of whether anything had changed.
|
||||||
// plugged. 3 s is the value the existing RefreshAudioDeviceLists docstring already
|
private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 750 };
|
||||||
// claimed; the actual timer just hadn't been bumped to match. Hot-plug latency goes from
|
// True when deviceRefreshTimer is a one-shot debounce (notification-driven — the normal case);
|
||||||
// up-to-1 s to up-to-3 s, which is fine for the device-list-refresh use case (nobody
|
// false when it's the periodic-poll fallback. Controls whether the Tick handler stops the timer.
|
||||||
// pulls a device and stares at the menu in the next second waiting for it to drop off).
|
private bool deviceRefreshOneShot = true;
|
||||||
private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 3000 };
|
// 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
|
// 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
|
// 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),
|
// (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 receiveOutputDevicesSignature = string.Empty;
|
||||||
private string asioSendDevicesSignature = string.Empty;
|
private string asioSendDevicesSignature = string.Empty;
|
||||||
private string asioReceiveOutputDevicesSignature = 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<string> 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<string> realtekAsioDriverNames = new();
|
||||||
|
private ToolStripMenuItem? realtekAsioToggleItem;
|
||||||
// True while we're rebuilding a CheckedListBox programmatically — suppresses the per-item
|
// 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.
|
// ItemCheck handler so re-adding pre-checked items doesn't fire ApplyAudioRuntime per item.
|
||||||
private bool suppressDeviceCheckChange;
|
private bool suppressDeviceCheckChange;
|
||||||
@@ -394,6 +411,21 @@ public sealed class MainForm : Form
|
|||||||
private DateTime connectedSinceUtc = DateTime.MinValue;
|
private DateTime connectedSinceUtc = DateTime.MinValue;
|
||||||
private DateTime lastSnapshotUtc = DateTime.MinValue;
|
private DateTime lastSnapshotUtc = DateTime.MinValue;
|
||||||
private DateTime lastCaptureZeroLogUtc = 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 firstCaptureCallbackLogged;
|
||||||
private bool firstSenderPacketLogged;
|
private bool firstSenderPacketLogged;
|
||||||
private bool firstReceiverPacketLogged;
|
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
|
// 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.
|
// (~5 minutes). See the inline comment in SnapshotLogIfDue for the full rationale.
|
||||||
private int nativeReaperTickCount;
|
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
|
// 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;
|
// 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.
|
// fixed by Windows. Hold the hotkey for bigger jumps.
|
||||||
() => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0),
|
() => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0),
|
||||||
() => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 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.,
|
// 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
|
// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and
|
||||||
// "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409:
|
// "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
|
// 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
|
// 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.)
|
// 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(
|
trayController = new MainFormTrayController(
|
||||||
this,
|
this,
|
||||||
// getSending / toggleSending — the tray's "Enable sending" checkable item reads
|
// 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).
|
// (now File menu items in BuildFileMenu).
|
||||||
asioDriverBox.AccessibleName = "ASIO driver (Alt+D)";
|
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
|
// 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
|
// 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
|
// 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.
|
// driver picker is hidden entirely in BuildAudioIOTab and the form runs WASAPI-only.
|
||||||
var asioDriverNames = AsioDeviceProbe.EnumerateDriverNames();
|
var asioDriverNames = AsioDeviceProbe.EnumerateDriverNames();
|
||||||
hasAnyAsioDriverInstalled = asioDriverNames.Count > 0;
|
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);
|
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
|
// 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
|
// "(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);
|
settings.SaveAsioDriverName(newDriver);
|
||||||
var driverActuallyChanged = !string.Equals(previousDriver, newDriver, StringComparison.OrdinalIgnoreCase);
|
var driverActuallyChanged = !string.Equals(previousDriver, newDriver, StringComparison.OrdinalIgnoreCase);
|
||||||
if (driverActuallyChanged) MarkProfileDirty();
|
if (driverActuallyChanged) MarkProfileDirty();
|
||||||
|
if (driverActuallyChanged) ClearAsioProbeCache();
|
||||||
|
|
||||||
// When the driver actually changes (including switching to/from "(none)"), clear
|
// 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
|
// 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.RecordStop, "record stop.wav", out recordStopSound);
|
||||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||||
|
TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound);
|
||||||
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
||||||
|
|
||||||
LoadAudioDevices();
|
LoadAudioDevices();
|
||||||
@@ -1038,7 +1102,11 @@ public sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- Hot-swap device watcher ---
|
// --- Hot-swap device watcher ---
|
||||||
deviceRefreshTimer.Tick += (_, _) => RefreshAudioDeviceLists();
|
deviceRefreshTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
if (deviceRefreshOneShot) deviceRefreshTimer.Stop(); // debounce: one refresh per change burst
|
||||||
|
RefreshAudioDeviceLists();
|
||||||
|
};
|
||||||
|
|
||||||
BuildLayout();
|
BuildLayout();
|
||||||
LoadRememberedPeersFromSettings();
|
LoadRememberedPeersFromSettings();
|
||||||
@@ -1047,6 +1115,9 @@ public sealed class MainForm : Form
|
|||||||
// Tailscale/VPN where broadcast doesn't traverse).
|
// Tailscale/VPN where broadcast doesn't traverse).
|
||||||
PushDiscoveryUnicastHints();
|
PushDiscoveryUnicastHints();
|
||||||
hotkeyController.Initialize(this);
|
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
|
// 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
|
// audio devices often come back wedged). The handler routes back through
|
||||||
@@ -1066,6 +1137,7 @@ public sealed class MainForm : Form
|
|||||||
continuousTuneTimer.Stop();
|
continuousTuneTimer.Stop();
|
||||||
updateCheckTimer.Stop();
|
updateCheckTimer.Stop();
|
||||||
asioDriverChangeDebounce.Stop();
|
asioDriverChangeDebounce.Stop();
|
||||||
|
try { deviceChangeNotifier?.Dispose(); } catch { }
|
||||||
try { powerResumeHandler?.Dispose(); } catch { }
|
try { powerResumeHandler?.Dispose(); } catch { }
|
||||||
try { routerPortMapper?.Dispose(); } catch { }
|
try { routerPortMapper?.Dispose(); } catch { }
|
||||||
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean
|
// 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
|
// 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.
|
// installed, so they see what's new. BeginInvoke so it opens after Shown completes.
|
||||||
BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate));
|
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();
|
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();
|
deviceRefreshTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,15 +1568,28 @@ public sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
profilePasswordsItem.Click += (_, _) => OpenProfilePasswordManager();
|
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<ToolStripItem>
|
||||||
{
|
{
|
||||||
recordingSettingsItem,
|
recordingSettingsItem,
|
||||||
keyboardItem,
|
keyboardItem,
|
||||||
startupBehaviourItem,
|
startupBehaviourItem,
|
||||||
profilePasswordsItem,
|
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
|
// 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
|
// 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();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<QuickProfileSwitchDialog.ProfileEntry>(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
|
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
|
||||||
/// settings dialog, open the configured folder, and change the configured folder.
|
/// 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
|
/// 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,
|
Dock = DockStyle.Fill,
|
||||||
Padding = new Padding(12),
|
Padding = new Padding(12),
|
||||||
ColumnCount = 2,
|
ColumnCount = 2,
|
||||||
RowCount = 10,
|
RowCount = 11,
|
||||||
AutoScroll = true,
|
AutoScroll = true,
|
||||||
};
|
};
|
||||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
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
|
// 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
|
// machines with no ASIO drivers installed the driver picker is hidden entirely (there
|
||||||
// is nothing to switch to) and the form runs WASAPI-only.
|
// 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)
|
if (hasAnyAsioDriverInstalled)
|
||||||
{
|
{
|
||||||
asioDriverLabel = new MnemonicLabel { Text = "ASIO driver (Alt+&D)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = asioDriverBox };
|
asioDriverLabel = new MnemonicLabel { Text = "ASIO driver (Alt+&D)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = asioDriverBox };
|
||||||
asioDriverLabel.Click += (_, _) => asioDriverBox.Focus();
|
asioDriverLabel.Click += (_, _) => asioDriverBox.Focus();
|
||||||
panel.Controls.Add(asioDriverLabel, 0, 0);
|
panel.Controls.Add(asioDriverLabel, 0, 1);
|
||||||
panel.Controls.Add(asioDriverBox, 1, 0);
|
panel.Controls.Add(asioDriverBox, 1, 1);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2441,16 +2598,16 @@ public sealed class MainForm : Form
|
|||||||
// suppresses them; the FlowLayoutPanel wrapper restores the announcement chain).
|
// suppresses them; the FlowLayoutPanel wrapper restores the announcement chain).
|
||||||
var receiveCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
|
var receiveCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
|
||||||
receiveCheckboxPanel.Controls.Add(receiveAudioCheckbox);
|
receiveCheckboxPanel.Controls.Add(receiveAudioCheckbox);
|
||||||
panel.Controls.Add(receiveCheckboxPanel, 1, 1);
|
panel.Controls.Add(receiveCheckboxPanel, 1, 2);
|
||||||
receiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 2, "WASAPI outputs for received sound (Alt+&3)", receiveOutputDevicesList, receiveOutputDevicesStatusLabel, FocusListControl);
|
receiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 3, "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);
|
asioReceiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 4, "ASIO outputs for received sound (Alt+&1)", asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, FocusListControl);
|
||||||
FormLayoutRows.AddRow(panel, 4, "Set volume for all received audio (Alt+&V)", volumeBar, FocusControl);
|
FormLayoutRows.AddRow(panel, 5, "Set volume for all received audio (Alt+&V)", volumeBar, FocusControl);
|
||||||
var sendCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
|
var sendCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
|
||||||
sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox);
|
sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox);
|
||||||
panel.Controls.Add(sendCheckboxPanel, 1, 5);
|
panel.Controls.Add(sendCheckboxPanel, 1, 6);
|
||||||
sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 6, "WASAPI outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl);
|
sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl);
|
||||||
sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl);
|
sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "WASAPI inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl);
|
||||||
asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "ASIO inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl);
|
asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 9, "ASIO inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl);
|
||||||
|
|
||||||
audioIOTabPage.Controls.Add(panel);
|
audioIOTabPage.Controls.Add(panel);
|
||||||
}
|
}
|
||||||
@@ -2887,7 +3044,8 @@ public sealed class MainForm : Form
|
|||||||
sb.AppendLine("Uptime: 0 seconds.");
|
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();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3250,12 +3408,19 @@ public sealed class MainForm : Form
|
|||||||
{
|
{
|
||||||
if (!connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
var endpoints = SelectedSendEndpoints();
|
var endpoints = SelectedSendEndpoints().ToArray();
|
||||||
sender.SetReceivers(endpoints);
|
allSendEndpoints = endpoints;
|
||||||
// Single-port heartbeat: tracked peers' audio endpoints ARE the heartbeat target.
|
// Single-port heartbeat: tracked peers' audio endpoints ARE the heartbeat target.
|
||||||
// HeartbeatService sends via sender.SendVia (wired in Connect) so heartbeat shares
|
// 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);
|
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
|
// 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.
|
// audio is encrypted/decrypted with it. Cheap when the password hasn't changed.
|
||||||
@@ -3327,6 +3492,62 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-arm the audio sender's destination list from the full selected-peer set
|
||||||
|
/// (<see cref="allSendEndpoints"/>), dropping any endpoint the heartbeat reports as
|
||||||
|
/// continuously unreachable for longer than <see cref="AudioPruneUnreachableAfter"/>. 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 <see cref="SnapshotLogIfDue"/>; only touches the sender when the armed set
|
||||||
|
/// actually changes, so it's cheap to run per tick.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshAudioReceivers()
|
||||||
|
{
|
||||||
|
if (!connected) return;
|
||||||
|
var all = allSendEndpoints;
|
||||||
|
|
||||||
|
// Endpoints the heartbeat currently considers long-dead, keyed by "ip:port".
|
||||||
|
HashSet<string>? 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<string>(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() =>
|
private bool HasCheckedSendDevice() =>
|
||||||
sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.DeviceId is not null)
|
sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.DeviceId is not null)
|
||||||
|| sendInputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.DeviceId is not null)
|
|| sendInputDevicesList.CheckedItems.OfType<AudioDeviceChoice>().Any(c => c.DeviceId is not null)
|
||||||
@@ -3388,13 +3609,34 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called (on a COM thread) by <see cref="deviceChangeNotifier"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Re-enumerates active audio endpoints and rebuilds any list whose set of devices changed.
|
/// Re-enumerates active audio endpoints and rebuilds any list whose set of devices changed.
|
||||||
/// Driven by <see cref="deviceRefreshTimer"/> at 3 s intervals so USB hot-plug / unplug
|
/// As of v3.4 this is driven by <see cref="deviceChangeNotifier"/> (Windows endpoint-change
|
||||||
/// shows up without an app restart. Each list is rebuilt only when its (id, name) signature
|
/// notifications), debounced through <see cref="deviceRefreshTimer"/>, rather than polled — so
|
||||||
/// changes — the no-op fast path leaves NVDA's focus and the listbox state untouched.
|
/// it runs only when the device set actually changes. Each list is rebuilt only when its
|
||||||
/// Check state is preserved by DeviceId across rebuilds; if a checked device disappeared,
|
/// (id, name) signature changes — the no-op fast path leaves NVDA's focus and the listbox state
|
||||||
/// the relevant runtime <c>Apply*</c> is called so the engine sees the change.
|
/// untouched. Check state is preserved by DeviceId across rebuilds; if a checked device
|
||||||
|
/// disappeared, the relevant runtime <c>Apply*</c> is called so the engine sees the change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshAudioDeviceLists()
|
private void RefreshAudioDeviceLists()
|
||||||
{
|
{
|
||||||
@@ -3425,16 +3667,16 @@ public sealed class MainForm : Form
|
|||||||
wasapiInputs = AudioDeviceCatalog.LoadInputs();
|
wasapiInputs = AudioDeviceCatalog.LoadInputs();
|
||||||
|
|
||||||
var currentMode = settings.LoadAudioMode();
|
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);
|
var info = GetCachedAsioProbeInfo(asioDriver, out var probeFailed);
|
||||||
if (info.InputChannelCount >= 0 && info.OutputChannelCount >= 0)
|
if (info is not null)
|
||||||
{
|
{
|
||||||
LogAsioChannelNamesIfChanged(asioDriver, info);
|
LogAsioChannelNamesIfChanged(asioDriver, info);
|
||||||
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
|
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
|
||||||
asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames);
|
asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames);
|
||||||
}
|
}
|
||||||
else
|
else if (probeFailed)
|
||||||
{
|
{
|
||||||
// Probe came back -1/-1 — driver is configured but can't enumerate right
|
// 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
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds <see cref="AudioDeviceChoice"/> entries for ASIO channel pairs (stereo) using the
|
/// Builds <see cref="AudioDeviceChoice"/> entries for ASIO channel pairs (stereo) using the
|
||||||
/// driver's own per-channel names, prefixed with the driver name. 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");
|
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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'");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called by <see cref="PowerResumeHandler"/> on a background thread after the system has
|
/// Called by <see cref="PowerResumeHandler"/> on a background thread after the system has
|
||||||
/// woken from sleep / hibernate (plus a short USB-settle delay). Marshals onto the UI
|
/// 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
|
// re-pushes the audio-runtime + receive-device configuration. The receiver's
|
||||||
// SetAudioMode call inside it does an unconditional render-backend rebuild; the
|
// SetAudioMode call inside it does an unconditional render-backend rebuild; the
|
||||||
// sender's, post-bounce, recreates its persistent ASIO from scratch.
|
// sender's, post-bounce, recreates its persistent ASIO from scratch.
|
||||||
|
ClearAsioProbeCache();
|
||||||
ApplyAsioMode();
|
ApplyAsioMode();
|
||||||
logFile.Event("power: audio backend re-initialised");
|
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
|
// serialised on the network-thread lock inside the receiver, so doing it from the UI
|
||||||
// tick is safe.
|
// tick is safe.
|
||||||
receiver.PruneIdleSessions();
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// no-ops when logFile.Enabled is false, so we don't need to wrap individual writes.
|
||||||
if (!DiagnosticsGate.Enabled) return;
|
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
|
// 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
|
// 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
|
// 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,
|
connected: connected,
|
||||||
sendRunning: sender.IsRunning,
|
sendRunning: sender.IsRunning,
|
||||||
receiveRunning: receiver.IsRunning,
|
receiveRunning: receiver.IsRunning,
|
||||||
codec: sender.Codec.ToString(),
|
codec: SnapshotCodecLabel(),
|
||||||
maxLatencyMs: primaryMaxMs,
|
maxLatencyMs: primaryMaxMs,
|
||||||
targetLatencyMs: primaryTargetMs,
|
targetLatencyMs: primaryTargetMs,
|
||||||
bufferMs: receiver.CurrentBufferMs,
|
bufferMs: receiver.CurrentBufferMs,
|
||||||
@@ -4765,6 +5228,7 @@ public sealed class MainForm : Form
|
|||||||
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} " +
|
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} " +
|
||||||
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
|
$"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} " +
|
$"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} " +
|
$"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} " +
|
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||||
@@ -4838,6 +5302,7 @@ public sealed class MainForm : Form
|
|||||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||||
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
|
$"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} " +
|
$"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} " +
|
$"captureMs={captureMs:0.0} sendMs={sendMs:0.0} " +
|
||||||
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
|
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
|
||||||
}
|
}
|
||||||
@@ -5149,11 +5614,12 @@ public sealed class MainForm : Form
|
|||||||
catch { /* baseline failure shouldn't block save */ }
|
catch { /* baseline failure shouldn't block save */ }
|
||||||
unsavedChanges = false;
|
unsavedChanges = false;
|
||||||
// A freshly created profile has no password yet, and encryption is always on — so
|
// 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
|
// ask for one now and write it straight into the file we just saved. OK requires a
|
||||||
// or Cancel) leaves it passwordless; the streaming gate will ask again when needed.
|
// non-empty password (requireNonEmpty); Cancel still leaves it passwordless and the
|
||||||
|
// streaming gate will ask again when needed.
|
||||||
if (string.IsNullOrEmpty(currentProfilePassword))
|
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||||
{
|
{
|
||||||
var pw = ProfilePasswordDialog.Show(this, title, "");
|
var pw = ProfilePasswordDialog.Show(this, title, "", requireNonEmpty: true);
|
||||||
if (!string.IsNullOrEmpty(pw))
|
if (!string.IsNullOrEmpty(pw))
|
||||||
{
|
{
|
||||||
currentProfilePassword = pw;
|
currentProfilePassword = pw;
|
||||||
@@ -5440,7 +5906,7 @@ public sealed class MainForm : Form
|
|||||||
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
|
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
|
||||||
|
|
||||||
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
|
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))
|
if (string.IsNullOrEmpty(entered))
|
||||||
{
|
{
|
||||||
// No password → can't stream. Put the box back without re-firing this gate.
|
// 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 RecordStop = "record-stop";
|
||||||
public const string Save = "save";
|
public const string Save = "save";
|
||||||
public const string ProfileSwitch = "profile-switch";
|
public const string ProfileSwitch = "profile-switch";
|
||||||
|
public const string ProfileMenuOpen = "profile-menu-open";
|
||||||
public const string Update = "update";
|
public const string Update = "update";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5823,6 +6290,7 @@ public sealed class MainForm : Form
|
|||||||
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
|
||||||
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
|
||||||
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
|
||||||
|
TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound);
|
||||||
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5907,6 +6375,35 @@ public sealed class MainForm : Form
|
|||||||
private static string CueOutcome(bool enabled, CuePlayer? sound) =>
|
private static string CueOutcome(bool enabled, CuePlayer? sound) =>
|
||||||
!enabled ? "muted in settings" : sound is null ? "enabled but sound not loaded" : "played";
|
!enabled ? "muted in settings" : sound is null ? "enabled but sound not loaded" : "played";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string>(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)
|
private void NudgeVolume(int deltaPercent)
|
||||||
{
|
{
|
||||||
BeginInvoke(() =>
|
BeginInvoke(() =>
|
||||||
@@ -6105,6 +6602,27 @@ public sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Snap an integer to the nearest 5. Used to keep RTT chatter in the per-peer
|
/// <summary>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.</summary>
|
/// listbox line low — single-millisecond drift no longer re-announces under NVDA.</summary>
|
||||||
private static int RoundToFive(int value) => ((value + 2) / 5) * 5;
|
private static int RoundToFive(int value) => ((value + 2) / 5) * 5;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
private readonly Action sendSystemVolumeUp;
|
private readonly Action sendSystemVolumeUp;
|
||||||
private readonly Action sendSystemVolumeDown;
|
private readonly Action sendSystemVolumeDown;
|
||||||
private readonly Action sendSystemMuteToggle;
|
private readonly Action sendSystemMuteToggle;
|
||||||
|
private readonly Action quickProfileSwitch;
|
||||||
private Form? owner;
|
private Form? owner;
|
||||||
private HotkeyInfo sendMuteHotkey;
|
private HotkeyInfo sendMuteHotkey;
|
||||||
private HotkeyInfo receiveMuteHotkey;
|
private HotkeyInfo receiveMuteHotkey;
|
||||||
@@ -43,6 +44,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
private HotkeyInfo systemVolumeUpHotkey;
|
private HotkeyInfo systemVolumeUpHotkey;
|
||||||
private HotkeyInfo systemVolumeDownHotkey;
|
private HotkeyInfo systemVolumeDownHotkey;
|
||||||
private HotkeyInfo systemMuteToggleHotkey;
|
private HotkeyInfo systemMuteToggleHotkey;
|
||||||
|
private HotkeyInfo quickProfileSwitchHotkey;
|
||||||
private GlobalHotkey? sendMuteGlobalHotkey;
|
private GlobalHotkey? sendMuteGlobalHotkey;
|
||||||
private GlobalHotkey? receiveMuteGlobalHotkey;
|
private GlobalHotkey? receiveMuteGlobalHotkey;
|
||||||
private GlobalHotkey? trayGlobalHotkey;
|
private GlobalHotkey? trayGlobalHotkey;
|
||||||
@@ -55,6 +57,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
private GlobalHotkey? systemVolumeUpGlobalHotkey;
|
private GlobalHotkey? systemVolumeUpGlobalHotkey;
|
||||||
private GlobalHotkey? systemVolumeDownGlobalHotkey;
|
private GlobalHotkey? systemVolumeDownGlobalHotkey;
|
||||||
private GlobalHotkey? systemMuteToggleGlobalHotkey;
|
private GlobalHotkey? systemMuteToggleGlobalHotkey;
|
||||||
|
private GlobalHotkey? quickProfileSwitchGlobalHotkey;
|
||||||
|
|
||||||
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
|
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
|
||||||
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
|
/// 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 sendRemoteMuteToggle,
|
||||||
Action sendSystemVolumeUp,
|
Action sendSystemVolumeUp,
|
||||||
Action sendSystemVolumeDown,
|
Action sendSystemVolumeDown,
|
||||||
Action sendSystemMuteToggle)
|
Action sendSystemMuteToggle,
|
||||||
|
Action quickProfileSwitch)
|
||||||
{
|
{
|
||||||
this.settingsStore = settingsStore;
|
this.settingsStore = settingsStore;
|
||||||
this.toggleSend = toggleSend;
|
this.toggleSend = toggleSend;
|
||||||
@@ -98,6 +102,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
this.sendSystemVolumeUp = sendSystemVolumeUp;
|
this.sendSystemVolumeUp = sendSystemVolumeUp;
|
||||||
this.sendSystemVolumeDown = sendSystemVolumeDown;
|
this.sendSystemVolumeDown = sendSystemVolumeDown;
|
||||||
this.sendSystemMuteToggle = sendSystemMuteToggle;
|
this.sendSystemMuteToggle = sendSystemMuteToggle;
|
||||||
|
this.quickProfileSwitch = quickProfileSwitch;
|
||||||
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
|
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
|
||||||
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
|
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
|
||||||
trayHotkey = settingsStore.LoadTrayHotkey();
|
trayHotkey = settingsStore.LoadTrayHotkey();
|
||||||
@@ -110,6 +115,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey();
|
systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey();
|
||||||
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
|
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
|
||||||
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
|
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
|
||||||
|
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Form ownerForm)
|
public void Initialize(Form ownerForm)
|
||||||
@@ -127,6 +133,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
|
systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||||
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||||
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
|
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||||
|
quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||||
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
|
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
|
||||||
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
|
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
|
||||||
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
|
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
|
||||||
@@ -139,6 +146,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp);
|
systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp);
|
||||||
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
|
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
|
||||||
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
|
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
|
||||||
|
quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch);
|
||||||
RegisterSendMuteHotkey();
|
RegisterSendMuteHotkey();
|
||||||
RegisterReceiveMuteHotkey();
|
RegisterReceiveMuteHotkey();
|
||||||
RegisterTrayHotkey();
|
RegisterTrayHotkey();
|
||||||
@@ -151,6 +159,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
RegisterSystemVolumeUpHotkey();
|
RegisterSystemVolumeUpHotkey();
|
||||||
RegisterSystemVolumeDownHotkey();
|
RegisterSystemVolumeDownHotkey();
|
||||||
RegisterSystemMuteToggleHotkey();
|
RegisterSystemMuteToggleHotkey();
|
||||||
|
RegisterQuickProfileSwitchHotkey();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
|
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 up to peers: {systemVolumeUpHotkey}");
|
||||||
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
|
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($"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)
|
if (prev >= 0 && prev < list.Items.Count)
|
||||||
{
|
{
|
||||||
list.SelectedIndex = prev;
|
list.SelectedIndex = prev;
|
||||||
@@ -295,6 +305,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
case 9: ChangeSystemVolumeUpHotkey(dialog); break;
|
case 9: ChangeSystemVolumeUpHotkey(dialog); break;
|
||||||
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
|
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
|
||||||
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
|
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
|
||||||
|
case 12: ChangeQuickProfileSwitchHotkey(dialog); break;
|
||||||
default: return;
|
default: return;
|
||||||
}
|
}
|
||||||
RefreshList();
|
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 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 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 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;
|
default: return;
|
||||||
}
|
}
|
||||||
RefreshList();
|
RefreshList();
|
||||||
@@ -405,6 +417,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
systemVolumeUpGlobalHotkey?.Dispose();
|
systemVolumeUpGlobalHotkey?.Dispose();
|
||||||
systemVolumeDownGlobalHotkey?.Dispose();
|
systemVolumeDownGlobalHotkey?.Dispose();
|
||||||
systemMuteToggleGlobalHotkey?.Dispose();
|
systemMuteToggleGlobalHotkey?.Dispose();
|
||||||
|
quickProfileSwitchGlobalHotkey?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
|
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
|
||||||
@@ -419,6 +432,7 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey;
|
public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey;
|
||||||
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
|
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
|
||||||
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
|
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
|
||||||
|
public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey;
|
||||||
|
|
||||||
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
|
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
|
||||||
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
|
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
|
||||||
@@ -550,6 +564,13 @@ internal sealed class MainFormHotkeyController : IDisposable
|
|||||||
settingsStore.SaveSystemMuteToggleHotkey(h);
|
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:
|
// 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
|
// * 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.
|
// 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 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 RegisterSystemVolumeDownHotkey() => RegisterIfSet(systemVolumeDownGlobalHotkey, systemVolumeDownHotkey, "send Windows global volume down", allowRepeat: true);
|
||||||
private void RegisterSystemMuteToggleHotkey() => RegisterIfSet(systemMuteToggleGlobalHotkey, systemMuteToggleHotkey, "send Windows global mute toggle");
|
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)
|
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ internal sealed class PreferencesDialog : Form
|
|||||||
s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)),
|
s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)),
|
||||||
new("Profile switched sound", MainForm.CueId.ProfileSwitch,
|
new("Profile switched sound", MainForm.CueId.ProfileSwitch,
|
||||||
s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)),
|
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,
|
new("Update sound", MainForm.CueId.Update,
|
||||||
s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)),
|
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.RecordStop => "record stop.wav",
|
||||||
MainForm.CueId.Save => "save.wav",
|
MainForm.CueId.Save => "save.wav",
|
||||||
MainForm.CueId.ProfileSwitch => "profile.wav",
|
MainForm.CueId.ProfileSwitch => "profile.wav",
|
||||||
|
MainForm.CueId.ProfileMenuOpen => "profile menu open.wav",
|
||||||
MainForm.CueId.Update => "update.wav",
|
MainForm.CueId.Update => "update.wav",
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,12 +44,35 @@ internal sealed class ProcessSelfMeter
|
|||||||
/// a leak somewhere in the hot path.</param>
|
/// a leak somewhere in the hot path.</param>
|
||||||
/// <param name="ElapsedMs">Wall-clock milliseconds since the previous sample, so the
|
/// <param name="ElapsedMs">Wall-clock milliseconds since the previous sample, so the
|
||||||
/// caller can sanity-check the delta calculation. Roughly 1000 in steady state.</param>
|
/// caller can sanity-check the delta calculation. Roughly 1000 in steady state.</param>
|
||||||
|
/// <param name="PrivateBytesMb">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.</param>
|
||||||
|
/// <param name="GcHeapMb">Managed heap size the GC reports right now
|
||||||
|
/// (GCMemoryInfo.HeapSizeBytes), including free gaps. Climbing here = a managed leak.</param>
|
||||||
|
/// <param name="GcFragmentedMb">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.</param>
|
||||||
|
/// <param name="GcCommittedMb">Total bytes the GC has committed from the OS
|
||||||
|
/// (GCMemoryInfo.TotalCommittedBytes). The managed share of the working set.</param>
|
||||||
|
/// <param name="HandleCount">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.</param>
|
||||||
|
/// <param name="ThreadCount">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.</param>
|
||||||
public readonly record struct Snapshot(
|
public readonly record struct Snapshot(
|
||||||
double CpuPercentOneCore,
|
double CpuPercentOneCore,
|
||||||
double ManagedHeapMb,
|
double ManagedHeapMb,
|
||||||
double WorkingSetMb,
|
double WorkingSetMb,
|
||||||
double AllocatedKbPerSecond,
|
double AllocatedKbPerSecond,
|
||||||
double ElapsedMs);
|
double ElapsedMs,
|
||||||
|
double PrivateBytesMb,
|
||||||
|
double GcHeapMb,
|
||||||
|
double GcFragmentedMb,
|
||||||
|
double GcCommittedMb,
|
||||||
|
int HandleCount,
|
||||||
|
int ThreadCount);
|
||||||
|
|
||||||
public Snapshot Take()
|
public Snapshot Take()
|
||||||
{
|
{
|
||||||
@@ -70,6 +93,21 @@ internal sealed class ProcessSelfMeter
|
|||||||
// size of the heap as the GC knows it.
|
// size of the heap as the GC knows it.
|
||||||
var managedHeapBytes = GC.GetTotalMemory(false);
|
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 cpuPercent = 0;
|
||||||
double allocKbps = 0;
|
double allocKbps = 0;
|
||||||
double elapsedMs = 0;
|
double elapsedMs = 0;
|
||||||
@@ -94,6 +132,12 @@ internal sealed class ProcessSelfMeter
|
|||||||
ManagedHeapMb: managedHeapBytes / (1024.0 * 1024.0),
|
ManagedHeapMb: managedHeapBytes / (1024.0 * 1024.0),
|
||||||
WorkingSetMb: workingSet / (1024.0 * 1024.0),
|
WorkingSetMb: workingSet / (1024.0 * 1024.0),
|
||||||
AllocatedKbPerSecond: allocKbps,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace RemSound.App;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ProfilePasswordDialog
|
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
|
using var dialog = new Form
|
||||||
{
|
{
|
||||||
@@ -45,16 +45,45 @@ internal static class ProfilePasswordDialog
|
|||||||
AutoSize = true,
|
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 };
|
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) =>
|
textBox.KeyDown += (_, args) =>
|
||||||
{
|
{
|
||||||
if (args.KeyCode == Keys.Enter)
|
if (args.KeyCode == Keys.Enter)
|
||||||
{
|
{
|
||||||
dialog.DialogResult = DialogResult.OK;
|
|
||||||
dialog.Close();
|
|
||||||
args.Handled = true;
|
args.Handled = true;
|
||||||
args.SuppressKeyPress = true;
|
args.SuppressKeyPress = true;
|
||||||
|
TryAccept();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ internal static class Program
|
|||||||
|
|
||||||
ApplicationConfiguration.Initialize();
|
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
|
// 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
|
// 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
|
// (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.
|
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
|
||||||
HelpLauncher.Install();
|
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.
|
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
|
||||||
// When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build
|
// 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
|
// a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile
|
||||||
@@ -229,4 +242,30 @@ internal static class Program
|
|||||||
if (!reloadFromScratch) return;
|
if (!reloadFromScratch) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
|
||||||
|
/// into the new <c>config\</c> folder. Only called when a real migration happened. TaskDialog
|
||||||
|
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
|
||||||
|
private static void ShowLayoutMigrationNotice(RemSound.Core.AppConfig.LayoutMigrationResult migration)
|
||||||
|
{
|
||||||
|
var moved = new System.Collections.Generic.List<string>();
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class QuickProfileSwitchDialog
|
||||||
|
{
|
||||||
|
public sealed record ProfileEntry(string Title, string Path, bool IsCurrent);
|
||||||
|
|
||||||
|
/// <summary>Shows the popup. Returns the chosen profile's path, or null if the user cancelled
|
||||||
|
/// (Escape / Close) or there were no profiles to show.</summary>
|
||||||
|
public static string? Show(IReadOnlyList<ProfileEntry> 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);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
tag_name on the latest GitHub release; bump it on every public release. The
|
tag_name on the latest GitHub release; bump it on every public release. The
|
||||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||||
is what the About dialog and the updater both read. -->
|
is what the About dialog and the updater both read. -->
|
||||||
<Version>3.3.0</Version>
|
<Version>3.4.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -97,6 +97,12 @@
|
|||||||
<Link>sounds\update.wav</Link>
|
<Link>sounds\update.wav</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<!-- profile menu open.wav (v3.4): plays as the Quick profile switch popup opens. Project-owner
|
||||||
|
supplied; absent file is ignored at load time. Per-profile mute + custom override in Prefs. -->
|
||||||
|
<Content Include="..\..\sounds\profile menu open.wav" Condition="Exists('..\..\sounds\profile menu open.wav')">
|
||||||
|
<Link>sounds\profile menu open.wav</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||||
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
||||||
the published copy whenever the source is newer; manually-edited copies inside
|
the published copy whenever the source is newer; manually-edited copies inside
|
||||||
|
|||||||
@@ -160,7 +160,104 @@ public sealed class AppConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
|
/// <summary>Friendly names of ASIO drivers RemSound must never touch — it won't probe them,
|
||||||
|
/// won't list them in the driver picker, and won't open them for streaming. Global (not
|
||||||
|
/// per-profile) because "this driver is broken on this machine" is about the hardware/driver
|
||||||
|
/// install, not any one profile. Populated when the user answers "yes" to the Realtek-ASIO
|
||||||
|
/// compatibility warning, or toggles the Options-menu entry. Matched case-insensitively.</summary>
|
||||||
|
public List<string> DisabledAsioDrivers { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Friendly names of ASIO drivers RemSound has already shown its compatibility warning
|
||||||
|
/// for, so a user who answered "no, keep using it" isn't nagged on every launch. Independent of
|
||||||
|
/// <see cref="DisabledAsioDrivers"/>: a driver can be warned-about-but-still-enabled.</summary>
|
||||||
|
public List<string> AsioDriversWarnedAbout { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>True if RemSound should refuse to interact with the named ASIO driver in any way.</summary>
|
||||||
|
public bool IsAsioDriverDisabled(string? driverName) =>
|
||||||
|
!string.IsNullOrWhiteSpace(driverName)
|
||||||
|
&& DisabledAsioDrivers.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>Disable or re-enable the named ASIO driver. Caller must <see cref="Save"/> after.</summary>
|
||||||
|
public void SetAsioDriverDisabled(string driverName, bool disabled)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverName)) return;
|
||||||
|
DisabledAsioDrivers.RemoveAll(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (disabled) DisabledAsioDrivers.Add(driverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True once the compatibility warning has been shown for this driver. Case-insensitive.</summary>
|
||||||
|
public bool HasWarnedAboutAsioDriver(string driverName) =>
|
||||||
|
AsioDriversWarnedAbout.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>Record that the compatibility warning has been shown for this driver (so we don't
|
||||||
|
/// re-nag a user who chose to keep it). Caller must <see cref="Save"/> after.</summary>
|
||||||
|
public void MarkAsioDriverWarned(string driverName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverName)) return;
|
||||||
|
if (!HasWarnedAboutAsioDriver(driverName)) AsioDriversWarnedAbout.Add(driverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the named ASIO driver looks like a Realtek HD Audio ASIO driver (its name
|
||||||
|
/// or description contains "Realtek"). Realtek's bundled ASIO driver (rthdasio64.dll) leaks OS
|
||||||
|
/// handles on every open and is broadly known to misbehave with ASIO hosts; ASUS and other OEMs
|
||||||
|
/// ship the same Realtek driver under their own branding, so we match "Realtek" anywhere in the
|
||||||
|
/// name. RemSound uses this to proactively offer to disable the driver.</summary>
|
||||||
|
public static bool IsRealtekAsioDriver(string? driverName) =>
|
||||||
|
!string.IsNullOrWhiteSpace(driverName)
|
||||||
|
&& driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>The config folder next to the exe (<c><exe>\config\</c>). Holds the global
|
||||||
|
/// config file and the <c>profiles\</c> subfolder. 2026-06-07: everything non-recording config
|
||||||
|
/// moved in here from loose files beside the exe, so the install root stays tidy.</summary>
|
||||||
|
public static string ConfigDirectory => Path.Combine(AppContext.BaseDirectory, "config");
|
||||||
|
|
||||||
|
private static string ConfigPath => Path.Combine(ConfigDirectory, "global config.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time, idempotent relocation of the pre-2026-06-07 layout into <c>config\</c>:
|
||||||
|
/// * <c><exe>\remsound.config.json</c> → <c><exe>\config\global config.json</c>
|
||||||
|
/// * <c><exe>\profiles\</c> → <c><exe>\config\profiles\</c>
|
||||||
|
/// Run once at startup BEFORE anything reads config or profiles. Each move only happens when
|
||||||
|
/// the old item exists and the new one doesn't, so it's safe to call every launch and it
|
||||||
|
/// upgrades anyone coming from an older build without losing a profile or a setting. A custom
|
||||||
|
/// <see cref="ProfilesDirectory"/> is untouched — it isn't in the default location.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> actually relocated this launch.
|
||||||
|
/// <see cref="MovedAnything"/> is true only on the one launch where an upgrade's old files
|
||||||
|
/// were found and moved — the caller uses it to show a one-time "your settings moved" notice.</summary>
|
||||||
|
public readonly record struct LayoutMigrationResult(bool MovedGlobalConfig, bool MovedProfiles)
|
||||||
|
{
|
||||||
|
public bool MovedAnything => MovedGlobalConfig || MovedProfiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded()
|
||||||
|
{
|
||||||
|
var movedGlobal = false;
|
||||||
|
var movedProfiles = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(ConfigDirectory);
|
||||||
|
var oldGlobal = Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
|
||||||
|
if (File.Exists(oldGlobal) && !File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
File.Move(oldGlobal, ConfigPath);
|
||||||
|
movedGlobal = true;
|
||||||
|
}
|
||||||
|
var oldProfiles = Path.Combine(AppContext.BaseDirectory, "profiles");
|
||||||
|
var newProfiles = Path.Combine(ConfigDirectory, "profiles");
|
||||||
|
if (Directory.Exists(oldProfiles) && !Directory.Exists(newProfiles))
|
||||||
|
{
|
||||||
|
Directory.Move(oldProfiles, newProfiles);
|
||||||
|
movedProfiles = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort: a failed move (permissions, file in use) just means the app falls
|
||||||
|
// back to defaults / an empty profiles list rather than crashing on launch.
|
||||||
|
}
|
||||||
|
return new LayoutMigrationResult(movedGlobal, movedProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
|
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
|
||||||
/// or malformed file becomes a defaults-only AppConfig rather than throwing.</summary>
|
/// or malformed file becomes a defaults-only AppConfig rather than throwing.</summary>
|
||||||
@@ -185,6 +282,7 @@ public sealed class AppConfig
|
|||||||
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
|
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
|
Directory.CreateDirectory(ConfigDirectory);
|
||||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(ConfigPath, json);
|
File.WriteAllText(ConfigPath, json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ public sealed class Profile
|
|||||||
public bool? EnableRecordStopCue { get; set; }
|
public bool? EnableRecordStopCue { get; set; }
|
||||||
public bool? EnableSaveCue { get; set; }
|
public bool? EnableSaveCue { get; set; }
|
||||||
public bool? EnableProfileSwitchCue { get; set; }
|
public bool? EnableProfileSwitchCue { get; set; }
|
||||||
|
/// <summary>When true (the default), the "profile menu open" cue plays as the Quick profile
|
||||||
|
/// switch popup opens. Per-profile so each setup can mute it independently.</summary>
|
||||||
|
public bool? EnableProfileMenuOpenCue { get; set; }
|
||||||
/// <summary>Plays the update cue just before an update starts installing (manual or
|
/// <summary>Plays the update cue just before an update starts installing (manual or
|
||||||
/// silent). Null = unset → defaults to on, so a silent background update still gives an
|
/// silent). Null = unset → defaults to on, so a silent background update still gives an
|
||||||
/// audible heads-up. Added 2026-05-31.</summary>
|
/// audible heads-up. Added 2026-05-31.</summary>
|
||||||
@@ -206,6 +209,9 @@ public sealed class Profile
|
|||||||
/// <summary>Hotkey that sends a "toggle Windows default-output-device mute" command to
|
/// <summary>Hotkey that sends a "toggle Windows default-output-device mute" command to
|
||||||
/// every connected peer.</summary>
|
/// every connected peer.</summary>
|
||||||
public HotkeyRecord? SystemMuteToggleHotkey { get; set; }
|
public HotkeyRecord? SystemMuteToggleHotkey { get; set; }
|
||||||
|
/// <summary>Global hotkey that opens the Quick profile switch popup — a list of all profiles you
|
||||||
|
/// can arrow through and press Enter to switch to, from anywhere in Windows. Unset by default.</summary>
|
||||||
|
public HotkeyRecord? QuickProfileSwitchHotkey { get; set; }
|
||||||
/// <summary>When true, this machine honours incoming Control packets from connected
|
/// <summary>When true, this machine honours incoming Control packets from connected
|
||||||
/// peers — adjusts the local volume slider or toggles mute. Default false: receiving
|
/// peers — adjusts the local volume slider or toggles mute. Default false: receiving
|
||||||
/// remote control is opt-in even though the audio allow-list already gates who's
|
/// remote control is opt-in even though the audio allow-list already gates who's
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public sealed class ProfileStore
|
|||||||
public ProfileStore()
|
public ProfileStore()
|
||||||
{
|
{
|
||||||
var machineFolder = SanitiseFsName(Environment.MachineName);
|
var machineFolder = SanitiseFsName(Environment.MachineName);
|
||||||
baseDir = Path.Combine(AppContext.BaseDirectory, "profiles", machineFolder);
|
// 2026-06-07: profiles live under config\profiles\<machine>\ (was <exe>\profiles\<machine>\).
|
||||||
|
// AppConfig.MigrateLegacyLayoutIfNeeded moves any pre-existing profiles here at startup.
|
||||||
|
baseDir = Path.Combine(AppContext.BaseDirectory, "config", "profiles", machineFolder);
|
||||||
try { Directory.CreateDirectory(baseDir); }
|
try { Directory.CreateDirectory(baseDir); }
|
||||||
catch { /* permissions; List/Save will surface this when actually used */ }
|
catch { /* permissions; List/Save will surface this when actually used */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,16 @@ public sealed class RemSoundSettingsStore
|
|||||||
Save(s);
|
Save(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HotkeyInfo LoadQuickProfileSwitchHotkey() =>
|
||||||
|
Try(() => Load()?.QuickProfileSwitchHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
|
||||||
|
|
||||||
|
public void SaveQuickProfileSwitchHotkey(HotkeyInfo hotkey)
|
||||||
|
{
|
||||||
|
var s = Load() ?? new Settings();
|
||||||
|
s.QuickProfileSwitchHotkey = HotkeySetting.From(hotkey);
|
||||||
|
Save(s);
|
||||||
|
}
|
||||||
|
|
||||||
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
|
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
|
||||||
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
|
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
|
||||||
|
|
||||||
@@ -428,6 +438,16 @@ public sealed class RemSoundSettingsStore
|
|||||||
Save(s);
|
Save(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool LoadEnableProfileMenuOpenCue() =>
|
||||||
|
Try(() => Load()?.EnableProfileMenuOpenCue) ?? true;
|
||||||
|
|
||||||
|
public void SaveEnableProfileMenuOpenCue(bool value)
|
||||||
|
{
|
||||||
|
var s = Load() ?? new Settings();
|
||||||
|
s.EnableProfileMenuOpenCue = value;
|
||||||
|
Save(s);
|
||||||
|
}
|
||||||
|
|
||||||
public bool LoadEnableUpdateCue() =>
|
public bool LoadEnableUpdateCue() =>
|
||||||
Try(() => Load()?.EnableUpdateCue) ?? true;
|
Try(() => Load()?.EnableUpdateCue) ?? true;
|
||||||
|
|
||||||
@@ -572,6 +592,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
SystemVolumeUpHotkey = profile.SystemVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeUpHotkey),
|
SystemVolumeUpHotkey = profile.SystemVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeUpHotkey),
|
||||||
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
|
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
|
||||||
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
|
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
|
||||||
|
QuickProfileSwitchHotkey = profile.QuickProfileSwitchHotkey is null ? null : HotkeySettingFromRecord(profile.QuickProfileSwitchHotkey),
|
||||||
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
|
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
|
||||||
MaxLatencyMs = profile.MaxLatencyMs,
|
MaxLatencyMs = profile.MaxLatencyMs,
|
||||||
Codec = profile.Codec,
|
Codec = profile.Codec,
|
||||||
@@ -597,6 +618,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
EnableRecordStopCue = profile.EnableRecordStopCue,
|
EnableRecordStopCue = profile.EnableRecordStopCue,
|
||||||
EnableSaveCue = profile.EnableSaveCue,
|
EnableSaveCue = profile.EnableSaveCue,
|
||||||
EnableProfileSwitchCue = profile.EnableProfileSwitchCue,
|
EnableProfileSwitchCue = profile.EnableProfileSwitchCue,
|
||||||
|
EnableProfileMenuOpenCue = profile.EnableProfileMenuOpenCue,
|
||||||
EnableUpdateCue = profile.EnableUpdateCue,
|
EnableUpdateCue = profile.EnableUpdateCue,
|
||||||
// Defensive copy so cache mutations don't leak into the in-memory Profile graph
|
// Defensive copy so cache mutations don't leak into the in-memory Profile graph
|
||||||
// (and vice-versa). Profile is loaded once at startup; the cache evolves through
|
// (and vice-versa). Profile is loaded once at startup; the cache evolves through
|
||||||
@@ -626,6 +648,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
profile.SystemVolumeUpHotkey = s.SystemVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeUpHotkey);
|
profile.SystemVolumeUpHotkey = s.SystemVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeUpHotkey);
|
||||||
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
|
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
|
||||||
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
|
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
|
||||||
|
profile.QuickProfileSwitchHotkey = s.QuickProfileSwitchHotkey is null ? null : HotkeyRecordFromSetting(s.QuickProfileSwitchHotkey);
|
||||||
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
|
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
|
||||||
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
|
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
|
||||||
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
|
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
|
||||||
@@ -654,6 +677,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
profile.EnableRecordStopCue = s.EnableRecordStopCue;
|
||||||
profile.EnableSaveCue = s.EnableSaveCue;
|
profile.EnableSaveCue = s.EnableSaveCue;
|
||||||
profile.EnableProfileSwitchCue = s.EnableProfileSwitchCue;
|
profile.EnableProfileSwitchCue = s.EnableProfileSwitchCue;
|
||||||
|
profile.EnableProfileMenuOpenCue = s.EnableProfileMenuOpenCue;
|
||||||
profile.EnableUpdateCue = s.EnableUpdateCue;
|
profile.EnableUpdateCue = s.EnableUpdateCue;
|
||||||
profile.CustomCuePaths = s.CustomCuePaths is null
|
profile.CustomCuePaths = s.CustomCuePaths is null
|
||||||
? new Dictionary<string, string>()
|
? new Dictionary<string, string>()
|
||||||
@@ -691,6 +715,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
public HotkeySetting? SystemVolumeUpHotkey { get; set; }
|
public HotkeySetting? SystemVolumeUpHotkey { get; set; }
|
||||||
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
|
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
|
||||||
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
|
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
|
||||||
|
public HotkeySetting? QuickProfileSwitchHotkey { get; set; }
|
||||||
public bool? AcceptRemoteVolumeCommands { get; set; }
|
public bool? AcceptRemoteVolumeCommands { get; set; }
|
||||||
public int? MaxLatencyMs { get; set; }
|
public int? MaxLatencyMs { get; set; }
|
||||||
public AudioTransportCodec? Codec { get; set; }
|
public AudioTransportCodec? Codec { get; set; }
|
||||||
@@ -725,6 +750,7 @@ public sealed class RemSoundSettingsStore
|
|||||||
public bool? EnableRecordStopCue { get; set; }
|
public bool? EnableRecordStopCue { get; set; }
|
||||||
public bool? EnableSaveCue { get; set; }
|
public bool? EnableSaveCue { get; set; }
|
||||||
public bool? EnableProfileSwitchCue { get; set; }
|
public bool? EnableProfileSwitchCue { get; set; }
|
||||||
|
public bool? EnableProfileMenuOpenCue { get; set; }
|
||||||
public bool? EnableUpdateCue { get; set; }
|
public bool? EnableUpdateCue { get; set; }
|
||||||
public Dictionary<string, string>? CustomCuePaths { get; set; }
|
public Dictionary<string, string>? CustomCuePaths { get; set; }
|
||||||
public RecordingSettings? RecordingSettings { get; set; }
|
public RecordingSettings? RecordingSettings { get; set; }
|
||||||
|
|||||||
@@ -832,6 +832,36 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The wire codec of the freshest currently-active receive session across all peers, or
|
||||||
|
/// null when nothing is being received. Surfaced in the SNAP log's Codec column so a
|
||||||
|
/// receive-only node reports what it is actually decoding rather than its dormant send-codec
|
||||||
|
/// setting (the old behaviour logged "Pcm" for a node receiving "PCM over Opus"). Lockless
|
||||||
|
/// scan for the freshest session, then a short lock to read its codec.
|
||||||
|
/// </summary>
|
||||||
|
public AudioTransportCodec? ActiveReceiveCodec
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
SessionPlayout? freshest = null;
|
||||||
|
foreach (var sp in playoutEngine.ActiveSessions)
|
||||||
|
{
|
||||||
|
if (now - sp.LastWriteUtc > SessionIdleTimeout) continue;
|
||||||
|
if (freshest is null || sp.LastWriteUtc > freshest.LastWriteUtc) freshest = sp;
|
||||||
|
}
|
||||||
|
if (freshest is null) return null;
|
||||||
|
lock (sessionsLock)
|
||||||
|
{
|
||||||
|
if (sessions.TryGetValue((freshest.Endpoint, freshest.StreamId), out var session))
|
||||||
|
{
|
||||||
|
return session.Codec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Packet routing (called on network thread) ===
|
// === Packet routing (called on network thread) ===
|
||||||
|
|
||||||
/// <summary>Hook for Heartbeat packets that arrive on the audio receiver's socket. The
|
/// <summary>Hook for Heartbeat packets that arrive on the audio receiver's socket. The
|
||||||
|
|||||||
Reference in New Issue
Block a user