Add priority mode + always-on network packet priority
Priority mode (per-profile toggle, Audio profile tab, Alt+U): - Bundled PROCESS_POWER_THROTTLING_STATE with EXECUTION_SPEED and IGNORE_TIMER_RESOLUTION flags (keeps CPU at full clock + 1 ms quantum pinned regardless of OS idle decisions) - PowerSetRequest(EXECUTION_REQUIRED) + SetThreadExecutionState so the system can't decide we're idle and downclock - timeBeginPeriod(1) for 1 ms scheduler quantum - ProcessPriorityClass.High, ProcessMemoryPriority normal, working-set minimum locked via SetProcessWorkingSetSizeEx(HARDWS_MIN_ENABLE) - Fully reversed on toggle-off and on form close - Off by default (battery cost on laptops); explicit opt-in per profile MMCSS thread priority on hot threads bumped from High to Critical (network listener, mix loop, render thread) — always on. Network packet priority (always on, no toggle): - qWAVE flow on the outbound UDP socket at QOS_TRAFFIC_TYPE_VOICE with QOS_NON_ADAPTIVE_FLOW (Voice priority, DSCP 46/EF, WMM Voice on Wi-Fi) - Silent fallback if qwave.dll missing or QoS service disabled - Sender + receiver kernel UDP buffers bumped to 1 MB each (was 256 KB send / 512 KB receive) for resilience against ~30 ms stalls Bug fix: PreferencesDialog logging toggle no longer flags the profile as dirty. Logging is a machine-local AppConfig setting; the spurious ChangedAnyProfileSetting flag was triggering the unsaved-changes prompt on exit after toggling logs alone. Manual updated: new "Use CPU and Windows performance settings in high priority mode (Alt+U)" subsection on the Audio profile tab, new "Network packet priority (qWAVE / Voice / WMM)" subsection in the network chapter, Alt+U row added to keyboard-shortcuts table. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
146e76c6e6
commit
768875cebb
@@ -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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// "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:
|
||||
/// <list type="number">
|
||||
/// <item><b>Process power throttling — opt out of EXECUTION_SPEED</b>. 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.</item>
|
||||
/// <item><b>PowerSetRequest(EXECUTION_REQUIRED)</b>. 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.).</item>
|
||||
/// <item><b>Process priority class = High</b>. Standard for low-latency audio apps;
|
||||
/// bumps our dispatch priority above almost everything except OS internals. We
|
||||
/// deliberately do <i>not</i> use Realtime — that class can starve OS services
|
||||
/// (including the audio service itself) and is widely documented as causing
|
||||
/// worse audio behaviour, not better.</item>
|
||||
/// <item><b>timeBeginPeriod(1)</b>. 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 <c>timeBeginPeriod</c> with a <c>timeEndPeriod</c> 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).</item>
|
||||
/// <item><b>SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)</b>. 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.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Trade-offs the user should know about:
|
||||
/// <list type="bullet">
|
||||
/// <item>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.</item>
|
||||
/// <item>Desktop on mains: usually a couple of watts more, no practical downside.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Idempotent — calling <see cref="Apply"/> with the same value twice is a no-op. The
|
||||
/// internal state tracks the active power request handle so <see cref="Apply(bool, Action{string})"/>
|
||||
/// with <c>enable: false</c> releases every resource it acquired. Safe to call from the
|
||||
/// UI thread; none of the underlying API calls block in any meaningful sense.
|
||||
/// </summary>
|
||||
internal static class PerformanceMode
|
||||
{
|
||||
private static readonly object gate = new();
|
||||
private static bool currentlyEnabled;
|
||||
private static IntPtr powerRequestHandle = IntPtr.Zero;
|
||||
private static ProcessPriorityClass? priorityBeforeBoost;
|
||||
|
||||
/// <summary>Apply or reverse Full-CPU-speed mode. <paramref name="log"/> 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.</summary>
|
||||
public static void Apply(bool enable, Action<string>? 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<string>? 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<PROCESS_POWER_THROTTLING_STATE>());
|
||||
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<string>? 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<string>? 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<string>? 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<string>? 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<string>? 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<string>? 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<string>? 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<string>? log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new MEMORY_PRIORITY_INFORMATION { MemoryPriority = MemoryPriorityNormal };
|
||||
var ok = SetProcessInformationMemory(GetCurrentProcess(), ProcessMemoryPriority,
|
||||
ref info, Marshal.SizeOf<MEMORY_PRIORITY_INFORMATION>());
|
||||
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<string>? 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<string>? 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// qWAVE flow attachment for RemSound's outbound UDP socket. Asks Windows' built-in QoS
|
||||
/// service (Quality Windows Audio/Video Experience, <c>qwave.dll</c>) to prioritise our
|
||||
/// audio packets ahead of best-effort traffic on the local hop.
|
||||
///
|
||||
/// What this actually buys us:
|
||||
/// <list type="bullet">
|
||||
/// <item>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.</item>
|
||||
/// <item>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).</item>
|
||||
/// <item>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.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class NetworkPriority : IDisposable
|
||||
{
|
||||
private IntPtr qosHandle = IntPtr.Zero;
|
||||
private uint flowId;
|
||||
private Socket? attachedSocket;
|
||||
private bool flowAdded;
|
||||
|
||||
/// <summary>Attach the supplied socket to a Voice-priority qWAVE flow. Returns true on
|
||||
/// success. On failure (logged via <paramref name="onDiagnostic"/>) 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.</summary>
|
||||
public bool TryAttach(Socket socket, Action<string>? 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);
|
||||
}
|
||||
@@ -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; }
|
||||
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.
|
||||
/// 2026-05-06.</summary>
|
||||
/// <summary>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
|
||||
/// <c>PerformanceMode</c> in the App project for the full lever list. Saved per
|
||||
/// profile (not in AppConfig) because the right answer genuinely differs between
|
||||
/// profiles.</summary>
|
||||
public bool PriorityMode { get; set; }
|
||||
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.</summary>
|
||||
public bool MuteConnectionCues { get; set; }
|
||||
public int MaxLatencyMs { get; set; } = 80;
|
||||
public int Smoothness { get; set; } = 3;
|
||||
|
||||
@@ -284,6 +284,21 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>Priority mode for the current profile. When true, the App's
|
||||
/// <c>PerformanceMode</c> 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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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; }
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +44,12 @@ public sealed class AudioSender : IDisposable
|
||||
private ICaptureBackend engine;
|
||||
private IReadOnlyList<CaptureSourceSpec> 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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user