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