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:
Ednunp
2026-06-10 22:44:55 +01:00
co-authored by Claude Fable 5
parent 55bdcde0af
commit 9e247112cb
9 changed files with 213 additions and 51 deletions
+22
View File
@@ -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
+73 -17
View File
@@ -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\&lt;exe&gt;</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.
+1 -1
View File
@@ -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>
+9
View File
@@ -179,6 +179,15 @@ public sealed class AudioSender : IDisposable
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
/// <summary>Loudest absolute pre-encode sample across both lanes since the last call (resets on
/// read). ~0 means we're sending silence; surfaced on the diag line as capPeak.</summary>
public float TakeMaxSenderPreEncodePeak()
{
var a = defaultLane.TakeMaxPreEncodePeak();
var b = asioLane.TakeMaxPreEncodePeak();
return a > b ? a : b;
}
// Cross-buffer (boundary) and within-buffer (content) split — see AudioStepProbe for the
// diagnostic distinction. Used by the per-second diag logger to emit two extra columns so
// an offline log inspection can tell a real audio transient apart from a buffer-boundary
+66 -12
View File
@@ -51,6 +51,15 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
private List<CaptureSourceSpec> asioSpecs = [];
private bool started;
// Coalesces capture-lane rebuilds. A push<->mix backend swap tears down and restarts the whole
// WASAPI lane (a brief audible gap); when the capture set flaps, doing that per-change produces a
// burst of restarts. We defer the swap by RebuildDebounceMs and re-arm on each change, so a run of
// changes collapses into one rebuild. Only the swap path is debounced — in-place source updates
// still apply immediately. The timer callback and all of these fields are mutated under `gate`.
private System.Threading.Timer? rebuildTimer;
private IReadOnlyList<CaptureSourceSpec>? pendingRebuildSpecs;
private const int RebuildDebounceMs = 250;
public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<ReadOnlyMemory<float>>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action<string>? onDiagnostic = null, bool useTightLatencyWasapi = false)
{
this.onMixedSamples = onMixedSamples;
@@ -212,32 +221,75 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
}
var (newWasapi, newAsio) = SplitSpecs(specs);
// If push-mode applicability changes (single WASAPI source toggled on/off), the
// backend has to swap. PushModeWasapiBackend supports only one source. Full
// restart is acceptable here — changing source count mid-session is rare.
// A push-mode swap (single WASAPI source toggled on/off) means tearing the whole WASAPI
// lane down and rebuilding it — a brief audible gap. When the capture set flaps (a device
// coming and going, or a quick reconfiguration), doing that on every change produces a
// burst of restarts. Coalesce instead: stash the target and (re)arm a short timer so a run
// of changes collapses into ONE rebuild to the final state. PushModeWasapiBackend still
// supports only one source; this just defers the swap, it doesn't change the end result.
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
var isPush = wasapi is PushModeWasapiBackend;
if (wouldBePush != isPush)
{
onDiagnostic?.Invoke($"wasapi backend: source count changed ({wasapiSpecs.Count}→{newWasapi.Count}), restarting to switch backend");
StopInternal();
Start(specs);
pendingRebuildSpecs = specs;
(rebuildTimer ??= new System.Threading.Timer(OnRebuildDue)).Change(RebuildDebounceMs, System.Threading.Timeout.Infinite);
onDiagnostic?.Invoke($"wasapi backend: source-count change ({wasapiSpecs.Count}→{newWasapi.Count}) — coalescing rebuild in {RebuildDebounceMs}ms");
return;
}
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
// No swap needed: the live backend takes these specs in place (cheap, no glitch). This
// also resolves a pending rebuild whose target flapped back to the current backend shape.
CancelPendingRebuild();
ApplyInPlace(newWasapi, newAsio);
}
}
private void ApplyInPlace(List<CaptureSourceSpec> newWasapi, List<CaptureSourceSpec> newAsio)
{
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
{
wasapi.UpdateSources(newWasapi);
wasapiSpecs = newWasapi;
}
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
{
asio.UpdateSources(newAsio);
asioSpecs = newAsio;
}
}
/// <summary>Fires ~<see cref="RebuildDebounceMs"/> after the last swap-triggering change. Applies
/// the final capture set with a single rebuild — or, if the set flapped back to the current
/// backend shape during the window, just an in-place update with no rebuild at all. Holds gate.</summary>
private void OnRebuildDue(object? state)
{
lock (gate)
{
var specs = pendingRebuildSpecs;
pendingRebuildSpecs = null;
if (specs is null || !started) return;
var (newWasapi, newAsio) = SplitSpecs(specs);
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
var isPush = wasapi is PushModeWasapiBackend;
if (wouldBePush != isPush)
{
wasapi.UpdateSources(newWasapi);
wasapiSpecs = newWasapi;
onDiagnostic?.Invoke($"wasapi backend: applying coalesced rebuild → {newWasapi.Count} wasapi source(s)");
StopInternal();
Start(specs);
}
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
else
{
asio.UpdateSources(newAsio);
asioSpecs = newAsio;
ApplyInPlace(newWasapi, newAsio);
}
}
}
private void CancelPendingRebuild()
{
pendingRebuildSpecs = null;
rebuildTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
}
private string ModeLabel() => mode switch
{
AudioMode.WasapiOnly => "fast (WASAPI direct)",
@@ -252,6 +304,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
private void StopInternal()
{
CancelPendingRebuild();
if (!started) return;
try { wasapi?.Stop(); } catch { /* ignore */ }
// ASIO child is NEVER stopped here — it's the persistent instance owned by AudioSender
@@ -264,6 +317,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
public void Dispose()
{
Stop();
try { rebuildTimer?.Dispose(); } catch { /* ignore */ }
try { wasapi?.Dispose(); } catch { /* ignore */ }
// ASIO child not disposed — see StopInternal above.
}
+15
View File
@@ -76,6 +76,13 @@ internal sealed class SenderLane
public float TakeMaxPreEncodeStepCrossBuffer() => preEncodeStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPreEncodeStepWithinBuffer() => preEncodeStepProbe.TakeMaxWithinBuffer();
// Loudest absolute sample seen on this lane's pre-encode buffer since the last drain (resets on
// read), surfaced on the diag line. ~0 = we are sending silence (mic blocked / muted / wrong
// endpoint); a clear non-zero = real audio is reaching the encoder. This is the signal level the
// log never had — which is exactly why a "mic sends silence" report couldn't be confirmed from it.
private float preEncodePeak;
public float TakeMaxPreEncodePeak() { var p = preEncodePeak; preEncodePeak = 0f; return p; }
// Which render route this lane announces in its format packets. The receiver reads the
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
@@ -190,6 +197,14 @@ internal sealed class SenderLane
// <see cref="preEncodeStepProbe"/> field comment for why this isn't shared with the
// other lane in BothIndependent.
preEncodeStepProbe.ScanStereo(span);
// Capture-level peak alongside the discontinuity probe — the loudest sample about to be sent.
var peak = preEncodePeak;
for (var s = 0; s < span.Length; s++)
{
var a = span[s] < 0f ? -span[s] : span[s];
if (a > peak) peak = a;
}
preEncodePeak = peak;
switch (owner.Codec)
{