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:<pid>").
- 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:<pid>" 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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-12 05:58:33 +01:00
co-authored by Claude Opus 4.8
parent 2fb9274a95
commit 8a7c4ddf2b
13 changed files with 856 additions and 38 deletions
+67 -6
View File
@@ -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
/// <see cref="PeerListItem.DisplayNameProvider"/>.</summary>
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<CaptureSourceSpec>();
var addedLoopbackIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? followedDefaultLoopback = null;
foreach (var item in sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>())
{
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<string>(StringComparer.OrdinalIgnoreCase);
string? followedDefaultInput = null;
foreach (var item in sendInputDevicesList.CheckedItems.OfType<AudioDeviceChoice>())
@@ -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)
+36
View File
@@ -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";
}
/// <summary>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).</summary>
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)";
}
/// <summary>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.</summary>
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}";
}
/// <summary>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.</summary>
+18
View File
@@ -225,6 +225,24 @@ public sealed class AppConfig
/// the same idea for the send-input list.</summary>
public bool UseDefaultOutputDevice { get; set; }
public bool UseDefaultInputDevice { get; set; }
/// <summary>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.</summary>
public bool UseDefaultLoopbackSend { get; set; }
/// <summary>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.</summary>
public string WasapiSendMode { get; set; } = "devices";
/// <summary>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 <see cref="SelectedSendApplications"/> are sent.
/// Mirrors the "Send all applications" master checkbox.</summary>
public bool SendAllApplications { get; set; } = true;
/// <summary>In "applications" send mode with <see cref="SendAllApplications"/> 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.</summary>
public List<string> SelectedSendApplications { get; set; } = new();
/// <summary>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
+21
View File
@@ -9,6 +9,11 @@ public enum CaptureKind
{
Loopback,
Input,
/// <summary>Per-application capture: loopback of ONE process (and its child process tree) via the
/// Windows process-loopback API (Windows 10 build 19041+). The <see cref="CaptureSourceSpec.DeviceId"/>
/// carries the target PID as <c>"proc:&lt;pid&gt;"</c> (see <see cref="ProcessLoopbackId"/>). WASAPI-only
/// — ASIO has no per-app concept.</summary>
ProcessLoopback,
}
/// <summary>
@@ -36,3 +41,19 @@ public static class AsioDeviceId
return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0;
}
}
/// <summary>Synthetic device-id for a per-application (process-loopback) capture: <c>"proc:&lt;pid&gt;"</c>.
/// 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.</summary>
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;
}
}
+6 -8
View File
@@ -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)
{
+14 -2
View File
@@ -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;
}
/// <summary>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.</summary>
public PeerDspChain Clone() => Build(sourceShaping, sourceEnabled) ?? this;
/// <summary>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 <c>dsp</c> reference is null
/// and it pays nothing.</summary>
@@ -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;
}
+118 -8
View File
@@ -54,6 +54,18 @@ internal sealed class PlayoutEngine : IWaveProvider
private Action<IPEndPoint, ReadOnlyMemory<float>>? 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<SessionPlayout>> 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
@@ -123,6 +135,8 @@ internal sealed class PlayoutEngine : IWaveProvider
/// lanes are inactive (no output device ticked at all) nothing's calling Read in the
/// first place; the orphan-fall-through logic is benign in that case.</summary>
public void SetLaneActive(RenderRoute lane, bool active)
{
lock (sessionsLock)
{
switch (lane)
{
@@ -131,6 +145,11 @@ internal sealed class PlayoutEngine : IWaveProvider
// 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();
}
}
/// <summary>Sets the concealment artifact for every active session and for any future
@@ -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;
}
}
/// <summary>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.</summary>
private List<RenderRoute> ActiveOutputLanesLocked()
{
var lanes = new List<RenderRoute>(2);
if (wasapiLaneActive) lanes.Add(RenderRoute.WasapiLane);
if (asioLaneActive) lanes.Add(RenderRoute.AsioLane);
return lanes;
}
/// <summary>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.</summary>
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<SessionPlayout>();
// 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([]);
}
}
/// <summary>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.</summary>
private void RebuildSnapshotLocked()
{
var all = new List<SessionPlayout>(sessions.Values);
foreach (var list in mirrorsByKey.Values) all.AddRange(list);
sessionsSnapshot = all.ToArray();
}
public IReadOnlyList<SessionPlayout> 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).
// 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;
@@ -0,0 +1,56 @@
using System.Net;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Public entry points for the app's <c>--selftest</c> 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.
/// </summary>
public static class ReceiverSelfChecks
{
/// <summary>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.)</summary>
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;
}
}
+33
View File
@@ -321,6 +321,9 @@ internal sealed class SessionPlayout : IDisposable
/// contributes to. Defaults to <see cref="RenderRoute.Mixed"/> — the value an old
/// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes.</summary>
public RenderRoute Route { get; set; } = RenderRoute.Mixed;
/// <summary>Ring capacity this session was sized to (bytes) — so a mirror replica for another
/// output lane can be created with the same capacity.</summary>
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<byte> source)
{
WriteLocal(source);
var mir = mirrors;
for (var i = 0; i < mir.Length; i++) mir[i].WriteLocal(source);
}
/// <summary>The single-instance write body — used directly for a mirror replica (so a mirror never
/// re-fans-out) and by <see cref="Write"/> for the primary before it forwards to its mirrors.</summary>
private void WriteLocal(ReadOnlySpan<byte> 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.
/// </summary>
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;
+113
View File
@@ -0,0 +1,113 @@
using System.Diagnostics;
using NAudio.CoreAudioApi;
using NAudio.CoreAudioApi.Interfaces;
namespace RemSound.Sender;
/// <summary>One application that currently has an audio session, for the "send specific applications"
/// picker. <see cref="ProcessName"/> is the stable identity (lower-case, no path/extension), tracked
/// across restarts; <see cref="Pid"/> is the current process id used to open a process-loopback capture
/// and is transient. <see cref="Playing"/> is true when at least one of the app's sessions is active
/// (actually producing sound right now) rather than merely open.</summary>
public sealed record AudioApp(string ProcessName, string DisplayName, int Pid, bool Playing);
/// <summary>
/// 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.
/// </summary>
public static class AudioAppEnumerator
{
/// <summary>Current audio apps, one entry per process (merged across sessions and devices), sorted by
/// display name. Never throws.</summary>
public static IReadOnlyList<AudioApp> Snapshot()
{
// Merge by process name: an app can have several sessions (and PIDs); it "plays" if any is active.
var byName = new Dictionary<string, (string Display, int Pid, bool Playing)>(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();
}
/// <summary>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.</summary>
public static IReadOnlyList<int> 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<int>(); }
}
// (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 ("", ""); }
}
}
+15 -5
View File
@@ -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<string>? 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<string>? onDiagnostic = null)
: this(
kind == CaptureKind.Loopback
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs),
kind, device.ID, displayName, onDiagnostic)
{
}
/// <summary>Wraps an arbitrary <see cref="IWaveIn"/> capture — used for per-application
/// process-loopback (<see cref="ProcessLoopbackCapture"/>), where the source isn't an
/// <see cref="MMDevice"/> and <paramref name="deviceId"/> is the synthetic <c>"proc:&lt;pid&gt;"</c> id.</summary>
public CaptureSource(IWaveIn waveIn, CaptureKind kind, string deviceId, string displayName, Action<string>? 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 =
+23 -2
View File
@@ -265,6 +265,26 @@ internal sealed class MixingEngine : ICaptureBackend
/// </summary>
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; }
}
}
@@ -0,0 +1,329 @@
using System.Runtime.InteropServices;
using System.Threading;
using NAudio.CoreAudioApi;
using NAudio.Wave;
namespace RemSound.Sender;
/// <summary>
/// Captures the audio rendered by ONE process (and its child-process tree) using the Windows
/// process-loopback API — <c>ActivateAudioInterfaceAsync</c> against the virtual device
/// <c>VAD\Process_Loopback</c> with <c>AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK</c>. 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.
///
/// <para>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 <see cref="IWaveIn"/> so it drops
/// straight into <see cref="CaptureSource"/> alongside the existing WASAPI/ASIO backends.</para>
///
/// <para>The mix format for a process-loopback client is not discoverable (<c>GetMixFormat</c> 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.</para>
/// </summary>
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<WaveInEventArgs>? DataAvailable;
public event EventHandler<StoppedEventArgs>? RecordingStopped;
/// <param name="processId">The PID whose render audio to capture.</param>
/// <param name="includeProcessTree">Capture the PID and all of its descendant processes
/// (INCLUDE tree) — the normal choice, since browsers and media apps spawn child renderers.</param>
public ProcessLoopbackCapture(int processId, bool includeProcessTree = true)
{
targetPid = processId;
includeTree = includeProcessTree;
}
/// <summary>True on Windows builds new enough for the process-loopback API (10.0.19041+).</summary>
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<AUDIOCLIENT_ACTIVATION_PARAMS>();
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<WaveFormat>());
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);
}