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)
{