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:
co-authored by
Claude Opus 4.8
parent
2fb9274a95
commit
8a7c4ddf2b
@@ -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 };
|
new("Use Windows default audio device, follows Windows changes", "__use-default-output__", CaptureKind.Loopback) { IsDefaultFollower = true };
|
||||||
private static readonly AudioDeviceChoice DefaultInputFollower =
|
private static readonly AudioDeviceChoice DefaultInputFollower =
|
||||||
new("Use Windows default audio device, follows Windows changes", "__use-default-input__", CaptureKind.Input) { IsDefaultFollower = true };
|
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
|
// 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
|
// 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).
|
// these to spot it and re-route (see ReapplyIfFollowedDefaultChanged).
|
||||||
private string? lastFollowedDefaultOutputId;
|
private string? lastFollowedDefaultOutputId;
|
||||||
private string? lastFollowedDefaultInputId;
|
private string? lastFollowedDefaultInputId;
|
||||||
|
private string? lastFollowedDefaultLoopbackId;
|
||||||
|
|
||||||
// Receive-output device IDs the user/profile selected — kept even while a device is unplugged,
|
// 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
|
// 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(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device");
|
||||||
WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input 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) =>
|
sendInputDevicesList.ItemCheck += (_, args) =>
|
||||||
{
|
{
|
||||||
if (suppressDeviceCheckChange) return;
|
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).
|
// by every measure. 2026-06-02 (Ed's "offline but still sending audio" report).
|
||||||
var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3))
|
var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3))
|
||||||
|| IsEndpointHeartbeatHealthy(ep);
|
|| 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);
|
var ghost = new PeerAnnouncement(id, $"{label}{suffix}", ep.Port, true, true, DateTime.UtcNow, ep.Address);
|
||||||
desired.Add((new PeerListItem(ghost), id));
|
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
|
/// (which for a manual-by-IP peer is the address). Used by every peer list via
|
||||||
/// <see cref="PeerListItem.DisplayNameProvider"/>.</summary>
|
/// <see cref="PeerListItem.DisplayNameProvider"/>.</summary>
|
||||||
private string ResolvePeerDisplayName(PeerAnnouncement peer)
|
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
|
? np.FriendlyName
|
||||||
: peer.Name;
|
: 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;
|
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
|
// 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.
|
// (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed.
|
||||||
var specs = new List<CaptureSourceSpec>();
|
var specs = new List<CaptureSourceSpec>();
|
||||||
|
var addedLoopbackIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? followedDefaultLoopback = null;
|
||||||
foreach (var item in sendOutputDevicesList.CheckedItems.OfType<AudioDeviceChoice>())
|
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);
|
var addedInputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
string? followedDefaultInput = null;
|
string? followedDefaultInput = null;
|
||||||
foreach (var item in sendInputDevicesList.CheckedItems.OfType<AudioDeviceChoice>())
|
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.
|
// 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"
|
// The user re-ticks once per session, avoiding the "wrong-device-still-selected"
|
||||||
// failure mode after a card unplug or ID change.
|
// 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));
|
sendInputDevicesSignature = SyncDeviceCheckedListBox(sendInputDevicesList, WithDefaultFollower(inputs, DefaultInputFollower));
|
||||||
receiveOutputDevicesSignature = SyncDeviceCheckedListBox(receiveOutputDevicesList, WithDefaultFollower(outputs, DefaultOutputFollower));
|
receiveOutputDevicesSignature = SyncDeviceCheckedListBox(receiveOutputDevicesList, WithDefaultFollower(outputs, DefaultOutputFollower));
|
||||||
// Re-tick the "Use Windows default" followers from the saved preference. They don't ride the
|
// 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;
|
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 sendInputChanged = MaybeSyncList(sendInputDevicesList, WithDefaultFollower(wasapiInputs, DefaultInputFollower), ref sendInputDevicesSignature);
|
||||||
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, WithDefaultFollower(wasapiOutputs, DefaultOutputFollower), ref receiveOutputDevicesSignature);
|
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, WithDefaultFollower(wasapiOutputs, DefaultOutputFollower), ref receiveOutputDevicesSignature);
|
||||||
bool asioSendChanged;
|
bool asioSendChanged;
|
||||||
@@ -5785,6 +5833,12 @@ public sealed class MainForm : Form
|
|||||||
{
|
{
|
||||||
ApplySendSources();
|
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)
|
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 */ }
|
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()
|
private void RestoreDefaultFollowerChecks()
|
||||||
{
|
{
|
||||||
AppConfig cfg;
|
AppConfig cfg;
|
||||||
@@ -5805,6 +5865,7 @@ public sealed class MainForm : Form
|
|||||||
catch { return; }
|
catch { return; }
|
||||||
SetFollowerChecked(receiveOutputDevicesList, cfg.UseDefaultOutputDevice);
|
SetFollowerChecked(receiveOutputDevicesList, cfg.UseDefaultOutputDevice);
|
||||||
SetFollowerChecked(sendInputDevicesList, cfg.UseDefaultInputDevice);
|
SetFollowerChecked(sendInputDevicesList, cfg.UseDefaultInputDevice);
|
||||||
|
SetFollowerChecked(sendOutputDevicesList, cfg.UseDefaultLoopbackSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetFollowerChecked(CheckedListBox list, bool on)
|
private void SetFollowerChecked(CheckedListBox list, bool on)
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Server wire-format compatibility", ServerWireCompat);
|
RunStep(results, "Server wire-format compatibility", ServerWireCompat);
|
||||||
RunStep(results, "App settings save and reload", SettingsRoundTrip);
|
RunStep(results, "App settings save and reload", SettingsRoundTrip);
|
||||||
RunStep(results, "Per-peer shaping DSP", PeerShapingDsp);
|
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, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
|
||||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||||
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
|
RunStep(results, "What's-new update marker", WhatsNewMarkerRoundTrip);
|
||||||
@@ -248,6 +250,40 @@ internal static class SelfTest
|
|||||||
return "unity→null, master-off→null, volume, parametric";
|
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
|
/// <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
|
/// 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>
|
/// bands, and the new recording default. All in-memory — the real config/profiles aren't touched.</summary>
|
||||||
|
|||||||
@@ -225,6 +225,24 @@ public sealed class AppConfig
|
|||||||
/// the same idea for the send-input list.</summary>
|
/// the same idea for the send-input list.</summary>
|
||||||
public bool UseDefaultOutputDevice { get; set; }
|
public bool UseDefaultOutputDevice { get; set; }
|
||||||
public bool UseDefaultInputDevice { 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
|
/// <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
|
/// default?" prompt. Null = ask each time; true = always untick; false = never untick. Set when the
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ public enum CaptureKind
|
|||||||
{
|
{
|
||||||
Loopback,
|
Loopback,
|
||||||
Input,
|
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:<pid>"</c> (see <see cref="ProcessLoopbackId"/>). WASAPI-only
|
||||||
|
/// — ASIO has no per-app concept.</summary>
|
||||||
|
ProcessLoopback,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -36,3 +41,19 @@ public static class AsioDeviceId
|
|||||||
return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0;
|
return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Synthetic device-id for a per-application (process-loopback) capture: <c>"proc:<pid>"</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1054,14 +1054,12 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
sp = playoutEngine.GetOrCreateSession(remote, streamId, MaxBufferCapacityBytes(MaxLatencyForSizingMs));
|
sp = playoutEngine.GetOrCreateSession(remote, streamId, MaxBufferCapacityBytes(MaxLatencyForSizingMs));
|
||||||
// Tag the session with the wire-announced render route. For classic-mode senders
|
// Output routing is now owned by PlayoutEngine: every received stream is fanned out to EVERY
|
||||||
// (or pre-2026-05-11 builds) this is always Mixed and PlayoutEngine treats the
|
// active output lane (a primary plus a mirror replica per extra lane), so the sender's
|
||||||
// session exactly as it always did. BothIndependent senders will tag their two
|
// captured-lane tag (format.Lane) no longer decides where the audio plays — it plays on all
|
||||||
// lanes with WasapiLane / AsioLane so the per-route surfaces direct each lane to
|
// the receiver's outputs. GetOrCreateSession assigns each replica its output lane. (The
|
||||||
// the matching render backend without mixing. Updated unconditionally so an
|
// sender still announces format.Lane on the wire for back-compat; the receiver ignores it
|
||||||
// in-place format change can re-route a session (e.g. a sender that mistakenly
|
// for routing.)
|
||||||
// started in classic mode and re-announces with the right lane mid-stream).
|
|
||||||
sp.Route = format.Lane;
|
|
||||||
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,16 +22,28 @@ public sealed class PeerDspChain
|
|||||||
// carries per-channel state. left.Length == right.Length always.
|
// carries per-channel state. left.Length == right.Length always.
|
||||||
private readonly BiQuadFilter[] left;
|
private readonly BiQuadFilter[] left;
|
||||||
private readonly BiQuadFilter[] right;
|
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.gainL = gainL;
|
||||||
this.gainR = gainR;
|
this.gainR = gainR;
|
||||||
this.hasGain = hasGain;
|
this.hasGain = hasGain;
|
||||||
this.left = left;
|
this.left = left;
|
||||||
this.right = right;
|
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
|
/// <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
|
/// 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>
|
/// 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;
|
return chain.IsNoOp ? null : chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
private Action<IPEndPoint, ReadOnlyMemory<float>>? recordTap;
|
private Action<IPEndPoint, ReadOnlyMemory<float>>? recordTap;
|
||||||
private bool recordTapRaw;
|
private bool recordTapRaw;
|
||||||
private SessionPlayout[] sessionsSnapshot = [];
|
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
|
// Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on
|
||||||
// its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render
|
// its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render
|
||||||
// thread, independent). They must not share scratch arrays — concurrent writes would
|
// 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
|
/// 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>
|
/// first place; the orphan-fall-through logic is benign in that case.</summary>
|
||||||
public void SetLaneActive(RenderRoute lane, bool active)
|
public void SetLaneActive(RenderRoute lane, bool active)
|
||||||
|
{
|
||||||
|
lock (sessionsLock)
|
||||||
{
|
{
|
||||||
switch (lane)
|
switch (lane)
|
||||||
{
|
{
|
||||||
@@ -131,6 +145,11 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
// RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane;
|
// RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane;
|
||||||
// no flag needed.
|
// 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
|
/// <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;
|
else peerDspByAddress[address] = chain;
|
||||||
foreach (var s in sessions.Values)
|
foreach (var s in sessions.Values)
|
||||||
if (s.Endpoint.Address.Equals(address)) s.SetDsp(chain);
|
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 (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain);
|
||||||
if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw);
|
if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw);
|
||||||
sessions[key] = sp;
|
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;
|
return sp;
|
||||||
}
|
}
|
||||||
@@ -364,13 +391,90 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
if (sessions.Remove(key, out var sp))
|
if (sessions.Remove(key, out var sp))
|
||||||
{
|
{
|
||||||
sp.Dispose();
|
sp.Dispose();
|
||||||
sessionsSnapshot = sessions.Values.ToArray();
|
if (mirrorsByKey.Remove(key, out var mirs))
|
||||||
|
foreach (var m in mirs) m.Dispose();
|
||||||
|
RebuildSnapshotLocked();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
public IReadOnlyList<SessionPlayout> ActiveSessions
|
||||||
{
|
{
|
||||||
get { lock (sessionsLock) return sessions.Values.ToList(); }
|
get { lock (sessionsLock) return sessions.Values.ToList(); }
|
||||||
@@ -381,7 +485,10 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
lock (sessionsLock)
|
lock (sessionsLock)
|
||||||
{
|
{
|
||||||
foreach (var s in sessions.Values) s.Dispose();
|
foreach (var s in sessions.Values) s.Dispose();
|
||||||
|
foreach (var list in mirrorsByKey.Values)
|
||||||
|
foreach (var m in list) m.Dispose();
|
||||||
sessions.Clear();
|
sessions.Clear();
|
||||||
|
mirrorsByKey.Clear();
|
||||||
sessionsSnapshot = [];
|
sessionsSnapshot = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,14 +924,17 @@ internal sealed class PlayoutEngine : IWaveProvider
|
|||||||
|
|
||||||
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
||||||
|
|
||||||
// Recording tap (per-lane). Mix is fully processed at this point — volume, mute and
|
// Recording is driven by ONE lane only (recordingRoute = the primary/first-active lane). With
|
||||||
// limiter have all been applied — so the recorder sees exactly what the user is
|
// the every-stream-to-every-output fan-out, both lanes now render the same streams, so firing
|
||||||
// about to hear from this lane. Tagged with `route` so the recorder can keep WASAPI-
|
// the record hooks on both would record every stream twice. The primary lane already carries a
|
||||||
// lane and ASIO-lane streams separate (each lane fires this method independently in
|
// replica of every stream (and holds the per-peer record taps), so recording from it alone
|
||||||
// BothIndependent; without the tag both ended up in one recorder ring, doubling the
|
// captures everything once. Mix is fully processed here (volume, mute, limiter applied), so the
|
||||||
// file's effective sample rate).
|
// recorder still sees exactly what the user hears.
|
||||||
|
if (route == recordingRoute)
|
||||||
|
{
|
||||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
||||||
OnRecordBlockComplete?.Invoke(outFloats);
|
OnRecordBlockComplete?.Invoke(outFloats);
|
||||||
|
}
|
||||||
|
|
||||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||||
return count;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,6 +321,9 @@ internal sealed class SessionPlayout : IDisposable
|
|||||||
/// contributes to. Defaults to <see cref="RenderRoute.Mixed"/> — the value an old
|
/// contributes to. Defaults to <see cref="RenderRoute.Mixed"/> — the value an old
|
||||||
/// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes.</summary>
|
/// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes.</summary>
|
||||||
public RenderRoute Route { get; set; } = RenderRoute.Mixed;
|
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 BufferedBytes => playout.BufferedBytes;
|
||||||
public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
|
public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
|
||||||
public long UnderrunCount => playout.UnderrunCount;
|
public long UnderrunCount => playout.UnderrunCount;
|
||||||
@@ -369,6 +372,7 @@ internal sealed class SessionPlayout : IDisposable
|
|||||||
{
|
{
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
StreamId = streamId;
|
StreamId = streamId;
|
||||||
|
Capacity = capacityBytes;
|
||||||
playout = new AudioRingBuffer(capacityBytes);
|
playout = new AudioRingBuffer(capacityBytes);
|
||||||
|
|
||||||
// Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation
|
// Resampler init. interp=true, filtercnt=0 picks WdlResampler's linear-interpolation
|
||||||
@@ -384,7 +388,26 @@ internal sealed class SessionPlayout : IDisposable
|
|||||||
driftResampler.SetRates(MixSampleRate, MixSampleRate);
|
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)
|
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;
|
var ms = source.Length * 1000 / MixBytesPerSecond;
|
||||||
if (ms > largestWriteMs) largestWriteMs = ms;
|
if (ms > largestWriteMs) largestWriteMs = ms;
|
||||||
@@ -412,6 +435,16 @@ internal sealed class SessionPlayout : IDisposable
|
|||||||
/// by the buffer + drift corrector + click-trim combo.
|
/// by the buffer + drift corrector + click-trim combo.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void NoteFramesQueued(int targetLatencyMs)
|
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 CatastrophicCapMs = 1000;
|
||||||
const int CatastrophicTrimToMs = 250;
|
const int CatastrophicTrimToMs = 250;
|
||||||
|
|||||||
@@ -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 ("", ""); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ internal sealed class CaptureSource : IDisposable
|
|||||||
private const int CaptureBufferMs = 10;
|
private const int CaptureBufferMs = 10;
|
||||||
private const int RingBufferMs = 250;
|
private const int RingBufferMs = 250;
|
||||||
|
|
||||||
private readonly WasapiCapture capture;
|
private readonly IWaveIn capture;
|
||||||
private readonly BufferedWaveProvider buffer;
|
private readonly BufferedWaveProvider buffer;
|
||||||
private readonly Action<string>? onDiagnostic;
|
private readonly Action<string>? onDiagnostic;
|
||||||
private long callbackCount;
|
private long callbackCount;
|
||||||
@@ -56,15 +56,25 @@ internal sealed class CaptureSource : IDisposable
|
|||||||
(int)(buffer.BufferedDuration.TotalMilliseconds);
|
(int)(buffer.BufferedDuration.TotalMilliseconds);
|
||||||
|
|
||||||
public CaptureSource(MMDevice device, CaptureKind kind, string displayName, Action<string>? onDiagnostic = null)
|
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:<pid>"</c> id.</summary>
|
||||||
|
public CaptureSource(IWaveIn waveIn, CaptureKind kind, string deviceId, string displayName, Action<string>? onDiagnostic = null)
|
||||||
{
|
{
|
||||||
Name = displayName;
|
Name = displayName;
|
||||||
Kind = kind;
|
Kind = kind;
|
||||||
DeviceId = device.ID;
|
DeviceId = deviceId;
|
||||||
this.onDiagnostic = onDiagnostic;
|
this.onDiagnostic = onDiagnostic;
|
||||||
|
|
||||||
capture = kind == CaptureKind.Loopback
|
capture = waveIn;
|
||||||
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
|
|
||||||
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs);
|
|
||||||
|
|
||||||
var captureFormat = capture.WaveFormat;
|
var captureFormat = capture.WaveFormat;
|
||||||
CaptureFormatDescription =
|
CaptureFormatDescription =
|
||||||
|
|||||||
@@ -265,6 +265,26 @@ internal sealed class MixingEngine : ICaptureBackend
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private ActiveSource? OpenSource(CaptureSourceSpec spec)
|
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;
|
MMDevice? device = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -302,7 +322,7 @@ internal sealed class MixingEngine : ICaptureBackend
|
|||||||
{
|
{
|
||||||
try { a.KeepAlive?.Dispose(); } catch { /* ignore */ }
|
try { a.KeepAlive?.Dispose(); } catch { /* ignore */ }
|
||||||
try { a.Source.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}";
|
private static string SourceKey(string deviceId, CaptureKind kind) => $"{deviceId}|{kind}";
|
||||||
@@ -391,7 +411,8 @@ internal sealed class MixingEngine : ICaptureBackend
|
|||||||
private sealed class ActiveSource
|
private sealed class ActiveSource
|
||||||
{
|
{
|
||||||
public required CaptureSource Source { get; init; }
|
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; }
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user