From 8a7c4ddf2b58ab3cb4aa0bb89fd300e33f5ca41f Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:58:07 +0100 Subject: [PATCH] Multi-output fan-out, offline-marker fix, #19, and per-app send engine core Local checkpoint - NOT for public release. Fan-out (every received stream to every selected output, no added latency): - SessionPlayout mirror replicas fed the same decoded bytes; delicate ReadFloats untouched. - PlayoutEngine reconciles replicas per active output lane; per-route tuner aggregation keeps lanes from disturbing each other. Recording dispatch gated to the recording route only. - PeerDspChain.Clone() gives each output lane independent biquad state. - ReceiverSelfChecks.FanOutToBothOutputs proves both lanes get audio (self-test). Offline-marker pile-up fix: - ResolvePeerDisplayName strips the " (offline)" marker before reuse, so a ghost peer no longer compounds the suffix hundreds of times in the status line. Issue #19 (Use-Windows-default follower in the loopback send list): - DefaultLoopbackSendFollower resolves to the current default render device's loopback spec, re-applied when the default changes. Per-application send engine core (issue #20) - WASAPI-only, Win10 19041+ gated: - CaptureKind.ProcessLoopback + ProcessLoopbackId ("proc:"). - AudioAppEnumerator: snapshots apps with audio sessions, tracked by process name, releasing every session object each pass so nothing piles up. - ProcessLoopbackCapture: IWaveIn over the process-loopback activation API (hand-rolled COM interop; NAudio has no binding). Fixed 48k/float/stereo. - CaptureSource IWaveIn overload; MixingEngine opens "proc:" sources with no MMDevice and no render keepalive. ASIO path untouched. - Self-test enumerated real apps on hardware; support gate verified. UI (Preferences device/app mode + app checklist), ApplySendSources app specs, and the reconcile timer are still to come. Gate: 15/15. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 73 +++- src/RemSound.App/SelfTest.cs | 36 ++ src/RemSound.Core/AppConfig.cs | 18 + src/RemSound.Core/CaptureSourceSpec.cs | 21 ++ src/RemSound.Receiver/AudioReceiver.cs | 14 +- src/RemSound.Receiver/PeerDspChain.cs | 16 +- src/RemSound.Receiver/PlayoutEngine.cs | 140 +++++++- src/RemSound.Receiver/ReceiverSelfChecks.cs | 56 +++ src/RemSound.Receiver/SessionPlayout.cs | 33 ++ src/RemSound.Sender/AudioAppEnumerator.cs | 113 ++++++ src/RemSound.Sender/CaptureSource.cs | 20 +- src/RemSound.Sender/MixingEngine.cs | 25 +- src/RemSound.Sender/ProcessLoopbackCapture.cs | 329 ++++++++++++++++++ 13 files changed, 856 insertions(+), 38 deletions(-) create mode 100644 src/RemSound.Receiver/ReceiverSelfChecks.cs create mode 100644 src/RemSound.Sender/AudioAppEnumerator.cs create mode 100644 src/RemSound.Sender/ProcessLoopbackCapture.cs diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 95796e5..bcedf73 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -478,11 +478,17 @@ public sealed class MainForm : Form new("Use Windows default audio device, follows Windows changes", "__use-default-output__", CaptureKind.Loopback) { IsDefaultFollower = true }; private static readonly AudioDeviceChoice DefaultInputFollower = new("Use Windows default audio device, follows Windows changes", "__use-default-input__", CaptureKind.Input) { IsDefaultFollower = true }; + // "Use Windows default" for the SEND side's "WASAPI audio outputs to send" (system-audio/loopback) + // list: loopback-capture whatever Windows currently uses as the default OUTPUT and follow it. Its own + // sentinel + persisted flag, distinct from the receive-output follower (which plays TO the default). + private static readonly AudioDeviceChoice DefaultLoopbackSendFollower = + new("Use Windows default audio device, follows Windows changes", "__use-default-loopback-send__", CaptureKind.Loopback) { IsDefaultFollower = true }; // The Windows-default device id we last routed to while following, per direction. A default-device // change doesn't change the device SET, so the list-sync wouldn't catch it — we compare against // these to spot it and re-route (see ReapplyIfFollowedDefaultChanged). private string? lastFollowedDefaultOutputId; private string? lastFollowedDefaultInputId; + private string? lastFollowedDefaultLoopbackId; // Receive-output device IDs the user/profile selected — kept even while a device is unplugged, // so a card that returns is silently re-ticked and re-opened (issue #5: recover after USB @@ -1172,7 +1178,21 @@ public sealed class MainForm : Form }; WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device"); WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device"); - sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; + sendOutputDevicesList.ItemCheck += (_, args) => + { + if (suppressDeviceCheckChange) return; + BeginInvoke(ApplyAudioRuntime); + if (sendOutputDevicesList.Items[args.Index] is AudioDeviceChoice { IsDefaultFollower: true }) + { + // "Use Windows default output for loopback" is a machine-wide preference (AppConfig), + // not part of the profile — a follower can never go stale. No untick-others prompt: the + // capture-spec builder de-dupes ids, so following the default never double-captures a + // device already ticked explicitly. + PersistUseDefaultLoopbackSend(args.NewValue == CheckState.Checked); + return; + } + MarkProfileDirty(); + }; sendInputDevicesList.ItemCheck += (_, args) => { if (suppressDeviceCheckChange) return; @@ -4362,7 +4382,7 @@ public sealed class MainForm : Form // by every measure. 2026-06-02 (Ed's "offline but still sending audio" report). var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3)) || IsEndpointHeartbeatHealthy(ep); - var suffix = stillThere ? "" : " (offline)"; + var suffix = stillThere ? "" : OfflineMarker; var ghost = new PeerAnnouncement(id, $"{label}{suffix}", ep.Port, true, true, DateTime.UtcNow, ep.Address); desired.Add((new PeerListItem(ghost), id)); } @@ -4466,9 +4486,22 @@ public sealed class MainForm : Form /// (which for a manual-by-IP peer is the address). Used by every peer list via /// . private string ResolvePeerDisplayName(PeerAnnouncement peer) - => namedPeers.TryGetValue(PeerIdentityKey(peer), out var np) && !string.IsNullOrWhiteSpace(np.FriendlyName) + { + var name = namedPeers.TryGetValue(PeerIdentityKey(peer), out var np) && !string.IsNullOrWhiteSpace(np.FriendlyName) ? np.FriendlyName : peer.Name; + // SyncConnectedList decorates a discovery-lost "ghost" peer's Name with a transient " (offline)" + // for the connected list. That decoration must NEVER be persisted as the peer's label — this + // method feeds selectedPeerLabels (the status readout, pan/EQ list, recordings), which is + // re-stored every status tick, so a baked-in " (offline)" compounds into + // "NAME (offline)(offline)(offline)…". Strip it here, the single point where a display name is + // resolved for storage, so the marker stays a once-only, live decoration. + return StripOfflineMarker(name); + } + + private const string OfflineMarker = " (offline)"; + private static string StripOfflineMarker(string name) => + name.Contains(OfflineMarker, StringComparison.Ordinal) ? name.Replace(OfflineMarker, "") : name; private PeerListItem? SelectedConnectedPeer() => SafeSelectedItem(connectedPeersList) as PeerListItem; @@ -5130,10 +5163,25 @@ public sealed class MainForm : Form // splits this set internally into WASAPI specs (sent to MixingEngine) and ASIO specs // (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed. var specs = new List(); + var addedLoopbackIds = new HashSet(StringComparer.OrdinalIgnoreCase); + string? followedDefaultLoopback = null; foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) { - if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); + if (item.IsDefaultFollower) + { + // Loopback-capture whatever Windows currently uses as the default OUTPUT, and follow it. + followedDefaultLoopback = ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render); + if (!string.IsNullOrEmpty(followedDefaultLoopback) && addedLoopbackIds.Add(followedDefaultLoopback)) + { + specs.Add(new CaptureSourceSpec(followedDefaultLoopback, CaptureKind.Loopback, "Windows default audio device")); + } + } + else if (item.DeviceId is { } id && addedLoopbackIds.Add(id)) + { + specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); + } } + lastFollowedDefaultLoopbackId = followedDefaultLoopback; var addedInputIds = new HashSet(StringComparer.OrdinalIgnoreCase); string? followedDefaultInput = null; foreach (var item in sendInputDevicesList.CheckedItems.OfType()) @@ -5179,7 +5227,7 @@ public sealed class MainForm : Form // All three lists start UNCHECKED every session. No persisted selection — by design. // The user re-ticks once per session, avoiding the "wrong-device-still-selected" // failure mode after a card unplug or ID change. - sendOutputDevicesSignature = SyncDeviceCheckedListBox(sendOutputDevicesList, outputs); + sendOutputDevicesSignature = SyncDeviceCheckedListBox(sendOutputDevicesList, WithDefaultFollower(outputs, DefaultLoopbackSendFollower)); sendInputDevicesSignature = SyncDeviceCheckedListBox(sendInputDevicesList, WithDefaultFollower(inputs, DefaultInputFollower)); receiveOutputDevicesSignature = SyncDeviceCheckedListBox(receiveOutputDevicesList, WithDefaultFollower(outputs, DefaultOutputFollower)); // Re-tick the "Use Windows default" followers from the saved preference. They don't ride the @@ -5286,7 +5334,7 @@ public sealed class MainForm : Form return; } - var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature); + var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, WithDefaultFollower(wasapiOutputs, DefaultLoopbackSendFollower), ref sendOutputDevicesSignature); var sendInputChanged = MaybeSyncList(sendInputDevicesList, WithDefaultFollower(wasapiInputs, DefaultInputFollower), ref sendInputDevicesSignature); var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, WithDefaultFollower(wasapiOutputs, DefaultOutputFollower), ref receiveOutputDevicesSignature); bool asioSendChanged; @@ -5785,6 +5833,12 @@ public sealed class MainForm : Form { ApplySendSources(); } + // Send-side loopback follower tracks the default OUTPUT (Render) — re-route when it moves. + if (IsFollowerChecked(sendOutputDevicesList) + && ResolveDefaultDeviceId(NAudio.CoreAudioApi.DataFlow.Render) != lastFollowedDefaultLoopbackId) + { + ApplySendSources(); + } } private static void PersistUseDefaultDevice(bool output, bool on) @@ -5798,6 +5852,12 @@ public sealed class MainForm : Form catch { /* harmless — choice just won't survive a restart */ } } + private static void PersistUseDefaultLoopbackSend(bool on) + { + try { var c = AppConfig.Load(); c.UseDefaultLoopbackSend = on; c.Save(); } + catch { /* harmless — choice just won't survive a restart */ } + } + private void RestoreDefaultFollowerChecks() { AppConfig cfg; @@ -5805,6 +5865,7 @@ public sealed class MainForm : Form catch { return; } SetFollowerChecked(receiveOutputDevicesList, cfg.UseDefaultOutputDevice); SetFollowerChecked(sendInputDevicesList, cfg.UseDefaultInputDevice); + SetFollowerChecked(sendOutputDevicesList, cfg.UseDefaultLoopbackSend); } private void SetFollowerChecked(CheckedListBox list, bool on) diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 0468c4e..c178f80 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -58,6 +58,8 @@ internal static class SelfTest RunStep(results, "Server wire-format compatibility", ServerWireCompat); RunStep(results, "App settings save and reload", SettingsRoundTrip); RunStep(results, "Per-peer shaping DSP", PeerShapingDsp); + RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs); + RunStep(results, "Per-application send enumeration", AppSendEnumeration); RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip); RunStep(results, "Profile save and reload", ProfileRoundTrip); RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip); @@ -248,6 +250,40 @@ internal static class SelfTest return "unity→null, master-off→null, volume, parametric"; } + /// Proves the "every received stream plays to EVERY active output" fan-out: with both output + /// lanes active (BothIndependent), one incoming stream must produce audio on BOTH the WASAPI and the + /// ASIO lane surface — the WASAPI lane from the primary session, the ASIO lane from its mirror + /// replica. Before the fan-out, only the lane matching the sender's capture tag played and the other + /// output was silent (the bug Ed hit: ASIO-sent audio never reached the WASAPI output). + private static string? FanOutToBothOutputs() + { + // Driven inside RemSound.Receiver (PlayoutEngine/SessionPlayout are internal there). + var err = ReceiverSelfChecks.FanOutToBothOutputs(); + Check(err is null, err ?? ""); + return "one stream played to both output lanes (WASAPI + ASIO fan-out)"; + } + + /// Per-application send plumbing: the enumerator returns a well-formed snapshot without + /// throwing (it may be empty on a silent/headless box — that's fine), the "proc:PID" id round-trips, + /// and the Windows-version support gate answers consistently. Does NOT open a real process-loopback + /// capture — that needs a live playing app + hardware, validated separately. + private static string? AppSendEnumeration() + { + var apps = RemSound.Sender.AudioAppEnumerator.Snapshot(); + Check(apps is not null, "enumerator returned null"); + foreach (var a in apps!) + Check(!string.IsNullOrWhiteSpace(a.ProcessName), "an app had an empty process name"); + + Check(ProcessLoopbackId.TryParse(ProcessLoopbackId.Format(1234), out var pid) && pid == 1234, + "proc:PID id did not round-trip"); + Check(!ProcessLoopbackId.TryParse("asio:0", out _), "ASIO id wrongly parsed as a process id"); + + var supported = RemSound.Sender.ProcessLoopbackCapture.IsSupported; + Check(supported == OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041), + "support gate disagrees with the OS build check"); + return $"enumerated {apps.Count} app(s); process-loopback supported={supported}"; + } + /// The v5 machine-wide settings and per-peer shaping survive a JSON save/reload: new /// AppConfig defaults, the named-peers book, the main tab order, per-peer shaping with parametric /// bands, and the new recording default. All in-memory — the real config/profiles aren't touched. diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 34b3ed7..2bd0fea 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -225,6 +225,24 @@ public sealed class AppConfig /// the same idea for the send-input list. public bool UseDefaultOutputDevice { get; set; } public bool UseDefaultInputDevice { get; set; } + /// Same idea for the "WASAPI audio outputs to send" (system-audio / loopback) list: when + /// true, RemSound sends the system audio of whatever Windows currently uses as the default OUTPUT + /// device, following it if the default changes. Machine-wide, opt-in, default false. + public bool UseDefaultLoopbackSend { get; set; } + + /// How the WASAPI send side captures system audio: "devices" (the WASAPI outputs-to-send + /// loopback list — the classic behaviour, default) or "applications" (per-application process + /// loopback — Windows 10 2004+ only). Chosen in Preferences; the send tab shows the matching list. + /// ASIO capture runs alongside either way. + public string WasapiSendMode { get; set; } = "devices"; + /// In "applications" send mode: when true (the default) every app's audio is sent (i.e. the + /// whole system audio); when false only the apps in are sent. + /// Mirrors the "Send all applications" master checkbox. + public bool SendAllApplications { get; set; } = true; + /// In "applications" send mode with off: the process names + /// (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by NAME not + /// PID so the selection survives an app restart. + public List SelectedSendApplications { get; set; } = new(); /// Remembered answer to the "untick the other outputs/inputs when you turn on Use Windows /// default?" prompt. Null = ask each time; true = always untick; false = never untick. Set when the diff --git a/src/RemSound.Core/CaptureSourceSpec.cs b/src/RemSound.Core/CaptureSourceSpec.cs index d4e3325..d897145 100644 --- a/src/RemSound.Core/CaptureSourceSpec.cs +++ b/src/RemSound.Core/CaptureSourceSpec.cs @@ -9,6 +9,11 @@ public enum CaptureKind { Loopback, Input, + /// Per-application capture: loopback of ONE process (and its child process tree) via the + /// Windows process-loopback API (Windows 10 build 19041+). The + /// carries the target PID as "proc:<pid>" (see ). WASAPI-only + /// — ASIO has no per-app concept. + ProcessLoopback, } /// @@ -36,3 +41,19 @@ public static class AsioDeviceId return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0; } } + +/// Synthetic device-id for a per-application (process-loopback) capture: "proc:<pid>". +/// The PID is resolved fresh each time the app list reconciles, so the id is transient (an app that +/// restarts gets a new PID and a new spec) — selection is tracked by process NAME elsewhere. +public static class ProcessLoopbackId +{ + public static string Format(int processId) => $"proc:{processId}"; + + public static bool TryParse(string deviceId, out int processId) + { + processId = -1; + if (string.IsNullOrEmpty(deviceId)) return false; + if (!deviceId.StartsWith("proc:", StringComparison.OrdinalIgnoreCase)) return false; + return int.TryParse(deviceId.AsSpan("proc:".Length), out processId) && processId > 0; + } +} diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index 8fb67ae..27c712d 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -1054,14 +1054,12 @@ public sealed class AudioReceiver : IDisposable } sp = playoutEngine.GetOrCreateSession(remote, streamId, MaxBufferCapacityBytes(MaxLatencyForSizingMs)); - // Tag the session with the wire-announced render route. For classic-mode senders - // (or pre-2026-05-11 builds) this is always Mixed and PlayoutEngine treats the - // session exactly as it always did. BothIndependent senders will tag their two - // lanes with WasapiLane / AsioLane so the per-route surfaces direct each lane to - // the matching render backend without mixing. Updated unconditionally so an - // in-place format change can re-route a session (e.g. a sender that mistakenly - // started in classic mode and re-announces with the right lane mid-stream). - sp.Route = format.Lane; + // Output routing is now owned by PlayoutEngine: every received stream is fanned out to EVERY + // active output lane (a primary plus a mirror replica per extra lane), so the sender's + // captured-lane tag (format.Lane) no longer decides where the audio plays — it plays on all + // the receiver's outputs. GetOrCreateSession assigns each replica its output lane. (The + // sender still announces format.Lane on the wire for back-compat; the receiver ignores it + // for routing.) if (existing is null) { diff --git a/src/RemSound.Receiver/PeerDspChain.cs b/src/RemSound.Receiver/PeerDspChain.cs index db470bf..5476f3c 100644 --- a/src/RemSound.Receiver/PeerDspChain.cs +++ b/src/RemSound.Receiver/PeerDspChain.cs @@ -22,16 +22,28 @@ public sealed class PeerDspChain // carries per-channel state. left.Length == right.Length always. private readonly BiQuadFilter[] left; private readonly BiQuadFilter[] right; + // The inputs this chain was built from, kept so a mirror output lane can get its OWN chain with + // fresh (independent) biquad state via Clone() — two output lanes reading the same peer must NOT + // share biquad delay lines, or their interleaved reads corrupt each other's filter state. + private readonly PeerShaping? sourceShaping; + private readonly bool sourceEnabled; - private PeerDspChain(float gainL, float gainR, bool hasGain, BiQuadFilter[] left, BiQuadFilter[] right) + private PeerDspChain(float gainL, float gainR, bool hasGain, BiQuadFilter[] left, BiQuadFilter[] right, PeerShaping? sourceShaping, bool sourceEnabled) { this.gainL = gainL; this.gainR = gainR; this.hasGain = hasGain; this.left = left; this.right = right; + this.sourceShaping = sourceShaping; + this.sourceEnabled = sourceEnabled; } + /// A fresh chain identical in coefficients/gain but with its OWN zeroed biquad state, for a + /// second output lane playing the same peer. Cheap (rebuilds a handful of biquads); the brief + /// zero-state settle is sub-millisecond, same as any DSP change. + public PeerDspChain Clone() => Build(sourceShaping, sourceEnabled) ?? this; + /// True when this chain would do nothing (unity gain — pan off/centre, volume 100% — and /// EQ off/flat). Build returns null in that case so an unshaped peer's dsp reference is null /// and it pays nothing. @@ -86,7 +98,7 @@ public sealed class PeerDspChain } } - var chain = new PeerDspChain(gainL, gainR, hasGain, [.. l], [.. r]); + var chain = new PeerDspChain(gainL, gainR, hasGain, [.. l], [.. r], shaping, enabled); return chain.IsNoOp ? null : chain; } diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs index 63a57c7..15fafe5 100644 --- a/src/RemSound.Receiver/PlayoutEngine.cs +++ b/src/RemSound.Receiver/PlayoutEngine.cs @@ -54,6 +54,18 @@ internal sealed class PlayoutEngine : IWaveProvider private Action>? recordTap; private bool recordTapRaw; private SessionPlayout[] sessionsSnapshot = []; + // Mirror replicas for the "every stream plays to every output" fan-out (BothIndependent). The + // PRIMARY per stream lives in `sessions` (the decoder writes to it, tagged with the first active + // output lane); these are the EXTRA replicas, one per additional active output lane, each fed the + // same decoded audio via the primary's mirror list and tagged with its own output lane. Reconciled + // whenever the active output lanes change (SetLaneActive) or a stream is created. Guarded by + // sessionsLock. `sessionsSnapshot` above includes both primaries and mirrors, so the existing + // per-Route reads and per-Route auto-tune aggregators route each replica to exactly its own output. + private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), List> mirrorsByKey = new(); + // The lane that drives RECORDING (the primary lane / first active output). The received-mix record + // dispatch and the per-block record hook fire only on this route, so a fanned-out stream is recorded + // once, not once per output. The per-peer record tap lives only on the primaries (in `sessions`). + private volatile RenderRoute recordingRoute = RenderRoute.Mixed; // Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on // its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render // thread, independent). They must not share scratch arrays — concurrent writes would @@ -124,12 +136,19 @@ internal sealed class PlayoutEngine : IWaveProvider /// first place; the orphan-fall-through logic is benign in that case. public void SetLaneActive(RenderRoute lane, bool active) { - switch (lane) + lock (sessionsLock) { - case RenderRoute.WasapiLane: wasapiLaneActive = active; break; - case RenderRoute.AsioLane: asioLaneActive = active; break; - // RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane; - // no flag needed. + switch (lane) + { + case RenderRoute.WasapiLane: wasapiLaneActive = active; break; + case RenderRoute.AsioLane: asioLaneActive = active; break; + // RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane; + // no flag needed. + } + // Re-fan-out every stream to the now-active set of output lanes: a newly-ticked device gets a + // mirror replica per existing stream; an un-ticked one has its replicas dropped. Idempotent, + // so the paired Wasapi+Asio calls CompositeRenderBackend makes don't double up. + ReconcileReplicasLocked(); } } @@ -154,6 +173,11 @@ internal sealed class PlayoutEngine : IWaveProvider else peerDspByAddress[address] = chain; foreach (var s in sessions.Values) if (s.Endpoint.Address.Equals(address)) s.SetDsp(chain); + // Mirror replicas each need their OWN chain instance (independent biquad state) — a fresh + // clone per mirror, or null to clear. Never share one chain across two output lanes. + foreach (var (key, mirs) in mirrorsByKey) + if (key.Endpoint.Address.Equals(address)) + foreach (var mir in mirs) mir.SetDsp(chain?.Clone()); } } @@ -350,7 +374,10 @@ internal sealed class PlayoutEngine : IWaveProvider if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain); if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw); sessions[key] = sp; - sessionsSnapshot = sessions.Values.ToArray(); + // Assigns the primary's output lane and creates a mirror replica per additional active + // output lane (fan-out), then rebuilds the snapshot. In WasapiOnly this is a no-op beyond + // setting the route (one active lane), so classic behaviour is unchanged. + ReconcileReplicasLocked(); } return sp; } @@ -364,13 +391,90 @@ internal sealed class PlayoutEngine : IWaveProvider if (sessions.Remove(key, out var sp)) { sp.Dispose(); - sessionsSnapshot = sessions.Values.ToArray(); + if (mirrorsByKey.Remove(key, out var mirs)) + foreach (var m in mirs) m.Dispose(); + RebuildSnapshotLocked(); return true; } return false; } } + /// The active output lanes, in priority order (WASAPI first). Drives which lane a stream's + /// primary uses and how many mirror replicas it needs. Caller holds sessionsLock. + private List ActiveOutputLanesLocked() + { + var lanes = new List(2); + if (wasapiLaneActive) lanes.Add(RenderRoute.WasapiLane); + if (asioLaneActive) lanes.Add(RenderRoute.AsioLane); + return lanes; + } + + /// Reconcile every stream's replicas to the current active output lanes: put the primary on + /// the first active lane and keep exactly one mirror per additional active lane (each a full, + /// independent SessionPlayout fed the same decoded audio). Idempotent, so it's cheap to call on + /// every lane change. In WasapiOnly there is one active lane, so no mirrors are made and behaviour is + /// unchanged. Caller holds sessionsLock. + private void ReconcileReplicasLocked() + { + var lanes = ActiveOutputLanesLocked(); + if (lanes.Count > 0) recordingRoute = lanes[0]; + + foreach (var (key, primary) in sessions) + { + if (lanes.Count == 0) + { + DropMirrorsLocked(key, primary); + continue; + } + primary.Route = lanes[0]; + // Wanted mirror lanes = every active lane except the primary's. + var wanted = lanes.Count > 1 ? lanes.GetRange(1, lanes.Count - 1) : null; + if (wanted is null || wanted.Count == 0) + { + DropMirrorsLocked(key, primary); + continue; + } + var existing = mirrorsByKey.TryGetValue(key, out var m) ? m : new List(); + // Drop mirrors whose lane is no longer active. + for (var i = existing.Count - 1; i >= 0; i--) + if (!wanted.Contains(existing[i].Route)) { existing[i].Dispose(); existing.RemoveAt(i); } + // Add a mirror for each wanted lane not already present. + foreach (var lane in wanted) + { + if (existing.Exists(x => x.Route == lane)) continue; + var mir = new SessionPlayout(primary.Endpoint, primary.StreamId, primary.Capacity) { Route = lane }; + mir.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw); + if (peerDspByAddress.TryGetValue(primary.Endpoint.Address, out var chain) && chain is not null) + mir.SetDsp(chain.Clone()); // its own filter state — must NOT share with the primary + // Deliberately NO record tap on mirrors: recording follows the primary/recordingRoute only. + existing.Add(mir); + } + mirrorsByKey[key] = existing; + primary.SetMirrors(existing.ToArray()); + } + RebuildSnapshotLocked(); + } + + private void DropMirrorsLocked((IPEndPoint Endpoint, ushort StreamId) key, SessionPlayout primary) + { + if (mirrorsByKey.Remove(key, out var mirs)) + { + foreach (var m in mirs) m.Dispose(); + primary.SetMirrors([]); + } + } + + /// Rebuild the lock-free snapshot the render/tune threads read: primaries plus all mirrors. + /// The per-Route reads and per-Route tune aggregators filter by Route, so each replica is routed to + /// exactly its own output lane. Caller holds sessionsLock. + private void RebuildSnapshotLocked() + { + var all = new List(sessions.Values); + foreach (var list in mirrorsByKey.Values) all.AddRange(list); + sessionsSnapshot = all.ToArray(); + } + public IReadOnlyList ActiveSessions { get { lock (sessionsLock) return sessions.Values.ToList(); } @@ -381,7 +485,10 @@ internal sealed class PlayoutEngine : IWaveProvider lock (sessionsLock) { foreach (var s in sessions.Values) s.Dispose(); + foreach (var list in mirrorsByKey.Values) + foreach (var m in list) m.Dispose(); sessions.Clear(); + mirrorsByKey.Clear(); sessionsSnapshot = []; } } @@ -817,14 +924,17 @@ internal sealed class PlayoutEngine : IWaveProvider if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats)); - // Recording tap (per-lane). Mix is fully processed at this point — volume, mute and - // limiter have all been applied — so the recorder sees exactly what the user is - // about to hear from this lane. Tagged with `route` so the recorder can keep WASAPI- - // lane and ASIO-lane streams separate (each lane fires this method independently in - // BothIndependent; without the tag both ended up in one recorder ring, doubling the - // file's effective sample rate). - DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route); - OnRecordBlockComplete?.Invoke(outFloats); + // Recording is driven by ONE lane only (recordingRoute = the primary/first-active lane). With + // the every-stream-to-every-output fan-out, both lanes now render the same streams, so firing + // the record hooks on both would record every stream twice. The primary lane already carries a + // replica of every stream (and holds the per-peer record taps), so recording from it alone + // captures everything once. Mix is fully processed here (volume, mute, limiter applied), so the + // recorder still sees exactly what the user hears. + if (route == recordingRoute) + { + DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route); + OnRecordBlockComplete?.Invoke(outFloats); + } Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); return count; diff --git a/src/RemSound.Receiver/ReceiverSelfChecks.cs b/src/RemSound.Receiver/ReceiverSelfChecks.cs new file mode 100644 index 0000000..c87773a --- /dev/null +++ b/src/RemSound.Receiver/ReceiverSelfChecks.cs @@ -0,0 +1,56 @@ +using System.Net; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Public entry points for the app's --selftest that need to drive the receiver's INTERNAL +/// playout engine directly (PlayoutEngine / SessionPlayout are internal to this assembly). Keeps the +/// test logic next to the code it exercises without widening those types' visibility. +/// +public static class ReceiverSelfChecks +{ + /// Proves the "every received stream plays to EVERY active output" fan-out. With both output + /// lanes active (BothIndependent), one incoming stream must produce audio on BOTH the WASAPI and the + /// ASIO lane surface — WASAPI from the primary session, ASIO from its mirror replica. Returns null on + /// success, or a message describing which lane was silent. (Before the fan-out, only the lane matching + /// the sender's capture tag played and the other output was silent.) + public static string? FanOutToBothOutputs() + { + const int rate = 48000, ch = 2, bpf = ch * 4; + var engine = new PlayoutEngine(new ReceiverDiagnostics()); + // Both output lanes active = BothIndependent. + engine.SetLaneActive(RenderRoute.WasapiLane, true); + engine.SetLaneActive(RenderRoute.AsioLane, true); + + var endpoint = new IPEndPoint(IPAddress.Loopback, 40000); + var sp = engine.GetOrCreateSession(endpoint, 1234, rate * bpf); // 1 s ring + + // ~200 ms of a constant, clearly non-zero stereo signal, then arm playback. + var floats = new float[rate / 5 * ch]; + Array.Fill(floats, 0.25f); + var block = new byte[floats.Length * 4]; + Buffer.BlockCopy(floats, 0, block, 0, block.Length); + sp.Write(block); + sp.NoteFramesQueued(5); + + // Read a 10 ms block from each lane surface: WASAPI = primary, ASIO = mirror. + var rd = rate / 100 * bpf; + var w = new byte[rd]; + var a = new byte[rd]; + engine.WasapiLaneOutput.Read(w, 0, rd); + engine.AsioLaneOutput.Read(a, 0, rd); + + if (!HasAudio(w)) return "WASAPI output lane produced silence — the fan-out primary isn't playing"; + if (!HasAudio(a)) return "ASIO output lane produced silence — the mirror isn't playing (the second output would be silent)"; + return null; + } + + private static bool HasAudio(byte[] pcmFloat) + { + var f = new float[pcmFloat.Length / 4]; + Buffer.BlockCopy(pcmFloat, 0, f, 0, pcmFloat.Length); + foreach (var v in f) if (Math.Abs(v) > 0.001f) return true; + return false; + } +} diff --git a/src/RemSound.Receiver/SessionPlayout.cs b/src/RemSound.Receiver/SessionPlayout.cs index 614d735..dcde6a2 100644 --- a/src/RemSound.Receiver/SessionPlayout.cs +++ b/src/RemSound.Receiver/SessionPlayout.cs @@ -321,6 +321,9 @@ internal sealed class SessionPlayout : IDisposable /// contributes to. Defaults to — the value an old /// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes. public RenderRoute Route { get; set; } = RenderRoute.Mixed; + /// Ring capacity this session was sized to (bytes) — so a mirror replica for another + /// output lane can be created with the same capacity. + public int Capacity { get; } public int BufferedBytes => playout.BufferedBytes; public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate; public long UnderrunCount => playout.UnderrunCount; @@ -369,6 +372,7 @@ internal sealed class SessionPlayout : IDisposable { Endpoint = endpoint; StreamId = streamId; + Capacity = capacityBytes; playout = new AudioRingBuffer(capacityBytes); // Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation @@ -384,7 +388,26 @@ internal sealed class SessionPlayout : IDisposable driftResampler.SetRates(MixSampleRate, MixSampleRate); } + // Mirror replicas. In BothIndependent mode the SAME decoded audio is fanned out to one extra + // SessionPlayout per additional output lane, so every output device plays every stream. Each + // mirror is a FULL, independent SessionPlayout — its own ring, drift resampler, arming and + // concealment — so each output keeps its own clock and latency with no shared cache and no extra + // latency (identical to a single-output setup, by construction). Only the decode-side fan-out lives + // here; ReadFloats (the tuned drift/trim/conceal math) is untouched and each replica runs it alone. + // Replaced wholesale (reference swap) by the UI thread; the network/audio threads only read it. + private volatile SessionPlayout[] mirrors = []; + public void SetMirrors(SessionPlayout[] value) => mirrors = value; + public void Write(ReadOnlySpan source) + { + WriteLocal(source); + var mir = mirrors; + for (var i = 0; i < mir.Length; i++) mir[i].WriteLocal(source); + } + + /// The single-instance write body — used directly for a mirror replica (so a mirror never + /// re-fans-out) and by for the primary before it forwards to its mirrors. + private void WriteLocal(ReadOnlySpan source) { var ms = source.Length * 1000 / MixBytesPerSecond; if (ms > largestWriteMs) largestWriteMs = ms; @@ -412,6 +435,16 @@ internal sealed class SessionPlayout : IDisposable /// by the buffer + drift corrector + click-trim combo. /// public void NoteFramesQueued(int targetLatencyMs) + { + NoteFramesQueuedLocal(targetLatencyMs); + // Each mirror arms independently on its own ring (same bytes arrive at the same rate, so they + // arm at ~the same moment). Forwarded here rather than called separately so the decode side + // only ever touches the primary. + var mir = mirrors; + for (var i = 0; i < mir.Length; i++) mir[i].NoteFramesQueuedLocal(targetLatencyMs); + } + + private void NoteFramesQueuedLocal(int targetLatencyMs) { const int CatastrophicCapMs = 1000; const int CatastrophicTrimToMs = 250; diff --git a/src/RemSound.Sender/AudioAppEnumerator.cs b/src/RemSound.Sender/AudioAppEnumerator.cs new file mode 100644 index 0000000..9bdacc0 --- /dev/null +++ b/src/RemSound.Sender/AudioAppEnumerator.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using NAudio.CoreAudioApi; +using NAudio.CoreAudioApi.Interfaces; + +namespace RemSound.Sender; + +/// One application that currently has an audio session, for the "send specific applications" +/// picker. is the stable identity (lower-case, no path/extension), tracked +/// across restarts; is the current process id used to open a process-loopback capture +/// and is transient. is true when at least one of the app's sessions is active +/// (actually producing sound right now) rather than merely open. +public sealed record AudioApp(string ProcessName, string DisplayName, int Pid, bool Playing); + +/// +/// Enumerates the applications that currently have audio sessions on the machine's render devices, for +/// the per-application send picker. Snapshot-only: every call re-enumerates from scratch and RELEASES +/// the NAudio session objects immediately — holding them is what piles up (the OS never expires a +/// session while a reference is alive). Best-effort throughout; a device that can't be read is skipped. +/// +public static class AudioAppEnumerator +{ + /// Current audio apps, one entry per process (merged across sessions and devices), sorted by + /// display name. Never throws. + public static IReadOnlyList Snapshot() + { + // Merge by process name: an app can have several sessions (and PIDs); it "plays" if any is active. + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var en = new MMDeviceEnumerator(); + foreach (var device in en.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)) + { + try + { + var mgr = device.AudioSessionManager; // realises the session manager for this device + mgr.RefreshSessions(); + var sessions = mgr.Sessions; + for (var i = 0; i < sessions.Count; i++) + { + var session = sessions[i]; + try + { + if (session.IsSystemSoundsSession) continue; + var pid = (int)session.GetProcessID; + if (pid <= 0) continue; + var playing = session.State == AudioSessionState.AudioSessionStateActive; + var (name, display) = ResolveProcess(pid); + if (name.Length == 0) continue; + if (byName.TryGetValue(name, out var existing)) + { + // Prefer a playing PID as the representative; keep any "playing" flag. + byName[name] = (existing.Display, playing && !existing.Playing ? pid : existing.Pid, existing.Playing || playing); + } + else + { + byName[name] = (display, pid, playing); + } + } + catch { /* skip a session we can't read */ } + // NOTE: do NOT retain `session`; NAudio's AudioSessionControl holds a COM ref and + // the OS never expires it while referenced. We've taken what we need; drop it. + } + } + catch { /* device without a usable session manager — skip */ } + finally { try { device.Dispose(); } catch { } } + } + } + catch { /* enumerator failure — return whatever we gathered */ } + + return byName + .Select(kv => new AudioApp(kv.Key, kv.Value.Display, kv.Value.Pid, kv.Value.Playing)) + .OrderBy(a => a.DisplayName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + + /// The current PIDs for a process NAME (an app may run several processes). Used at capture + /// time to resolve a name-based selection to the live processes to loopback-capture. + public static IReadOnlyList PidsForProcessName(string processName) + { + try + { + return Process.GetProcessesByName(processName).Select(p => + { + var id = 0; + try { id = p.Id; } catch { } + finally { try { p.Dispose(); } catch { } } + return id; + }).Where(id => id > 0).ToList(); + } + catch { return Array.Empty(); } + } + + // (name, friendly display) for a PID. Name = lower-case ProcessName (no .exe) — the stable identity. + // Display = the exe's FileDescription when readable (e.g. "VLC media player"), else the process name. + private static (string Name, string Display) ResolveProcess(int pid) + { + try + { + using var p = Process.GetProcessById(pid); + var name = p.ProcessName; // already no ".exe" + if (string.IsNullOrWhiteSpace(name)) return ("", ""); + var display = name; + try + { + var desc = p.MainModule?.FileVersionInfo.FileDescription; + if (!string.IsNullOrWhiteSpace(desc)) display = desc!; + } + catch { /* MainModule can throw (access denied / 32-vs-64) — fall back to the name */ } + return (name.ToLowerInvariant(), display); + } + catch { return ("", ""); } + } +} diff --git a/src/RemSound.Sender/CaptureSource.cs b/src/RemSound.Sender/CaptureSource.cs index 075d30b..935671a 100644 --- a/src/RemSound.Sender/CaptureSource.cs +++ b/src/RemSound.Sender/CaptureSource.cs @@ -36,7 +36,7 @@ internal sealed class CaptureSource : IDisposable private const int CaptureBufferMs = 10; private const int RingBufferMs = 250; - private readonly WasapiCapture capture; + private readonly IWaveIn capture; private readonly BufferedWaveProvider buffer; private readonly Action? onDiagnostic; private long callbackCount; @@ -56,15 +56,25 @@ internal sealed class CaptureSource : IDisposable (int)(buffer.BufferedDuration.TotalMilliseconds); public CaptureSource(MMDevice device, CaptureKind kind, string displayName, Action? onDiagnostic = null) + : this( + kind == CaptureKind.Loopback + ? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs) + : new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs), + kind, device.ID, displayName, onDiagnostic) + { + } + + /// Wraps an arbitrary capture — used for per-application + /// process-loopback (), where the source isn't an + /// and is the synthetic "proc:<pid>" id. + public CaptureSource(IWaveIn waveIn, CaptureKind kind, string deviceId, string displayName, Action? onDiagnostic = null) { Name = displayName; Kind = kind; - DeviceId = device.ID; + DeviceId = deviceId; this.onDiagnostic = onDiagnostic; - capture = kind == CaptureKind.Loopback - ? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs) - : new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs); + capture = waveIn; var captureFormat = capture.WaveFormat; CaptureFormatDescription = diff --git a/src/RemSound.Sender/MixingEngine.cs b/src/RemSound.Sender/MixingEngine.cs index c8a6337..f586e59 100644 --- a/src/RemSound.Sender/MixingEngine.cs +++ b/src/RemSound.Sender/MixingEngine.cs @@ -265,6 +265,26 @@ internal sealed class MixingEngine : ICaptureBackend /// private ActiveSource? OpenSource(CaptureSourceSpec spec) { + // Per-application source: no MMDevice, no render keepalive — the process-loopback client + // captures the app's render stream directly. If the app has exited, the PID is stale and + // activation fails; the caller simply drops it and the next reconcile re-resolves the name. + if (spec.Kind == CaptureKind.ProcessLoopback) + { + try + { + if (!ProcessLoopbackId.TryParse(spec.DeviceId, out var pid)) + throw new ArgumentException($"bad process-loopback id \"{spec.DeviceId}\""); + var proc = new ProcessLoopbackCapture(pid); + var src = new CaptureSource(proc, spec.Kind, spec.DeviceId, spec.Name, onDiagnostic); + return new ActiveSource { Source = src, KeepAlive = null, Device = null }; + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mixer: failed to open app source \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + MMDevice? device = null; try { @@ -302,7 +322,7 @@ internal sealed class MixingEngine : ICaptureBackend { try { a.KeepAlive?.Dispose(); } catch { /* ignore */ } try { a.Source.Dispose(); } catch { /* ignore */ } - try { a.Device.Dispose(); } catch { /* ignore */ } + try { a.Device?.Dispose(); } catch { /* ignore */ } } private static string SourceKey(string deviceId, CaptureKind kind) => $"{deviceId}|{kind}"; @@ -391,7 +411,8 @@ internal sealed class MixingEngine : ICaptureBackend private sealed class ActiveSource { public required CaptureSource Source { get; init; } - public required MMDevice Device { get; init; } + // Null for per-application (process-loopback) sources, which have no MMDevice. + public required MMDevice? Device { get; init; } public SilentRenderKeepAlive? KeepAlive { get; init; } } } diff --git a/src/RemSound.Sender/ProcessLoopbackCapture.cs b/src/RemSound.Sender/ProcessLoopbackCapture.cs new file mode 100644 index 0000000..43a41bd --- /dev/null +++ b/src/RemSound.Sender/ProcessLoopbackCapture.cs @@ -0,0 +1,329 @@ +using System.Runtime.InteropServices; +using System.Threading; +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace RemSound.Sender; + +/// +/// Captures the audio rendered by ONE process (and its child-process tree) using the Windows +/// process-loopback API — ActivateAudioInterfaceAsync against the virtual device +/// VAD\Process_Loopback with AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK. This is what +/// makes "send only specific applications" possible: unlike ordinary WASAPI loopback (which captures a +/// whole output device), this isolates a single app's render stream. +/// +/// Hand-rolled COM interop because NAudio has no binding for the process-loopback activation path. +/// Requires Windows 10 build 19041 (20H1) or newer; on older systems the app-send feature is hidden in +/// the UI so this type is never constructed. Presents as a standard so it drops +/// straight into alongside the existing WASAPI/ASIO backends. +/// +/// The mix format for a process-loopback client is not discoverable (GetMixFormat returns +/// E_NOTIMPL for this virtual device), so we request a fixed 48 kHz / 32-bit-float / stereo shared-mode +/// format — the same format the rest of the pipeline already runs at — and Windows resamples the app's +/// audio to it for us. +/// +public sealed class ProcessLoopbackCapture : IWaveIn +{ + private const string VirtualDevicePath = "VAD\\Process_Loopback"; + + // Fixed capture format — see class remarks. 48 kHz, IEEE float, stereo. + private static readonly WaveFormat CaptureFormat = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2); + + private const int AUDCLNT_SHAREMODE_SHARED = 0; + private const uint AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000; + private const uint AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000; + private const uint AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000; + private const long BufferDurationHns = 20 * 10_000; // 20 ms in 100-ns units + + private readonly int targetPid; + private readonly bool includeTree; + private IAudioClient? audioClient; + private IAudioCaptureClient? captureClient; + private EventWaitHandle? bufferReady; + private Thread? captureThread; + private volatile bool running; + + public WaveFormat WaveFormat { get; set; } = CaptureFormat; + + public event EventHandler? DataAvailable; + public event EventHandler? RecordingStopped; + + /// The PID whose render audio to capture. + /// Capture the PID and all of its descendant processes + /// (INCLUDE tree) — the normal choice, since browsers and media apps spawn child renderers. + public ProcessLoopbackCapture(int processId, bool includeProcessTree = true) + { + targetPid = processId; + includeTree = includeProcessTree; + } + + /// True on Windows builds new enough for the process-loopback API (10.0.19041+). + public static bool IsSupported => + OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041); + + public void StartRecording() + { + if (running) return; + if (!IsSupported) + throw new PlatformNotSupportedException("Process-loopback capture needs Windows 10 build 19041 or newer."); + + Activate(); + running = true; + captureThread = new Thread(CaptureLoop) + { + IsBackground = true, + Name = $"proc-loopback-{targetPid}", + Priority = ThreadPriority.AboveNormal, + }; + captureThread.Start(); + } + + public void StopRecording() + { + if (!running && captureThread == null) return; + running = false; + bufferReady?.Set(); // wake the loop so it can exit + captureThread?.Join(500); + captureThread = null; + try { audioClient?.Stop(); } catch { } + RecordingStopped?.Invoke(this, new StoppedEventArgs()); + } + + private void Activate() + { + // Build the activation params: process-loopback for our PID with the include-tree mode. + var loopbackParams = new AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS + { + TargetProcessId = (uint)targetPid, + ProcessLoopbackMode = includeTree + ? PROCESS_LOOPBACK_MODE.INCLUDE_TARGET_PROCESS_TREE + : PROCESS_LOOPBACK_MODE.EXCLUDE_TARGET_PROCESS_TREE, + }; + + var activationParams = new AUDIOCLIENT_ACTIVATION_PARAMS + { + ActivationType = AUDIOCLIENT_ACTIVATION_TYPE.PROCESS_LOOPBACK, + ProcessLoopbackParams = loopbackParams, + }; + + var paramsSize = Marshal.SizeOf(); + var paramsPtr = Marshal.AllocHGlobal(paramsSize); + var propVariant = default(PROPVARIANT); + try + { + Marshal.StructureToPtr(activationParams, paramsPtr, false); + propVariant.vt = 65; // VT_BLOB + propVariant.blobSize = (uint)paramsSize; + propVariant.blobData = paramsPtr; + + var handler = new ActivationHandler(); + var iidAudioClient = typeof(IAudioClient).GUID; + var hr = ActivateAudioInterfaceAsync(VirtualDevicePath, ref iidAudioClient, ref propVariant, handler, out _); + if (hr != 0) Marshal.ThrowExceptionForHR(hr); + + if (!handler.Completed.WaitOne(3000)) + throw new TimeoutException("Process-loopback activation timed out."); + if (handler.ActivateResult != 0) Marshal.ThrowExceptionForHR(handler.ActivateResult); + + audioClient = (IAudioClient)handler.Interface!; + } + finally + { + Marshal.FreeHGlobal(paramsPtr); + } + + var formatPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(CaptureFormat, formatPtr, false); + var flags = AUDCLNT_STREAMFLAGS_LOOPBACK + | AUDCLNT_STREAMFLAGS_EVENTCALLBACK + | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM; + var hr = audioClient!.Initialize(AUDCLNT_SHAREMODE_SHARED, flags, BufferDurationHns, 0, formatPtr, IntPtr.Zero); + if (hr != 0) Marshal.ThrowExceptionForHR(hr); + } + finally + { + Marshal.FreeHGlobal(formatPtr); + } + + bufferReady = new EventWaitHandle(false, EventResetMode.AutoReset); + var setHr = audioClient!.SetEventHandle(bufferReady.SafeWaitHandle.DangerousGetHandle()); + if (setHr != 0) Marshal.ThrowExceptionForHR(setHr); + + var iidCapture = typeof(IAudioCaptureClient).GUID; + var svcHr = audioClient.GetService(ref iidCapture, out var svc); + if (svcHr != 0) Marshal.ThrowExceptionForHR(svcHr); + captureClient = (IAudioCaptureClient)svc; + + var startHr = audioClient.Start(); + if (startHr != 0) Marshal.ThrowExceptionForHR(startHr); + } + + private void CaptureLoop() + { + Exception? failure = null; + var frameBytes = CaptureFormat.BlockAlign; // 8 bytes (2ch * float) + try + { + while (running) + { + if (bufferReady!.WaitOne(200) == false) continue; + if (!running) break; + + while (true) + { + var hr = captureClient!.GetBuffer(out var dataPtr, out var frames, out var flags, out _, out _); + if (hr != 0) + { + // AUDCLNT_S_BUFFER_EMPTY (0x08890001) — nothing to read this wake. + if ((uint)hr == 0x08890001) break; + Marshal.ThrowExceptionForHR(hr); + } + if (frames == 0) break; + + var byteCount = frames * frameBytes; + var buffer = new byte[byteCount]; + const int AUDCLNT_BUFFERFLAGS_SILENT = 0x2; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) + Marshal.Copy(dataPtr, buffer, 0, byteCount); + // else leave zeroed — WASAPI signalled a silent packet. + + captureClient.ReleaseBuffer(frames); + DataAvailable?.Invoke(this, new WaveInEventArgs(buffer, byteCount)); + } + } + } + catch (Exception ex) + { + failure = ex; + } + finally + { + if (failure != null) + RecordingStopped?.Invoke(this, new StoppedEventArgs(failure)); + } + } + + public void Dispose() + { + StopRecording(); + if (captureClient != null) { try { Marshal.ReleaseComObject(captureClient); } catch { } captureClient = null; } + if (audioClient != null) { try { Marshal.ReleaseComObject(audioClient); } catch { } audioClient = null; } + bufferReady?.Dispose(); + bufferReady = null; + } + + // ---- Async activation completion handler ------------------------------------------------- + + [ComVisible(true)] + private sealed class ActivationHandler : IActivateAudioInterfaceCompletionHandler + { + public readonly EventWaitHandle Completed = new(false, EventResetMode.ManualReset); + public int ActivateResult { get; private set; } = unchecked((int)0x80004005); // E_FAIL until proven otherwise + public object? Interface { get; private set; } + + public void ActivateCompleted(IActivateAudioInterfaceAsyncOperation activateOperation) + { + try + { + activateOperation.GetActivateResult(out var hr, out var iface); + ActivateResult = hr; + Interface = iface; + } + catch (Exception ex) + { + ActivateResult = ex.HResult != 0 ? ex.HResult : unchecked((int)0x80004005); + } + finally + { + Completed.Set(); + } + } + } + + // ---- Native declarations ----------------------------------------------------------------- + + [DllImport("Mmdevapi.dll", ExactSpelling = true, PreserveSig = true)] + private static extern int ActivateAudioInterfaceAsync( + [MarshalAs(UnmanagedType.LPWStr)] string deviceInterfacePath, + ref Guid riid, + ref PROPVARIANT activationParams, + IActivateAudioInterfaceCompletionHandler completionHandler, + out IActivateAudioInterfaceAsyncOperation activationOperation); + + private enum AUDIOCLIENT_ACTIVATION_TYPE { DEFAULT = 0, PROCESS_LOOPBACK = 1 } + + private enum PROCESS_LOOPBACK_MODE { INCLUDE_TARGET_PROCESS_TREE = 0, EXCLUDE_TARGET_PROCESS_TREE = 1 } + + [StructLayout(LayoutKind.Sequential)] + private struct AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS + { + public uint TargetProcessId; + public PROCESS_LOOPBACK_MODE ProcessLoopbackMode; + } + + [StructLayout(LayoutKind.Sequential)] + private struct AUDIOCLIENT_ACTIVATION_PARAMS + { + public AUDIOCLIENT_ACTIVATION_TYPE ActivationType; + public AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS ProcessLoopbackParams; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROPVARIANT + { + public ushort vt; + public ushort r1, r2, r3; + public uint blobSize; + public IntPtr blobData; + public IntPtr padding; // keep the union large enough on 64-bit + } +} + +// ---- COM interfaces (declared in vtable order — DO NOT reorder methods) ----------------------- + +[ComImport, Guid("72A22D78-CDE4-4B31-B8CC-843A71199B6D"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IActivateAudioInterfaceAsyncOperation +{ + void GetActivateResult(out int activateResult, + [MarshalAs(UnmanagedType.IUnknown)] out object activatedInterface); +} + +[ComImport, Guid("41D949AB-9862-444A-80F6-C261334DA5EB"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IActivateAudioInterfaceCompletionHandler +{ + void ActivateCompleted(IActivateAudioInterfaceAsyncOperation activateOperation); +} + +[ComImport, Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioClient +{ + [PreserveSig] int Initialize(int shareMode, uint streamFlags, long hnsBufferDuration, + long hnsPeriodicity, IntPtr format, IntPtr audioSessionGuid); + [PreserveSig] int GetBufferSize(out uint bufferFrames); + [PreserveSig] int GetStreamLatency(out long latency); + [PreserveSig] int GetCurrentPadding(out uint padding); + [PreserveSig] int IsFormatSupported(int shareMode, IntPtr format, out IntPtr closestMatch); + [PreserveSig] int GetMixFormat(out IntPtr deviceFormat); + [PreserveSig] int GetDevicePeriod(out long defaultPeriod, out long minimumPeriod); + [PreserveSig] int Start(); + [PreserveSig] int Stop(); + [PreserveSig] int Reset(); + [PreserveSig] int SetEventHandle(IntPtr eventHandle); + [PreserveSig] int GetService(ref Guid interfaceId, + [MarshalAs(UnmanagedType.IUnknown)] out object instance); +} + +[ComImport, Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IAudioCaptureClient +{ + [PreserveSig] int GetBuffer(out IntPtr dataBuffer, out int framesToRead, + out int flags, out long devicePosition, out long qpcPosition); + [PreserveSig] int ReleaseBuffer(int framesRead); + [PreserveSig] int GetNextPacketSize(out int framesInNextPacket); +}