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);
+}