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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user