Release v3.7: device-change smoothing, auto-tune spike rejection, wider mic detector, forensic logging
Coalesce capture-engine rebuilds (CompositeCaptureBackend): a swap-triggering source change now arms a 250ms debounce timer and re-arms on each further change, so a flap or quick reconfiguration produces ONE rebuild to the final state instead of a burst (Andre's 16:40 four-rebuilds-in-33s crackle). In-place updates still apply immediately; a pending rebuild whose target flaps back is cancelled. Auto-tune (TickRoute) now keys off the SECOND-highest arrival-gap/render-gap second in the lookback window instead of the single worst, so a lone ~1s OS/driver stall no longer balloons the buffer to the 200ms cap (the 16:51 trim burst); sustained jitter still reacts at full speed. Logs both gap-max (true peak) and gap-used (value acted on). Mic-privacy detector widened: also catches a per-app Deny aimed at this exe under ConsentStore\microphone\NonPackaged\<exe>, the HKLM NonPackaged gate, and the Group-Policy/MDM force-deny (AppPrivacy LetAppsAccessMicrophone=2) — the block shapes that silence WASAPI capture while ASIO sails past, and that the old three-value check missed. Forensic instrumentation so the next log proves what happened: capPeak= (loudest pre-encode sample, per lane, on the diag line), mic-privacy verdict logged at startup, ui: capture tick/untick events, and device-event: lines for Windows endpoint changes. Docs: mic-privacy + auto-tune sections updated in readme.html, MANUAL.md regenerated, About-box changelog and RELEASE_NOTES for v3.7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,28 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.7
|
||||
|
||||
Changing audio devices mid-session is smoother. A quick
|
||||
run of device changes now settles for a moment and
|
||||
rebuilds the sound engine once, instead of several times
|
||||
in a row — so reconfiguring no longer crackles.
|
||||
|
||||
The latency auto-tuner keeps its head when the computer
|
||||
hiccups. A single brief stall no longer makes it balloon
|
||||
the buffer; it only raises the cushion when late audio
|
||||
keeps coming.
|
||||
|
||||
The microphone-privacy warning now also catches blocks
|
||||
aimed at RemSound alone, and blocks set by an
|
||||
administrator policy — kinds of block Windows applies
|
||||
without showing you a switch.
|
||||
|
||||
RemSound's log now records the loudness of what you're
|
||||
sending, the mic-privacy check's verdict, and every
|
||||
device change — so if something goes wrong, the log can
|
||||
prove what happened instead of leaving us guessing.
|
||||
|
||||
RemSound v3.6
|
||||
|
||||
Updating is far more reliable. RemSound now installs its
|
||||
|
||||
@@ -1010,6 +1010,7 @@ public sealed class MainForm : Form
|
||||
sendInputDevicesList.ItemCheck += (_, args) =>
|
||||
{
|
||||
if (suppressDeviceCheckChange) return;
|
||||
logFile.Event($"ui: capture (WASAPI mic) '{sendInputDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}");
|
||||
BeginInvoke(ApplyAudioRuntime);
|
||||
MarkProfileDirty();
|
||||
// Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app
|
||||
@@ -1026,7 +1027,7 @@ public sealed class MainForm : Form
|
||||
WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel");
|
||||
WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel");
|
||||
asioReceiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
||||
asioSendDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
asioSendDevicesList.ItemCheck += (_, args) => { if (!suppressDeviceCheckChange) { logFile.Event($"ui: capture (ASIO) '{asioSendDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}"); BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
// Profile-management button click wirings retired 2026-05-08 — File menu items now
|
||||
// call SaveProfileAs() / UpdateExistingProfile() / hotkeyController.ShowKeyboardShortcutsDialog
|
||||
// / trayController.Minimize() directly. See BuildFileMenu.
|
||||
@@ -3672,6 +3673,7 @@ public sealed class MainForm : Form
|
||||
private void OnAudioEndpointsChanged()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
logFile.Event("device-event: Windows reported an audio endpoint change (refresh queued)");
|
||||
try
|
||||
{
|
||||
BeginInvoke(new Action(() =>
|
||||
@@ -3821,20 +3823,28 @@ public sealed class MainForm : Form
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reads Windows' microphone privacy setting and returns true when DESKTOP apps (which
|
||||
/// RemSound is) are blocked from the mic. When blocked, WASAPI capture still opens but returns
|
||||
/// pure silence, so the user "sends" but the peer hears nothing. Registry-based: the
|
||||
/// CapabilityAccessManager ConsentStore "Value" is "Allow"/"Deny"; either the general per-user
|
||||
/// gate or the NonPackaged (desktop-app) gate set to Deny blocks us. Best-effort — any failure
|
||||
/// returns false so a registry hiccup never stops the user enabling their mic.</summary>
|
||||
/// <summary>Reads Windows' microphone privacy settings and returns true when desktop apps — or
|
||||
/// THIS app specifically — are blocked from the mic. When blocked, WASAPI capture still opens but
|
||||
/// returns pure silence; ASIO bypasses this gate entirely, which is why the same mic can be live
|
||||
/// in ASIO yet dead here. Checks, in order: the per-user and machine top-level gate and the
|
||||
/// NonPackaged (all desktop apps) gate; a per-app Deny aimed at this exe under
|
||||
/// <c>NonPackaged\<exe></c> (the case the old single-value check missed, where one app is
|
||||
/// denied while the top-level value still says Allow); and a Group-Policy / MDM force-deny
|
||||
/// (<c>LetAppsAccessMicrophone=2</c>, which the Settings UI doesn't even show). Best-effort — any
|
||||
/// failure returns false so a registry hiccup never stops the user enabling their mic.</summary>
|
||||
private static bool IsMicrophoneBlockedByWindowsPrivacy()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone";
|
||||
const string nonPackaged = consent + @"\NonPackaged";
|
||||
return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent + @"\NonPackaged")
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent);
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, nonPackaged)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, nonPackaged)
|
||||
|| IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.CurrentUser, nonPackaged)
|
||||
|| IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.LocalMachine, nonPackaged)
|
||||
|| IsMicPolicyForceDenied();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -3845,9 +3855,40 @@ public sealed class MainForm : Form
|
||||
private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey)
|
||||
{
|
||||
using var key = root.OpenSubKey(subKey);
|
||||
// The ConsentStore "Value" is a REG_SZ "Allow"/"Deny". `as string` yields null for any other
|
||||
// type or a missing value, so anything that isn't an explicit "Deny" reads as allowed.
|
||||
return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>True if a per-app override under NonPackaged denies THIS exe specifically (its child
|
||||
/// key is named by the exe's full path with '\' replaced by '#'). We match by exe filename rather
|
||||
/// than reconstructing the exact encoding, so a deny on a different app never false-warns us.</summary>
|
||||
private static bool IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.RegistryKey root, string nonPackagedKey)
|
||||
{
|
||||
var exeName = System.IO.Path.GetFileName(Environment.ProcessPath ?? "");
|
||||
if (string.IsNullOrEmpty(exeName)) return false;
|
||||
using var key = root.OpenSubKey(nonPackagedKey);
|
||||
if (key is null) return false;
|
||||
foreach (var childName in key.GetSubKeyNames())
|
||||
{
|
||||
if (childName.IndexOf(exeName, StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
using var child = key.OpenSubKey(childName);
|
||||
if (string.Equals(child?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True if a Group Policy / MDM rule force-denies microphone access to apps
|
||||
/// (<c>HKLM\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\LetAppsAccessMicrophone == 2</c>). This
|
||||
/// sits below the Settings toggles, so the user can't see or undo it without policy access — and
|
||||
/// the old check never looked here at all.</summary>
|
||||
private static bool IsMicPolicyForceDenied()
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Policies\Microsoft\Windows\AppPrivacy");
|
||||
return key?.GetValue("LetAppsAccessMicrophone") is int v && v == 2;
|
||||
}
|
||||
|
||||
/// <summary>One-shot, OK-only message telling the user Windows is blocking desktop-app mic
|
||||
/// access (so their mic would send silence) and exactly which two toggles to turn on. Shown
|
||||
/// when they tick a WASAPI mic on while the block is in place.</summary>
|
||||
@@ -3873,13 +3914,16 @@ public sealed class MainForm : Form
|
||||
private void MaybeWarnMicBlockedOnStartup()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (!IsMicrophoneBlockedByWindowsPrivacy()) return;
|
||||
var blocked = IsMicrophoneBlockedByWindowsPrivacy();
|
||||
var anyWasapiMicChecked = false;
|
||||
for (var i = 0; i < sendInputDevicesList.Items.Count; i++)
|
||||
{
|
||||
if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; }
|
||||
}
|
||||
if (anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
// Log the detector's verdict every startup so a silent-mic session is no longer ambiguous —
|
||||
// we can see whether RemSound thought Windows was blocking the mic, not just whether it warned.
|
||||
logFile.Event($"mic-privacy: windows-blocks-desktop-mic={blocked} wasapiMicTicked={anyWasapiMicChecked}");
|
||||
if (blocked && anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -5336,6 +5380,7 @@ public sealed class MainForm : Form
|
||||
var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
|
||||
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
|
||||
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
|
||||
var capPeak = sender.TakeMaxSenderPreEncodePeak();
|
||||
var clippedNow = sender.ClippedSampleCount;
|
||||
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
|
||||
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
|
||||
@@ -5371,7 +5416,7 @@ public sealed class MainForm : Form
|
||||
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||
$"filtErr={filteredErrorFrames:0.0}f " +
|
||||
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"capPeak={capPeak:0.000} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||
@@ -6917,22 +6962,33 @@ public sealed class MainForm : Form
|
||||
|
||||
var sampleCount = Math.Min(LookbackSeconds, recentMaxGaps.Count);
|
||||
var skip = recentMaxGaps.Count - sampleCount;
|
||||
var observedGap = 0;
|
||||
// Track the TWO highest arrival-gap seconds, not just the worst. A single transient spike —
|
||||
// one bad second from an OS/driver hiccup that doesn't recur — used to drive the whole
|
||||
// recommendation (one 1046ms gap pushed the buffer straight to the 200ms cap and shed a big
|
||||
// trim burst). Using the SECOND-highest requires the jitter to persist across >=2 seconds
|
||||
// before it counts, while still honouring sustained jitter at full speed. The true peak is
|
||||
// still logged for diagnosis; we fall back to the single value when there's only one sample.
|
||||
int gapPeak = 0, gapSecond = 0;
|
||||
var i = 0;
|
||||
foreach (var gap in recentMaxGaps)
|
||||
{
|
||||
if (i++ < skip) continue;
|
||||
if (gap > observedGap) observedGap = gap;
|
||||
if (gap > gapPeak) { gapSecond = gapPeak; gapPeak = gap; }
|
||||
else if (gap > gapSecond) { gapSecond = gap; }
|
||||
}
|
||||
var observedGap = sampleCount >= 2 ? gapSecond : gapPeak;
|
||||
|
||||
var observedRenderCb = RenderPeriodFloorMs;
|
||||
// Same lone-spike rejection for the render-callback gap.
|
||||
int rcbPeak = RenderPeriodFloorMs, rcbSecond = RenderPeriodFloorMs;
|
||||
var rcbSkip = recentRenderCbGaps.Count - sampleCount;
|
||||
var rcbI = 0;
|
||||
foreach (var rcb in recentRenderCbGaps)
|
||||
{
|
||||
if (rcbI++ < rcbSkip) continue;
|
||||
if (rcb > observedRenderCb) observedRenderCb = rcb;
|
||||
if (rcb > rcbPeak) { rcbSecond = rcbPeak; rcbPeak = rcb; }
|
||||
else if (rcb > rcbSecond) { rcbSecond = rcb; }
|
||||
}
|
||||
var observedRenderCb = sampleCount >= 2 ? rcbSecond : rcbPeak;
|
||||
|
||||
var codecFloor = (int)Math.Ceiling(1.5 * frameMs);
|
||||
var jitterBased = observedGap + observedRenderCb + SafetyMarginMs;
|
||||
@@ -6963,7 +7019,7 @@ public sealed class MainForm : Form
|
||||
suppressFlag = false;
|
||||
}
|
||||
var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
|
||||
logFile.Event($"{logPrefix}: gap-max={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
|
||||
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
|
||||
}
|
||||
|
||||
// UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>3.6.0</Version>
|
||||
<Version>3.7.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user