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