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>
|
||||
|
||||
@@ -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