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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user