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
+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;
}
+125 -15
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
@@ -124,12 +136,19 @@ internal sealed class PlayoutEngine : IWaveProvider
/// first place; the orphan-fall-through logic is benign in that case.</summary>
public void SetLaneActive(RenderRoute lane, bool active)
{
switch (lane)
lock (sessionsLock)
{
case RenderRoute.WasapiLane: wasapiLaneActive = active; break;
case RenderRoute.AsioLane: asioLaneActive = active; break;
// RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane;
// no flag needed.
switch (lane)
{
case RenderRoute.WasapiLane: wasapiLaneActive = active; break;
case RenderRoute.AsioLane: asioLaneActive = active; break;
// RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane;
// no flag needed.
}
// Re-fan-out every stream to the now-active set of output lanes: a newly-ticked device gets a
// mirror replica per existing stream; an un-ticked one has its replicas dropped. Idempotent,
// so the paired Wasapi+Asio calls CompositeRenderBackend makes don't double up.
ReconcileReplicasLocked();
}
}
@@ -154,6 +173,11 @@ internal sealed class PlayoutEngine : IWaveProvider
else peerDspByAddress[address] = chain;
foreach (var s in sessions.Values)
if (s.Endpoint.Address.Equals(address)) s.SetDsp(chain);
// Mirror replicas each need their OWN chain instance (independent biquad state) — a fresh
// clone per mirror, or null to clear. Never share one chain across two output lanes.
foreach (var (key, mirs) in mirrorsByKey)
if (key.Endpoint.Address.Equals(address))
foreach (var mir in mirs) mir.SetDsp(chain?.Clone());
}
}
@@ -350,7 +374,10 @@ internal sealed class PlayoutEngine : IWaveProvider
if (peerDspByAddress.TryGetValue(endpoint.Address, out var chain)) sp.SetDsp(chain);
if (recordTap is not null) sp.SetRecordTap(recordTap, recordTapRaw);
sessions[key] = sp;
sessionsSnapshot = sessions.Values.ToArray();
// Assigns the primary's output lane and creates a mirror replica per additional active
// output lane (fan-out), then rebuilds the snapshot. In WasapiOnly this is a no-op beyond
// setting the route (one active lane), so classic behaviour is unchanged.
ReconcileReplicasLocked();
}
return sp;
}
@@ -364,13 +391,90 @@ internal sealed class PlayoutEngine : IWaveProvider
if (sessions.Remove(key, out var sp))
{
sp.Dispose();
sessionsSnapshot = sessions.Values.ToArray();
if (mirrorsByKey.Remove(key, out var mirs))
foreach (var m in mirs) m.Dispose();
RebuildSnapshotLocked();
return true;
}
return false;
}
}
/// <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).
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
OnRecordBlockComplete?.Invoke(outFloats);
// Recording is driven by ONE lane only (recordingRoute = the primary/first-active lane). With
// the every-stream-to-every-output fan-out, both lanes now render the same streams, so firing
// the record hooks on both would record every stream twice. The primary lane already carries a
// replica of every stream (and holds the per-peer record taps), so recording from it alone
// captures everything once. Mix is fully processed here (volume, mute, limiter applied), so the
// recorder still sees exactly what the user hears.
if (route == recordingRoute)
{
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
OnRecordBlockComplete?.Invoke(outFloats);
}
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count;
@@ -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;