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
+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.
}