diff --git a/readme.html b/readme.html
index fa8343e..e5f6df0 100644
--- a/readme.html
+++ b/readme.html
@@ -260,7 +260,35 @@ ul, ol { padding-left: 1.4em; }
8. Audio profile tab
-Everything that shapes the audio quality / latency trade-off lives here. The tab is split into two NVDA-announced groups: Audio send parameters at the top, Audio receive parameters below. As you tab through, NVDA announces which group you’ve entered.
+Everything that shapes the audio quality / latency trade-off lives here. The first thing you land on when you Tab into the tab is the Priority mode checkbox — kept ungrouped at the top because it's the single biggest knob for cold-start audio feel. Below that, two NVDA-announced groups: Audio send parameters first, Audio receive parameters below. As you tab through, NVDA announces which group you’ve entered.
+
+Use CPU and Windows performance settings in high priority mode (Alt+U)
+
+The first control on the tab. When ticked, RemSound asks Windows for high-priority treatment across the board, the entire time RemSound is open under this profile. Specifically:
+
+
+- No automatic CPU downclock when the machine looks idle.
+- No migration of audio threads onto efficiency cores on hybrid CPUs (12th-gen Intel and later).
+- No deep CPU-sleep idle states.
+- 1 ms scheduler quantum honoured even when other apps haven’t asked for it.
+- High process priority class.
+- Process memory priority asserted as normal, so Windows doesn’t demote our pages under memory pressure.
+- Working-set minimum locked at 32 MB so the OS’s idle-time trimmer can’t page out our hot code and audio buffers.
+- MMCSS Pro Audio thread priority bumped one notch from the default High to Critical on every hot thread (network listener, mix loop, render).
+
+
+Net effect: the cold-start “first few seconds sound terrible, then it warms up” behaviour goes away. Nothing in the OS coasts while RemSound is sitting between bursts of activity.
+
+It’s a per-profile setting, so you can have one profile for live sessions where this is on and another for casual background-listening profiles where it stays off. Toggling marks the profile as having unsaved changes; save the profile to keep the choice.
+
+
+| When to tick it | When to leave it off |
+| Live music collaboration. Anything where the first few seconds matter. Pro-audio setups using ASIO at very low latency targets (sub-15 ms). Sessions where the CPU sits idle between brief bursts of activity (one person presses Push-To-Talk on the other end). | Battery-powered laptop, especially when running long. Background listening over hours. A passive monitoring profile that doesn’t need responsive cold starts. |
+
+
+Cost on a desktop: a couple of watts more while RemSound is open. Cost on a laptop on battery: more aggressive battery drain over the session because the cores park in a higher power state instead of dropping to deep idle — RemSound’s actual CPU usage doesn’t change, the cores just don’t sleep. The override is automatically reversed when RemSound closes (or when you untick it), so leaving the app running with the box on for a session is fine — turning it off mid-session also works.
+
+None of the other apps on your machine are affected. RemSound only asks Windows to keep itself at high priority; the OS keeps throttling everything else normally, so your screen reader, browser and background services still get the usual power-saving treatment.
Audio send parameters
@@ -370,6 +398,19 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
Heartbeat packets ride on the same UDP port as audio, so if your audio reaches the peer, your heartbeat does too — one firewall rule covers both.
+Network packet priority (qWAVE / Voice / WMM)
+
+RemSound asks Windows’ built-in Quality-of-Service service (qWAVE — Quality Windows Audio/Video Experience) to mark its outbound audio packets as Voice priority (DSCP 46, Expedited Forwarding). This happens automatically on every launch — there’s no toggle — and falls back silently if the service is unavailable (some stripped-down Windows images).
+
+What it buys you, in order of how much you’ll actually notice:
+
+- Wi-Fi: the DSCP marking maps to WMM Voice access category, the shortest medium-contention window the Wi-Fi standard defines. On a busy access point with other clients fighting for airtime (someone streaming, downloading, video-calling), RemSound’s packets get to transmit first. This is the biggest real-world win.
+- Local NIC scheduling: the Windows network stack sends RemSound’s packets ahead of best-effort traffic from the same machine (browser downloads, OS background transfers, cloud-sync uploads). When the upstream is saturated, audio still goes out cleanly.
+- Wired LAN switches and routers that honour DSCP: most consumer kit does, on the local side at least. The marking can give RemSound’s packets priority through the home network.
+
+
+What it doesn’t buy you: priority across the public internet. Most ISPs strip or rewrite DSCP markings at the network edge, so the qWAVE bits rarely survive past your local ISP’s first hop. LAN and same-house Wi-Fi: tangible benefit. Across the internet: neutral — no harm, no help. RemSound also requests 1 MB kernel send and receive buffers on its UDP sockets so a momentary stall (GC, scheduler hiccup) up to roughly 30 ms doesn’t cause the kernel to drop datagrams on either side.
+
LAN — same Wi-Fi or Ethernet
On a normal home network, discovery and heartbeat both work without configuration. Launch RemSound on two machines and they’ll see each other within a second or two via the discovery broadcast. No firewall changes are usually needed because UDP broadcast is allowed by default.
@@ -536,6 +577,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
| Key | Action |
+| Alt+U | Toggle Use CPU and Windows performance settings in high priority mode (for this profile) |
| Alt+C | Focus Audio codec |
| Alt+P | Focus Packet size |
| Alt+K | Toggle Lock to audio clock |
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 1593ffb..be1fe55 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -187,6 +187,19 @@ public sealed class MainForm : Form
private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" };
private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" };
private readonly AccessibleCheckBox tightLatencyBox = new() { AutoSize = true };
+ // Priority mode (per profile). Sits as the first control on the Audio profile tab,
+ // ungrouped above the two GroupBoxes, so it's the first thing focus lands on when the
+ // user Tabs into the tab. Toggling marks the profile dirty (the setting lives in
+ // Profile, not AppConfig) and flips every PerformanceMode lever in one shot — CPU
+ // scheduling, Windows power management, memory priority, working-set lock, and
+ // MMCSS thread priority. Label deliberately mentions both "CPU" and "Windows
+ // performance settings" because the toggle reaches well past just CPU scheduling.
+ private readonly AccessibleCheckBox priorityModeBox = new()
+ {
+ Text = "&Use CPU and Windows performance settings in high priority mode (Alt+U)",
+ AccessibleName = "Use CPU and Windows performance settings in high priority mode",
+ AutoSize = true,
+ };
// --- Connectivity tab controls (Phase 2 refactor) ---
private readonly LiveCheckedListBox connectedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Connected peers (Alt+C)" };
@@ -626,6 +639,12 @@ public sealed class MainForm : Form
// having to infer from sender-engine restarts. Includes the audio mode because what
// "tight" means is mode-dependent (per-callback ASIO emission vs. WASAPI push-mode).
logFile.Event($"tight latency at startup: {(initialTightLatency ? "on" : "off")} (audio mode={settings.LoadAudioMode()})");
+
+ // Priority mode (per-profile). Applies every PerformanceMode lever on first launch
+ // under this profile so the OS doesn't start coasting before the user has tabbed
+ // onto the Audio profile tab. The Audio-profile tab's checkbox handler re-applies
+ // on every toggle.
+ PerformanceMode.Apply(settings.LoadPriorityMode(), msg => logFile.Event(msg));
// Native-rate passthrough is automatic now (driven by codec, not a user setting):
// PCM+single-source-WASAPI-push = pass capture-device rate through to the wire;
// Opus = always pre-resample to 48 kHz (encoder is locked at 48 k); MixingEngine /
@@ -801,6 +820,10 @@ public sealed class MainForm : Form
continuousTuneTimer.Stop();
updateCheckTimer.Stop();
asioDriverChangeDebounce.Stop();
+ // Reverse every Win32 lever PerformanceMode applied. The kernel would clean
+ // these up on process exit anyway, but doing it explicitly releases the power
+ // request handle and matches our timeBeginPeriod with a timeEndPeriod.
+ try { PerformanceMode.Apply(false, msg => logFile.Event(msg)); } catch { /* harmless */ }
try { discovery.Dispose(); } catch { }
try { heartbeatService?.Dispose(); } catch { }
@@ -1522,19 +1545,37 @@ public sealed class MainForm : Form
/// mirrors of hidden form-fields.
private void BuildAudioProfileTab()
{
- // Outer layout: one column, two rows — one row per GroupBox. AutoScroll on so the
- // tab page handles overflow rather than the inner groups clipping their contents.
+ // Outer layout: one column, three rows. Row 0 is the Full-CPU-speed checkbox — the
+ // first thing the user lands on when they Tab into the tab, deliberately ungrouped
+ // and at the top so it can't be missed. Rows 1 and 2 are the existing Audio send
+ // parameters / Audio receive parameters GroupBoxes. AutoScroll on so the tab page
+ // handles overflow rather than the inner groups clipping their contents.
var outerPanel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
- RowCount = 2,
+ RowCount = 3,
AutoScroll = true,
};
outerPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+
+ // Wrap the checkbox in its own FlowLayoutPanel — same NVDA-friendly pattern the
+ // form's other top-level checkboxes use (a bare CheckBox in a TableLayoutPanel
+ // cell suppresses some state-change announcements; the FlowLayoutPanel restores
+ // the announcement chain).
+ var priorityModePanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
+ priorityModePanel.Controls.Add(priorityModeBox);
+ priorityModeBox.Checked = settings.LoadPriorityMode();
+ priorityModeBox.CheckedChanged += (_, _) =>
+ {
+ settings.SavePriorityMode(priorityModeBox.Checked);
+ PerformanceMode.Apply(priorityModeBox.Checked, msg => logFile.Event(msg));
+ MarkProfileDirty();
+ };
var sendGroup = new GroupBox
{
@@ -1554,8 +1595,9 @@ public sealed class MainForm : Form
BuildAudioSendGroupContents(sendGroup);
BuildAudioReceiveGroupContents(receiveGroup);
- outerPanel.Controls.Add(sendGroup, 0, 0);
- outerPanel.Controls.Add(receiveGroup, 0, 1);
+ outerPanel.Controls.Add(priorityModePanel, 0, 0);
+ outerPanel.Controls.Add(sendGroup, 0, 1);
+ outerPanel.Controls.Add(receiveGroup, 0, 2);
audioProfileTabPage.Controls.Add(outerPanel);
}
diff --git a/src/RemSound.App/PerformanceMode.cs b/src/RemSound.App/PerformanceMode.cs
new file mode 100644
index 0000000..159b806
--- /dev/null
+++ b/src/RemSound.App/PerformanceMode.cs
@@ -0,0 +1,425 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace RemSound.App;
+
+///
+/// "Full CPU speed" mode for the current profile. When enabled, pulls every documented
+/// Windows lever to keep our process running at full clock — no EcoQoS downclocking, no
+/// migration to E-cores, no deep-C-state idling, 1 ms scheduler quantum, High priority
+/// class. All five mechanisms are per-process or per-thread: nothing affects other apps,
+/// and nothing here changes the system power plan (which is global) or anyone else's
+/// scheduling. Untoggling reverses every change cleanly.
+///
+/// Mechanisms layered on:
+///
+/// - Process power throttling — opt out of EXECUTION_SPEED. This is the
+/// main one. Tells Windows' scheduler "this is not a background process; don't
+/// downclock it, don't migrate its threads onto efficiency cores on hybrid CPUs".
+/// Without this, Windows aggressively saves power on processes it thinks are
+/// idle, which is the root cause of the "cold start sounds awful, warms up after
+/// a few seconds" behaviour the user reported. Same API Discord, Spotify, OBS
+/// and similar real-time apps use.
+/// - PowerSetRequest(EXECUTION_REQUIRED). Tells the kernel the system as a
+/// whole can't enter deep low-power C-states while this process is active. Belt
+/// and braces with #1 — covers the case where #1 isn't honoured (very old build
+/// of Windows 10, some custom OEM image, etc.).
+/// - Process priority class = High. Standard for low-latency audio apps;
+/// bumps our dispatch priority above almost everything except OS internals. We
+/// deliberately do not use Realtime — that class can starve OS services
+/// (including the audio service itself) and is widely documented as causing
+/// worse audio behaviour, not better.
+/// - timeBeginPeriod(1). Asks Windows for a 1 ms scheduler quantum. Helps
+/// any code path that does timer-based waiting. Since Windows 10 build 1803,
+/// this is scoped per-process — so we don't damage other apps' scheduling. We
+/// match every timeBeginPeriod with a timeEndPeriod when the
+/// feature is disabled, otherwise the OS keeps the elevated rate forever (the
+/// old global-period gotcha is long gone but we're tidy anyway).
+/// - SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED). Older
+/// API but additive to #2; prevents the system from idle-sleeping. Negligible
+/// cost. ES_DISPLAY_REQUIRED is deliberately omitted — we don't need the
+/// screen on while we're running, just the CPU.
+///
+///
+/// Trade-offs the user should know about:
+///
+/// - Laptop on battery: faster drain while RemSound is open with this on. Worth
+/// the energy for a "live session" profile; not worth it for a "background
+/// listening" one. That's why this lives on the Profile, not on AppConfig.
+/// - Desktop on mains: usually a couple of watts more, no practical downside.
+///
+///
+/// Idempotent — calling with the same value twice is a no-op. The
+/// internal state tracks the active power request handle so
+/// with enable: false releases every resource it acquired. Safe to call from the
+/// UI thread; none of the underlying API calls block in any meaningful sense.
+///
+internal static class PerformanceMode
+{
+ private static readonly object gate = new();
+ private static bool currentlyEnabled;
+ private static IntPtr powerRequestHandle = IntPtr.Zero;
+ private static ProcessPriorityClass? priorityBeforeBoost;
+
+ /// Apply or reverse Full-CPU-speed mode. receives a
+ /// short status line so the activation is visible in the diagnostic log file.
+ /// Failures on individual mechanisms (e.g. an OEM image rejecting one of the Win32
+ /// calls) are logged but don't abort the rest of the application of the mode.
+ public static void Apply(bool enable, Action? log = null)
+ {
+ lock (gate)
+ {
+ if (enable == currentlyEnabled)
+ {
+ log?.Invoke($"performance mode: already {(enable ? "on" : "off")}, no change");
+ return;
+ }
+
+ if (enable)
+ {
+ TrySetPowerThrottling(disable: true, log);
+ TryStartPowerRequest(log);
+ TryRaisePriority(log);
+ TryBeginTimePeriod(log);
+ TrySetThreadExecutionState(keepAwake: true, log);
+ TrySetMemoryPriorityNormal(log);
+ TryLockWorkingSetMin(log);
+ currentlyEnabled = true;
+ log?.Invoke("priority mode: ON (EcoQoS off, timer resolution honored, power-request set, priority High, 1 ms quantum, system-required, memory priority normal, working-set min locked)");
+ }
+ else
+ {
+ TrySetPowerThrottling(disable: false, log);
+ TryStopPowerRequest(log);
+ TryRestorePriority(log);
+ TryEndTimePeriod(log);
+ TrySetThreadExecutionState(keepAwake: false, log);
+ TryRelaxWorkingSet(log);
+ currentlyEnabled = false;
+ log?.Invoke("priority mode: OFF (all overrides cleared, defaults restored)");
+ }
+ }
+ }
+
+ // === 1. Process power throttling (EcoQoS opt-out + timer-resolution honoring) ===
+
+ private static void TrySetPowerThrottling(bool disable, Action? log)
+ {
+ try
+ {
+ // We control two flags at once:
+ // * EXECUTION_SPEED — StateMask=0 means "don't throttle this process"
+ // (no downclock, no E-core migration). The main lever.
+ // * IGNORE_TIMER_RESOLUTION — StateMask=0 means "honour my
+ // timeBeginPeriod request even if other apps don't want fine grain".
+ // Pairs naturally with the timeBeginPeriod(1) call further down.
+ // When we revert (disable=false) we set ControlMask=0, which hands both
+ // flags back to Windows' system-default decision-making rather than
+ // forcing them on.
+ var state = new PROCESS_POWER_THROTTLING_STATE
+ {
+ Version = ProcessPowerThrottlingCurrentVersion,
+ ControlMask = disable
+ ? (ProcessPowerThrottlingExecutionSpeed | ProcessPowerThrottlingIgnoreTimerResolution)
+ : 0u,
+ StateMask = 0u,
+ };
+ var ok = SetProcessInformation(GetCurrentProcess(), ProcessPowerThrottling,
+ ref state, Marshal.SizeOf());
+ if (!ok) log?.Invoke($"priority mode: SetProcessInformation(power throttling) failed (win32={Marshal.GetLastWin32Error()})");
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"priority mode: power throttling threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // === 2. Kernel power request (no deep C-states) ===
+
+ private static void TryStartPowerRequest(Action? log)
+ {
+ try
+ {
+ var ctx = new REASON_CONTEXT
+ {
+ Version = PowerRequestContextVersion,
+ Flags = PowerRequestContextSimpleString,
+ Reason = "RemSound is running with Full CPU speed enabled.",
+ };
+ var handle = PowerCreateRequest(ref ctx);
+ if (handle == new IntPtr(-1))
+ {
+ log?.Invoke($"performance mode: PowerCreateRequest failed (win32={Marshal.GetLastWin32Error()})");
+ return;
+ }
+ if (!PowerSetRequest(handle, PowerRequestExecutionRequired))
+ {
+ log?.Invoke($"performance mode: PowerSetRequest failed (win32={Marshal.GetLastWin32Error()})");
+ CloseHandle(handle);
+ return;
+ }
+ powerRequestHandle = handle;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: power request threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static void TryStopPowerRequest(Action? log)
+ {
+ if (powerRequestHandle == IntPtr.Zero) return;
+ try
+ {
+ PowerClearRequest(powerRequestHandle, PowerRequestExecutionRequired);
+ CloseHandle(powerRequestHandle);
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: power-request release threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ powerRequestHandle = IntPtr.Zero;
+ }
+ }
+
+ // === 3. Process priority class ===
+
+ private static void TryRaisePriority(Action? log)
+ {
+ try
+ {
+ using var proc = Process.GetCurrentProcess();
+ priorityBeforeBoost = proc.PriorityClass;
+ proc.PriorityClass = ProcessPriorityClass.High;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: priority raise threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static void TryRestorePriority(Action? log)
+ {
+ if (priorityBeforeBoost is null) return;
+ try
+ {
+ using var proc = Process.GetCurrentProcess();
+ proc.PriorityClass = priorityBeforeBoost.Value;
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: priority restore threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ priorityBeforeBoost = null;
+ }
+ }
+
+ // === 4. timeBeginPeriod / timeEndPeriod ===
+
+ private static bool timePeriodActive;
+
+ private static void TryBeginTimePeriod(Action? log)
+ {
+ if (timePeriodActive) return;
+ try
+ {
+ if (timeBeginPeriod(1) == 0)
+ {
+ timePeriodActive = true;
+ }
+ else
+ {
+ log?.Invoke("performance mode: timeBeginPeriod(1) failed");
+ }
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: timeBeginPeriod threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static void TryEndTimePeriod(Action? log)
+ {
+ if (!timePeriodActive) return;
+ try
+ {
+ timeEndPeriod(1);
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"performance mode: timeEndPeriod threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ finally
+ {
+ timePeriodActive = false;
+ }
+ }
+
+ // === 5. SetThreadExecutionState ===
+
+ private static void TrySetThreadExecutionState(bool keepAwake, Action? log)
+ {
+ try
+ {
+ var flags = keepAwake
+ ? ES_CONTINUOUS | ES_SYSTEM_REQUIRED
+ : ES_CONTINUOUS;
+ var prev = SetThreadExecutionState(flags);
+ if (prev == 0) log?.Invoke("priority mode: SetThreadExecutionState returned 0 (call rejected)");
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"priority mode: SetThreadExecutionState threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // === 6. Process memory priority ===
+
+ // Setting our process's memory priority explicitly to NORMAL. The OS default is
+ // already NORMAL (5), but Windows can demote "background-looking" processes
+ // (especially after long idle periods or under memory pressure), and a demoted
+ // process gets its working-set pages evicted first when the trimmer runs. This
+ // call is a defensive assertion: "I am a foreground-class process, treat my
+ // pages accordingly".
+ private static void TrySetMemoryPriorityNormal(Action? log)
+ {
+ try
+ {
+ var info = new MEMORY_PRIORITY_INFORMATION { MemoryPriority = MemoryPriorityNormal };
+ var ok = SetProcessInformationMemory(GetCurrentProcess(), ProcessMemoryPriority,
+ ref info, Marshal.SizeOf());
+ if (!ok) log?.Invoke($"priority mode: SetProcessInformation(memory) failed (win32={Marshal.GetLastWin32Error()})");
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"priority mode: memory priority threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // === 7. Lock the minimum working-set size ===
+
+ // Windows trims a process's working set proactively when the machine looks idle.
+ // For RemSound that's exactly the moment we'd rather it didn't — the first packet
+ // after an idle stretch hits a cold cache and pages have to be brought back in,
+ // which audibly stalls the first frames. Locking a minimum working set (32 MB —
+ // enough for the JIT'd code + audio scratch buffers plus headroom for NAudio's
+ // and the GC's reserves) tells Windows "you may not trim below this floor".
+ // Released cleanly when priority mode is turned off.
+ private const long MinWorkingSetBytes = 32L * 1024 * 1024;
+
+ private static void TryLockWorkingSetMin(Action? log)
+ {
+ try
+ {
+ // dwMaximumWorkingSetSize = -1 means "leave unchanged".
+ var min = (IntPtr)MinWorkingSetBytes;
+ var maxUnchanged = (IntPtr)(-1);
+ if (!SetProcessWorkingSetSizeEx(GetCurrentProcess(), min, maxUnchanged, QUOTA_LIMITS_HARDWS_MIN_ENABLE))
+ {
+ log?.Invoke($"priority mode: SetProcessWorkingSetSizeEx(lock) failed (win32={Marshal.GetLastWin32Error()})");
+ }
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"priority mode: working-set lock threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ private static void TryRelaxWorkingSet(Action? log)
+ {
+ try
+ {
+ var unchanged = (IntPtr)(-1);
+ if (!SetProcessWorkingSetSizeEx(GetCurrentProcess(), unchanged, unchanged, QUOTA_LIMITS_HARDWS_MIN_DISABLE))
+ {
+ log?.Invoke($"priority mode: SetProcessWorkingSetSizeEx(unlock) failed (win32={Marshal.GetLastWin32Error()})");
+ }
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke($"priority mode: working-set unlock threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // === Native interop ===
+
+ private const int ProcessMemoryPriority = 0; // PROCESS_INFORMATION_CLASS.ProcessMemoryPriority
+ private const int ProcessPowerThrottling = 4; // PROCESS_INFORMATION_CLASS.ProcessPowerThrottling
+ private const uint ProcessPowerThrottlingCurrentVersion = 1;
+ private const uint ProcessPowerThrottlingExecutionSpeed = 0x1;
+ private const uint ProcessPowerThrottlingIgnoreTimerResolution = 0x4;
+ private const int PowerRequestExecutionRequired = 3;
+ private const uint PowerRequestContextVersion = 0;
+ private const uint PowerRequestContextSimpleString = 0x1;
+ private const uint ES_CONTINUOUS = 0x80000000;
+ private const uint ES_SYSTEM_REQUIRED = 0x00000001;
+ private const uint MemoryPriorityNormal = 5;
+ private const int QUOTA_LIMITS_HARDWS_MIN_ENABLE = 0x1;
+ private const int QUOTA_LIMITS_HARDWS_MIN_DISABLE = 0x2;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct PROCESS_POWER_THROTTLING_STATE
+ {
+ public uint Version;
+ public uint ControlMask;
+ public uint StateMask;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MEMORY_PRIORITY_INFORMATION
+ {
+ public uint MemoryPriority;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct REASON_CONTEXT
+ {
+ public uint Version;
+ public uint Flags;
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public string Reason;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr GetCurrentProcess();
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool SetProcessInformation(IntPtr hProcess, int ProcessInformationClass,
+ ref PROCESS_POWER_THROTTLING_STATE ProcessInformation, int ProcessInformationSize);
+
+ // Same kernel32 entry point as SetProcessInformation, but with a different struct type
+ // in the ref parameter — declare a separate P/Invoke so the marshaller picks the right
+ // signature without us needing to manually pin and StructureToPtr the buffer.
+ [DllImport("kernel32.dll", SetLastError = true, EntryPoint = "SetProcessInformation")]
+ private static extern bool SetProcessInformationMemory(IntPtr hProcess, int ProcessInformationClass,
+ ref MEMORY_PRIORITY_INFORMATION ProcessInformation, int ProcessInformationSize);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool SetProcessWorkingSetSizeEx(IntPtr hProcess,
+ IntPtr dwMinimumWorkingSetSize, IntPtr dwMaximumWorkingSetSize, int Flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr PowerCreateRequest(ref REASON_CONTEXT Context);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool PowerSetRequest(IntPtr PowerRequest, int RequestType);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool PowerClearRequest(IntPtr PowerRequest, int RequestType);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool CloseHandle(IntPtr hObject);
+
+ [DllImport("winmm.dll")]
+ private static extern uint timeBeginPeriod(uint uMilliseconds);
+
+ [DllImport("winmm.dll")]
+ private static extern uint timeEndPeriod(uint uMilliseconds);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint SetThreadExecutionState(uint esFlags);
+}
diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs
index 521f190..a4c29ac 100644
--- a/src/RemSound.App/PreferencesDialog.cs
+++ b/src/RemSound.App/PreferencesDialog.cs
@@ -201,8 +201,12 @@ internal sealed class PreferencesDialog : Form
loggingBox.Checked = getLoggingEnabled();
loggingBox.CheckedChanged += (_, _) =>
{
+ // Machine-local setting (AppConfig.LoggingEnabled). applyLoggingEnabled writes
+ // through immediately and flips the live gate, so closing the dialog needs no
+ // further action. NOT a profile setting — do NOT touch ChangedAnyProfileSetting
+ // or we'll trigger a spurious "save profile?" prompt on exit when the user
+ // toggled nothing else.
applyLoggingEnabled(loggingBox.Checked);
- ChangedAnyProfileSetting = true;
};
writeLogsNowButton.Click += (_, _) => writeLogsNow();
diff --git a/src/RemSound.Core/NetworkPriority.cs b/src/RemSound.Core/NetworkPriority.cs
new file mode 100644
index 0000000..dcc5900
--- /dev/null
+++ b/src/RemSound.Core/NetworkPriority.cs
@@ -0,0 +1,147 @@
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+
+namespace RemSound.Core;
+
+///
+/// qWAVE flow attachment for RemSound's outbound UDP socket. Asks Windows' built-in QoS
+/// service (Quality Windows Audio/Video Experience, qwave.dll) to prioritise our
+/// audio packets ahead of best-effort traffic on the local hop.
+///
+/// What this actually buys us:
+///
+/// - The NIC scheduler sends our packets first when there's contention with other
+/// outbound traffic (browser downloads, video uploads, OS-level background
+/// transfers). Locally, our audio always goes first.
+/// - Windows marks the outbound packets with DSCP bits (Voice = 46/EF) that user-mode
+/// code is normally not allowed to set. The marking propagates across routers that
+/// honour DSCP (most consumer kit on a LAN does).
+/// - On Wi-Fi, the DSCP marking maps to WMM Voice access category — gives our packets
+/// the shortest medium-contention window the standard defines. Real win on a busy
+/// access point with other Wi-Fi clients fighting for airtime.
+///
+///
+/// What it doesn't buy us: priority across the public internet. Most ISPs strip or rewrite
+/// DSCP at the network edge, so qWAVE markings rarely survive past your local ISP's first
+/// hop. LAN and same-house Wi-Fi: tangible benefit. Across the internet: neutral. We do
+/// this anyway because the LAN/Wi-Fi half is genuine and there's no cost to attaching the
+/// flow on every launch — always-on.
+///
+/// Lifecycle: attach to the socket once after it's bound, detach on Dispose. The qWAVE
+/// flow handle is per-process-per-socket; closing it cleanly releases the OS-side flow
+/// state. If anything fails (qwave.dll missing on a stripped-down Windows image, QoS
+/// service disabled), the methods log and return false — the socket continues to work
+/// without prioritisation.
+///
+public sealed class NetworkPriority : IDisposable
+{
+ private IntPtr qosHandle = IntPtr.Zero;
+ private uint flowId;
+ private Socket? attachedSocket;
+ private bool flowAdded;
+
+ /// Attach the supplied socket to a Voice-priority qWAVE flow. Returns true on
+ /// success. On failure (logged via ) the socket is
+ /// untouched and continues to work without prioritisation — the caller does not need
+ /// to special-case the failure path. The socket must already be bound.
+ public bool TryAttach(Socket socket, Action? onDiagnostic = null)
+ {
+ if (attachedSocket is not null) return true; // already attached
+ try
+ {
+ var version = new QOS_VERSION { MajorVersion = 1, MinorVersion = 0 };
+ if (!QOSCreateHandle(ref version, out qosHandle))
+ {
+ var err = Marshal.GetLastWin32Error();
+ onDiagnostic?.Invoke($"qwave: QOSCreateHandle failed (win32={err})");
+ qosHandle = IntPtr.Zero;
+ return false;
+ }
+
+ // No DestAddr (IntPtr.Zero) — flow applies to any destination from this socket.
+ // QOS_NON_ADAPTIVE_FLOW = don't let qWAVE adjust our priority downward if it
+ // thinks the link is congested; we want consistent Voice priority always.
+ uint id = 0;
+ if (!QOSAddSocketToFlow(qosHandle, socket.Handle, IntPtr.Zero,
+ QOS_TRAFFIC_TYPE_VOICE, QOS_NON_ADAPTIVE_FLOW, ref id))
+ {
+ var err = Marshal.GetLastWin32Error();
+ onDiagnostic?.Invoke($"qwave: QOSAddSocketToFlow failed (win32={err})");
+ QOSCloseHandle(qosHandle);
+ qosHandle = IntPtr.Zero;
+ return false;
+ }
+ flowId = id;
+ attachedSocket = socket;
+ flowAdded = true;
+ onDiagnostic?.Invoke($"qwave: attached send socket to Voice-priority flow (flowId={flowId})");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ onDiagnostic?.Invoke($"qwave: attach threw {ex.GetType().Name}: {ex.Message}");
+ if (qosHandle != IntPtr.Zero)
+ {
+ try { QOSCloseHandle(qosHandle); } catch { /* ignore */ }
+ qosHandle = IntPtr.Zero;
+ }
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (flowAdded && attachedSocket is not null && qosHandle != IntPtr.Zero)
+ {
+ QOSRemoveSocketFromFlow(qosHandle, attachedSocket.Handle, flowId, 0);
+ }
+ if (qosHandle != IntPtr.Zero)
+ {
+ QOSCloseHandle(qosHandle);
+ }
+ }
+ catch { /* shutdown is best-effort */ }
+ finally
+ {
+ flowAdded = false;
+ attachedSocket = null;
+ qosHandle = IntPtr.Zero;
+ flowId = 0;
+ }
+ }
+
+ // === Native interop ===
+ // qWAVE traffic-type ordering (higher = more priority): BestEffort < Background <
+ // ExcellentEffort < AudioVideo < Voice < Control. We pick Voice rather than
+ // AudioVideo because Voice has the most aggressive jitter requirements in qWAVE's
+ // model, which matches RemSound's sub-50 ms expectations.
+ private const int QOS_TRAFFIC_TYPE_VOICE = 4;
+ private const uint QOS_NON_ADAPTIVE_FLOW = 0x2;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct QOS_VERSION
+ {
+ public ushort MajorVersion;
+ public ushort MinorVersion;
+ }
+
+ [DllImport("qwave.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QOSCreateHandle(ref QOS_VERSION Version, out IntPtr QOSHandle);
+
+ [DllImport("qwave.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QOSCloseHandle(IntPtr QOSHandle);
+
+ [DllImport("qwave.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QOSAddSocketToFlow(IntPtr QOSHandle, IntPtr Socket,
+ IntPtr DestAddr, int TrafficType, uint Flags, ref uint FlowID);
+
+ [DllImport("qwave.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool QOSRemoveSocketFromFlow(IntPtr QOSHandle, IntPtr Socket,
+ uint FlowID, uint Flags);
+}
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index 62cc6fe..69ecc8b 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -52,8 +52,17 @@ public sealed class Profile
public int OpusFrameMilliseconds { get; set; } = 10;
public int SendRateRaw { get; set; } = (int)SendRate.Standard;
public bool TightLatencyMode { get; set; }
- /// True suppresses the connect/disconnect sound cues. Off by default.
- /// 2026-05-06.
+ /// True if this profile asks Windows to keep the RemSound process in
+ /// high-priority mode while it's running — CPU scheduling, power management, memory
+ /// priority, working-set lock, and MMCSS thread priority all elevated. Off by default;
+ /// the user opts in per profile when they want a "live session" feel where the
+ /// cold-start CPU ramp doesn't audibly hurt latency. On laptops that drains the
+ /// battery faster; on desktops it costs a couple of extra watts. See
+ /// PerformanceMode in the App project for the full lever list. Saved per
+ /// profile (not in AppConfig) because the right answer genuinely differs between
+ /// profiles.
+ public bool PriorityMode { get; set; }
+ /// True suppresses the connect/disconnect sound cues. Off by default.
public bool MuteConnectionCues { get; set; }
public int MaxLatencyMs { get; set; } = 80;
public int Smoothness { get; set; } = 3;
diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs
index a26925f..e9044e7 100644
--- a/src/RemSound.Core/RemSoundSettingsStore.cs
+++ b/src/RemSound.Core/RemSoundSettingsStore.cs
@@ -284,6 +284,21 @@ public sealed class RemSoundSettingsStore
Save(s);
}
+ /// Priority mode for the current profile. When true, the App's
+ /// PerformanceMode helper drives every Win32 lever that elevates a process's
+ /// scheduling and memory behaviour: EcoQoS opt-out, kernel power request, High
+ /// priority class, 1 ms scheduler quantum, memory priority, working-set lock,
+ /// and MMCSS thread priority. Off by default; the user opts in per profile.
+ public bool LoadPriorityMode(bool defaultValue = false) =>
+ Try(() => Load()?.PriorityMode) ?? defaultValue;
+
+ public void SavePriorityMode(bool value)
+ {
+ var s = Load() ?? new Settings();
+ s.PriorityMode = value;
+ Save(s);
+ }
+
/// Suppresses the connect/disconnect sound cues that play when a peer's health
/// transitions to/from Healthy. Off by default — cues are on. Saved per-profile so users
/// who don't want them in a given setup don't have to remember to mute every session.
@@ -399,6 +414,7 @@ public sealed class RemSoundSettingsStore
// mode is derived from AsioDriverName and the Both-mode warning popup is gone.
SendRate = profile.SendRate,
TightLatencyMode = profile.TightLatencyMode,
+ PriorityMode = profile.PriorityMode,
Smoothness = profile.Smoothness,
ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw,
MuteConnectionCues = profile.MuteConnectionCues,
@@ -439,6 +455,7 @@ public sealed class RemSoundSettingsStore
// popup that owned the suppression flag is gone.
if (s.SendRate is SendRate sr) profile.SendRate = sr;
if (s.TightLatencyMode is bool tl) profile.TightLatencyMode = tl;
+ if (s.PriorityMode is bool pm) profile.PriorityMode = pm;
if (s.Smoothness is int sm) profile.Smoothness = sm;
if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca;
if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc;
@@ -492,6 +509,7 @@ public sealed class RemSoundSettingsStore
// derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.
public SendRate? SendRate { get; set; }
public bool? TightLatencyMode { get; set; }
+ public bool? PriorityMode { get; set; }
public int? Smoothness { get; set; }
public ConcealmentArtifact? ConcealmentArtifact { get; set; }
public bool? MuteConnectionCues { get; set; }
diff --git a/src/RemSound.Core/WindowsAudioThreadBoost.cs b/src/RemSound.Core/WindowsAudioThreadBoost.cs
index ba6caa5..78871e6 100644
--- a/src/RemSound.Core/WindowsAudioThreadBoost.cs
+++ b/src/RemSound.Core/WindowsAudioThreadBoost.cs
@@ -30,8 +30,14 @@ public sealed class WindowsAudioThreadBoost : IDisposable
if (avrtHandle != IntPtr.Zero)
{
- AvSetMmThreadPriority(avrtHandle, AvrtPriority.High);
- Mode = $"MMCSS {taskName}";
+ // Critical sits one notch above the MMCSS task's default priority (High).
+ // It's still well below RealTime — the OS keeps a reservation for system
+ // services above us so the audio stack and screen reader can't be starved.
+ // Empirically the bump helps the receive listener and the render thread when
+ // the machine is also doing other foreground work (browser, NVDA reading a
+ // page) by getting us off the queue ahead of those tasks' worker threads.
+ AvSetMmThreadPriority(avrtHandle, AvrtPriority.Critical);
+ Mode = $"MMCSS {taskName} (priority Critical)";
}
}
diff --git a/src/RemSound.Receiver/NetworkListener.cs b/src/RemSound.Receiver/NetworkListener.cs
index 2127043..bdd4b2e 100644
--- a/src/RemSound.Receiver/NetworkListener.cs
+++ b/src/RemSound.Receiver/NetworkListener.cs
@@ -45,7 +45,11 @@ internal sealed class NetworkListener : IDisposable
cts = new CancellationTokenSource();
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
- socket.ReceiveBufferSize = 512 * 1024;
+ // 1 MB kernel receive buffer — large enough to ride out ~30 ms of audio-thread or GC
+ // stall at typical PCM-stereo bitrates before the kernel starts dropping inbound
+ // datagrams. Cheap on modern Windows; the old 512 KB cap was below what the receive
+ // thread can saturate during a long-tail GC pause.
+ socket.ReceiveBufferSize = 1024 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Any, udpPort));
var startedSocket = socket;
diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs
index c7c2ad3..729455a 100644
--- a/src/RemSound.Sender/AudioSender.cs
+++ b/src/RemSound.Sender/AudioSender.cs
@@ -44,6 +44,12 @@ public sealed class AudioSender : IDisposable
private ICaptureBackend engine;
private IReadOnlyList pendingSources = [];
private readonly UdpClient udp;
+ // qWAVE attachment for the outbound UDP socket. Marks our packets at DSCP Voice (EF/46)
+ // and gives them NIC-scheduler priority over best-effort traffic. Wins on LAN and Wi-Fi
+ // (WMM Voice access category); neutral across the public internet (most ISPs strip DSCP).
+ // Always on — no toggle. Failure (qwave.dll missing, QoS service disabled) is logged and
+ // ignored; the socket continues unprioritised.
+ private readonly NetworkPriority networkPriority = new();
// Two lanes. defaultLane carries every output in the three classic modes (Mixed route).
// In BothIndependent mode defaultLane carries WASAPI-only audio (route WasapiLane) and
// asioLane carries ASIO-only audio (route AsioLane), each producing its own UDP stream
@@ -141,8 +147,11 @@ public sealed class AudioSender : IDisposable
public AudioSender()
{
udp = new UdpClient(AddressFamily.InterNetwork);
- udp.Client.SendBufferSize = 256 * 1024;
- udp.Client.ReceiveBufferSize = 256 * 1024;
+ // 1 MB kernel buffers each way — big enough to absorb GC pauses or scheduler hiccups
+ // up to ~30 ms at typical PCM-stereo bitrates without dropping packets on the kernel
+ // side. The old 256 KB ceiling was the actual cap on resilience to short stalls.
+ udp.Client.SendBufferSize = 1024 * 1024;
+ udp.Client.ReceiveBufferSize = 1024 * 1024;
// Explicit bind to port 0 (OS picks an ephemeral). Two reasons:
// 1. ReceiveFrom on an unbound UDP socket throws SocketException (WSAEINVAL) on
// Windows — the receive thread we start below would then CPU-spin in its
@@ -152,6 +161,12 @@ public sealed class AudioSender : IDisposable
// this; LAN peer-to-peer is unaffected (we still send from this port, peer just
// sends to its own well-known port as before).
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
+ // Attach the bound socket to qWAVE Voice flow. Must happen after Bind — qWAVE
+ // inspects the local endpoint when it registers the flow. Diagnostics route through
+ // the same sink as everything else; if Diagnostic hasn't been wired yet (typical at
+ // construction time), the lines are silently dropped, which is acceptable for a
+ // success/no-op outcome. On failure the socket keeps working without prioritisation.
+ networkPriority.TryAttach(udp.Client, msg => diagnostic?.Invoke(msg));
defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
// WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null.
@@ -528,6 +543,11 @@ public sealed class AudioSender : IDisposable
// own it here and close the driver as part of app shutdown.
try { persistentAsio?.Dispose(); } catch { /* ignore */ }
persistentAsio = null;
+ // Detach the qWAVE flow before closing the socket — the qwave handle holds a
+ // reference into the kernel-side socket state, and closing the socket first leaves
+ // the QOS flow handle pointing at freed state. Order matters even though both calls
+ // are wrapped in try/catch.
+ try { networkPriority.Dispose(); } catch { /* ignore */ }
udp.Dispose();
}