using RemSound.Core; namespace RemSound.Sender; /// /// Capture backend that runs a WASAPI and the persistent /// owned by in parallel, as two /// independent lanes — each producing its own PCM stream for its own . /// /// Two pipeline shapes are reachable today: /// /// WasapiOnly: WASAPI child only, no ASIO in the path. Used when no ASIO driver is /// selected (or none is installed). Lowest latency for WASAPI-only setups. /// BothIndependent: WASAPI child + persistent ASIO child running side by side. Each /// delivers samples to its own callback; there is no mix loop, no shared buffer, no /// tee. ASIO keeps its native sub-5 ms pipeline; WASAPI keeps its WASAPI-event rate. /// The legacy AudioMode.Both tee-style mode and AudioMode.AsioOnly are /// no longer reachable from the UI; their enum values remain in /// for back-compat but produce nothing here. /// /// internal sealed class CompositeCaptureBackend : ICaptureBackend { // WASAPI lane callback. In WasapiOnly this is the only callback in use; in BothIndependent // it is specifically the WASAPI lane (the ASIO lane has its own callback below). private readonly Action> onMixedSamples; // ASIO lane callback. Only meaningful in BothIndependent (passed but unused in WasapiOnly, // where the persistent ASIO instance is disposed by AudioSender). private readonly Action>? onAsioLaneSamples; private readonly Action? onDiagnostic; private readonly object gate = new(); // WASAPI child. Normally a MixingEngine (timer-driven, supports N sources); swapped to // PushModeWasapiBackend in Start() when useTightLatencyWasapi is true AND there is exactly // one WASAPI source. Push-mode lets the WASAPI capture event drive the encoder/UDP-send // pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler jitter that's // otherwise visible in the receiver as maxGapMs spikes. Multi-source push mode isn't // supported (rendezvous-of-N-callback-streams problem) — multi-source falls back to // MixingEngine. private ICaptureBackend? wasapi; // ASIO child. BORROWED — AudioSender owns the persistent instance and keeps the driver // open across audio-mode rebuilds (so Audient and similar drivers don't get a rapid // close+reopen, which they hate). The composite uses this reference but does NOT // dispose it; AudioSender disposes on app shutdown or driver change. private readonly AsioCaptureBackend? asio; private readonly string? asioDriverName; private readonly AudioMode mode; private readonly bool useTightLatencyWasapi; private List wasapiSpecs = []; private List 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? pendingRebuildSpecs; private const int RebuildDebounceMs = 250; public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action> onMixedSamples, Action>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action? onDiagnostic = null, bool useTightLatencyWasapi = false) { this.onMixedSamples = onMixedSamples; this.onAsioLaneSamples = onAsioLaneSamples; this.onDiagnostic = onDiagnostic; this.asioDriverName = asioDriverName; this.mode = mode; this.useTightLatencyWasapi = useTightLatencyWasapi; // Legacy enum values (AsioOnly, Both) are no longer produced by the UI but might // arrive here from in-flight callers. Coerce them into reachable modes: a non-WASAPI // request without a driver demotes to WasapiOnly; a non-WASAPI request with a driver // is treated as BothIndependent (the only ASIO-using mode now). if (mode != AudioMode.WasapiOnly) { if (string.IsNullOrEmpty(asioDriverName) || injectedAsio is null) { this.mode = mode = AudioMode.WasapiOnly; } else if (mode != AudioMode.BothIndependent) { this.mode = mode = AudioMode.BothIndependent; } } // Always build the WASAPI lane (it is the WasapiOnly callback path, and the WASAPI // lane in BothIndependent). Push-mode swap, if applicable, happens in Start(). wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); // Borrow the persistent ASIO instance only in BothIndependent. AudioSender already // pointed its callback at the right lane via SetCallback before constructing us. if (mode == AudioMode.BothIndependent) { asio = injectedAsio; } } public bool IsRunning => started; public long TotalCaptureCallbacks => (wasapi?.TotalCaptureCallbacks ?? 0) + (asio?.TotalCaptureCallbacks ?? 0); public long TotalCaptureBytes => (wasapi?.TotalCaptureBytes ?? 0) + (asio?.TotalCaptureBytes ?? 0); public string? FirstCaptureFormatDescription => asio?.FirstCaptureFormatDescription ?? wasapi?.FirstCaptureFormatDescription; public string? FirstCaptureLastError => asio?.FirstCaptureLastError ?? wasapi?.FirstCaptureLastError; // ClippedSampleCount lived on the (now-removed) classic-Both mix loop; the per-lane // BothIndependent pipeline has no shared mix bus to clip. Kept as 0 so any UI binding // that still reads it doesn't NRE. public long ClippedSampleCount => 0; /// Worst callback-gap across both inner backends. We have to take from BOTH (so /// each inner's counter resets), then return the larger — otherwise the unread inner /// would just keep accumulating its max forever. public int TakeMaxCallbackGapMs() { var w = wasapi?.TakeMaxCallbackGapMs() ?? 0; var a = asio?.TakeMaxCallbackGapMs() ?? 0; return Math.Max(w, a); } public IReadOnlyList ActiveSourceNames { get { var combined = new List(); if (wasapi is not null) combined.AddRange(wasapi.ActiveSourceNames); if (asio is not null) combined.AddRange(asio.ActiveSourceNames); return combined; } } /// Max raw-capture step across both inner backends since the last call. Has to /// drain BOTH probes (so neither sits accumulating forever after we read one) and return /// the larger value. public float TakeMaxRawCaptureStep() { var w = wasapi?.TakeMaxRawCaptureStep() ?? 0f; var a = asio?.TakeMaxRawCaptureStep() ?? 0f; return w > a ? w : a; } /// Cross-buffer (boundary) max across both inner backends. Drains BOTH. public float TakeMaxRawCaptureStepCrossBuffer() { var w = wasapi?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f; var a = asio?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f; return w > a ? w : a; } /// Within-buffer max across both inner backends. Drains BOTH. public float TakeMaxRawCaptureStepWithinBuffer() { var w = wasapi?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f; var a = asio?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f; return w > a ? w : a; } /// Sum of cumulative capture-callback ticks across both inner backends since /// the last call. The diag log uses this for captureMs — the per-thread CPU footprint /// of all capture-side work (item 2 of RemSoundefficiency.md). Drains BOTH so neither /// accumulates forever; in BothIndependent the user wants both lanes' load combined. public long TakeCumulativeCaptureTicks() { var w = wasapi?.TakeCumulativeCaptureTicks() ?? 0L; var a = asio?.TakeCumulativeCaptureTicks() ?? 0L; return w + a; } public void Start(IReadOnlyList specs) { lock (gate) { if (started) StopInternal(); (wasapiSpecs, asioSpecs) = SplitSpecs(specs); // Push-mode WASAPI selection. Lets the WASAPI capture event drive the encoder/UDP // send pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler // jitter. Conditions: tight-latency requested, and exactly one WASAPI source // (multi-source needs the rendezvous logic in MixingEngine). Applies equally in // WasapiOnly and BothIndependent — in either, the WASAPI lane is single-source // when the user has ticked one input. var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1; var currentIsPush = wasapi is PushModeWasapiBackend; if (wantPushMode != currentIsPush) { try { wasapi?.Dispose(); } catch { /* ignore */ } if (wantPushMode) { wasapi = new PushModeWasapiBackend(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); onDiagnostic?.Invoke("wasapi backend: switched to push-mode (audio-clock-locked, single-source)"); } else { wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); onDiagnostic?.Invoke("wasapi backend: switched to mix-engine (timer-driven, multi-source capable)"); } } wasapi!.Start(wasapiSpecs); // ASIO child is BORROWED from AudioSender. If the driver is already open from a // previous engine instance we want UpdateSources (which won't close it) rather // than Start (which would Stop+Open and trigger the close+reopen hang on Audient). // The callback was already wired to the correct lane by EnsurePersistentAsioLocked. if (asio is not null) { if (asio.IsRunning) asio.UpdateSources(asioSpecs); else asio.Start(asioSpecs); } started = true; onDiagnostic?.Invoke($"composite capture started: wasapi={wasapiSpecs.Count} sources, asio={asioSpecs.Count} sources, mode={ModeLabel()}{(wantPushMode ? " [wasapi push]" : "")}"); } } public void UpdateSources(IReadOnlyList specs) { lock (gate) { if (!started) { Start(specs); return; } var (newWasapi, newAsio) = SplitSpecs(specs); // 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) { 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; } // 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 newWasapi, List 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; } } /// Fires ~ 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. 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) { onDiagnostic?.Invoke($"wasapi backend: applying coalesced rebuild → {newWasapi.Count} wasapi source(s)"); StopInternal(); Start(specs); } else { 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)", AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)", _ => mode.ToString(), }; public void Stop() { lock (gate) StopInternal(); } 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 // and kept alive across engine rebuilds. Stopping it would force a close+reopen that // Audient (and similar drivers) hang on for ~5 s. AudioSender disposes it on app // shutdown or driver change. started = false; } public void Dispose() { Stop(); try { rebuildTimer?.Dispose(); } catch { /* ignore */ } try { wasapi?.Dispose(); } catch { /* ignore */ } // ASIO child not disposed — see StopInternal above. } private static (List wasapi, List asio) SplitSpecs(IReadOnlyList specs) { var wasapi = new List(); var asio = new List(); foreach (var spec in specs) { if (AsioDeviceId.TryParse(spec.DeviceId, out _)) asio.Add(spec); else wasapi.Add(spec); } return (wasapi, asio); } private static bool SpecsEqual(IReadOnlyList a, IReadOnlyList b) { if (a.Count != b.Count) return false; for (var i = 0; i < a.Count; i++) { if (a[i].DeviceId != b[i].DeviceId || a[i].Kind != b[i].Kind) return false; } return true; } }