Initial commit: RemSound v1.0

This commit is contained in:
Ednunp
2026-05-13 15:15:27 +01:00
commit 17259438c6
80 changed files with 17892 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
using NAudio.Wave;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// ASIO render backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver,
/// pulling the receiver's mixed stereo audio from <see cref="PlayoutEngine"/> and broadcasting
/// it across one or more output channel pairs of the driver. Same shape as
/// <see cref="MultiOutputPlayout"/>: <see cref="AudioReceiver"/> doesn't care which is active.
///
/// Spec identity: each output ID is a synthetic <c>"asio:&lt;channel-pair-index&gt;"</c>. Pair 0
/// = ASIO output channels 0+1, pair 1 = 2+3, etc. The driver itself is locked at construction.
///
/// Same simplifications as <see cref="AsioCaptureBackend"/>: 48 kHz fixed; driver is single
/// per session. Always opens the AsioOut with the driver's full output channel count so that
/// adding/removing channel pairs never requires reopening the driver — important when the
/// sender and receiver are both holding the same single-client driver (Komplete Audio etc.):
/// reopening one while the other is alive caused 15-second freezes.
/// </summary>
internal sealed class AsioRenderBackend : IRenderBackend
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
// Same reasoning as MultiOutputPlayout — source typed as IWaveProvider so the composite
// backend can hand us a tee'd buffer.
private readonly IWaveProvider source;
private readonly Action<string>? onDiagnostic;
private readonly string driverName;
private readonly object gate = new();
private AsioOut? asio;
private List<int> activeChannelPairs = [];
private BroadcastProvider? broadcaster;
public AsioRenderBackend(string driverName, IWaveProvider source, Action<string>? onDiagnostic = null)
{
this.driverName = driverName;
this.source = source;
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => asio is not null;
public string ActiveDeviceSummary
{
get
{
lock (gate)
{
if (activeChannelPairs.Count == 0) return "(none)";
var names = activeChannelPairs.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}").ToList();
if (names.Count <= 3) return string.Join(", ", names);
return $"({names.Count} ASIO outputs)";
}
}
}
public IReadOnlyList<string> ActiveDeviceIds
{
get { lock (gate) return activeChannelPairs.Select(AsioDeviceId.Format).ToList(); }
}
public void Start()
{
// ASIO render starts lazily when SetOutputDevices is given a non-empty list. There's no
// useful "open driver but render to nothing" state — that just locks the device with no
// benefit. The MixingEngine equivalent (producer loop) for WASAPI runs continuously
// even with zero outputs to keep state alive; ASIO doesn't need that since the AsioOut
// *is* the output and there's nothing to keep alive when no channels are wanted.
// Caller is expected to call SetOutputDevices first; this method is a no-op when empty.
lock (gate)
{
if (IsRunning) return;
if (activeChannelPairs.Count == 0) return;
OpenAsioLocked();
}
}
public void Stop()
{
lock (gate) StopInternal();
}
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
{
lock (gate)
{
var newPairs = ParsePairs(deviceIds);
if (newPairs.Count == 0)
{
if (IsRunning) StopInternal();
activeChannelPairs = newPairs;
return;
}
activeChannelPairs = newPairs;
// First time we have any pairs → open the driver. Otherwise we never reopen on a
// pair-set change, because we already opened with the driver's full channel count
// at Start time. Just update the broadcaster's pair list and we're done.
if (asio is null)
{
OpenAsioLocked();
return;
}
broadcaster?.SetActivePairs(activeChannelPairs);
onDiagnostic?.Invoke($"asio render: pairs updated to {string.Join(",", activeChannelPairs)} (no driver restart)");
}
}
private void OpenAsioLocked()
{
try
{
asio = new AsioOut(driverName);
// Always open with the driver's full output channel count. Channels we don't
// immediately broadcast to are zero-filled by BroadcastProvider, which is
// essentially free. Trades a tiny bit of buffer memory for a big stability win:
// adding or removing an output pair never reopens the driver — see the type
// doc-comment for why this matters with single-client drivers.
var outputChannelCount = asio.DriverOutputChannelCount;
if (outputChannelCount <= 0)
{
onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" reports zero output channels");
StopInternal();
return;
}
// Sanity-check requested pairs are in range; warn if not but continue (out-of-range
// pairs simply get no audio).
var maxPair = activeChannelPairs.Max();
var highestNeededChannel = (maxPair + 1) * 2;
if (highestNeededChannel > outputChannelCount)
{
onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" only has {outputChannelCount} output channels, but spec requests pair {maxPair} (channels {maxPair * 2 + 1}/{maxPair * 2 + 2})");
}
broadcaster = new BroadcastProvider(source, outputChannelCount, activeChannelPairs);
asio.ChannelOffset = 0;
asio.Init(broadcaster);
asio.Play();
onDiagnostic?.Invoke($"asio render started \"{driverName}\" {MixSampleRate} Hz, {outputChannelCount} output channel(s); pairs={string.Join(",", activeChannelPairs)}");
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"asio render start failed: {ex.GetType().Name}: {ex.Message}");
StopInternal();
}
}
private void StopInternal()
{
if (asio is not null)
{
try { asio.Stop(); } catch { /* ignore */ }
try { asio.Dispose(); } catch { /* ignore */ }
asio = null;
}
broadcaster = null;
}
public void Dispose() => Stop();
private static List<int> ParsePairs(IReadOnlyList<string> deviceIds)
{
var result = new List<int>();
foreach (var id in deviceIds)
{
if (AsioDeviceId.TryParse(id, out var pair) && pair >= 0)
{
result.Add(pair);
}
}
result.Sort();
return result.Distinct().ToList();
}
/// <summary>
/// Wave provider that pulls stereo audio from <see cref="PlayoutEngine"/> and writes it to
/// a multi-channel ASIO buffer at the requested channel pair positions, zero-filling the
/// channels that aren't selected. Output is interleaved 32-bit float at 48 kHz, exactly
/// what NAudio's AsioOut wants.
/// </summary>
private sealed class BroadcastProvider : IWaveProvider
{
private readonly IWaveProvider source;
private readonly int outputChannelCount;
private byte[] sourceScratchBytes = new byte[16384];
private List<int> activePairs;
public WaveFormat WaveFormat { get; }
public BroadcastProvider(IWaveProvider source, int outputChannelCount, List<int> activePairs)
{
this.source = source;
this.outputChannelCount = outputChannelCount;
this.activePairs = new List<int>(activePairs);
WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, outputChannelCount);
}
public void SetActivePairs(IEnumerable<int> pairs)
{
// Atomic swap. Read side reads activePairs once per Read so a partial swap is
// tolerable — at worst we get one tick of stale routing.
activePairs = pairs.ToList();
}
public int Read(byte[] buffer, int offset, int count)
{
// Frame size in BYTES on the output side.
var bytesPerOutputFrame = outputChannelCount * sizeof(float);
var frames = count / bytesPerOutputFrame;
if (frames <= 0) return 0;
// Pull stereo from PlayoutEngine — its WaveFormat is 48k stereo float, so 8 bytes
// per frame.
var sourceBytes = frames * MixChannels * sizeof(float);
if (sourceScratchBytes.Length < sourceBytes) sourceScratchBytes = new byte[sourceBytes];
source.Read(sourceScratchBytes, 0, sourceBytes);
// Interpret source bytes as float array, output bytes as float array, broadcast.
var srcFloats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(sourceScratchBytes.AsSpan(0, sourceBytes));
var dstFloats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(buffer.AsSpan(offset, count));
dstFloats.Clear();
var pairs = activePairs;
for (var f = 0; f < frames; f++)
{
var l = srcFloats[f * MixChannels];
var r = srcFloats[f * MixChannels + 1];
var dstFrameStart = f * outputChannelCount;
foreach (var pair in pairs)
{
var lCh = pair * 2;
var rCh = pair * 2 + 1;
if (lCh < outputChannelCount) dstFloats[dstFrameStart + lCh] = l;
if (rCh < outputChannelCount) dstFloats[dstFrameStart + rCh] = r;
}
}
return count;
}
}
}
+803
View File
@@ -0,0 +1,803 @@
using System.Diagnostics;
using System.Net;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Public façade for the receiver pipeline. Routes raw packets from <see cref="NetworkListener"/>
/// to one <see cref="StreamSession"/> per remote sender, all of which write to their own
/// <see cref="SessionPlayout"/>; the <see cref="PlayoutEngine"/> then mixes those at render time.
///
/// Multi-source rationale: the previous design held a single <c>activeSession</c> and reset the
/// playout buffer whenever a Format packet arrived from a different endpoint. With two senders
/// transmitting to the same receiver simultaneously (peer-to-peer plus a localhost-monitor, or
/// a future conferencing setup), Format packets alternated and the buffer flushed several times
/// per second — the crackle the WAN test surfaced. Now each endpoint gets its own session and
/// playout state, all summed at the render output.
///
/// Idle sessions are pruned: any session that hasn't received audio data in
/// <see cref="SessionIdleTimeout"/> is removed by <see cref="PruneIdleSessions"/>, called by the
/// App's snapshot tick.
///
/// Responsibilities deliberately scoped:
/// * Lifecycle (Start / Stop / Dispose).
/// * Public configuration (max latency, volume, mute, output device).
/// * Routing packets to the right session, creating sessions for new endpoints.
/// </summary>
public sealed class AudioReceiver : IDisposable
{
public const int MixSampleRate = 48000;
public const int MixChannels = 2;
private const int MixBytesPerSecond = MixSampleRate * MixChannels * sizeof(float);
/// <summary>How big each session's AudioRingBuffer is sized — enough to absorb burst arrival
/// over the maximum supported latency without dropping. Values much above the user-set max
/// latency just waste memory; below it can drop on a deep WAN burst.</summary>
private const int CapacityHeadroomMultiplier = 8;
private const int MaxLatencyForSizingMs = 500;
/// <summary>Sessions that have received nothing for this long are pruned. Long enough that a
/// brief silent gap (mute / no input) doesn't kill the session, short enough that a peer that
/// truly stops sending doesn't keep occupying state forever (and inflating the underrun
/// counter — every render read of an empty-but-armed session bumps the underrun count even
/// though the mix output is unaffected).</summary>
public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(4);
private readonly Stopwatch uptime = new();
private readonly ReceiverDiagnostics diagnostics = new();
private readonly PlayoutEngine playoutEngine;
private IRenderBackend multiOutput;
private readonly NetworkListener listener;
private Action<string>? diagnosticSink;
private readonly object sessionsLock = new();
// Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. A peer can produce
// multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native-
// independent audio mode). For single-lane modes the sender emits one streamId so
// the dict still has one entry per peer, identical to the pre-refactor behaviour.
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), StreamSession> sessions = new();
/// <summary>When false (the default), a Format packet arriving with a NEW streamId from
/// a peer that already has a session under a DIFFERENT streamId triggers immediate
/// disposal of the old session — preserves the pre-refactor "one peer = one active
/// session" behaviour. The sender legitimately rotates streamId on codec changes /
/// engine restarts; without this, the old SessionPlayout sits empty for 4 seconds
/// until <see cref="PruneIdleSessions"/> fires, racking up phantom underrun counts
/// from the render thread polling its empty buffer (~100 per second).
///
/// Set true in the native-independent audio mode (Stage 4) where two streamIds from
/// the same peer are expected to coexist (WASAPI lane + ASIO lane). In that mode the
/// auto-dispose-old-on-new-streamId is wrong — both lanes are continuously active.</summary>
public bool AllowMultipleStreamsPerPeer { get; set; }
// True when audio playback is enabled — i.e. multiOutput is started and Format/Audio
// packets should be processed into sessions. False means the listener stays bound
// (so the single-port heartbeat path keeps working) but audio packets are discarded
// before any decode/buffer work, and no SessionPlayout is created. Volatile because
// packet handlers run on the network thread and may observe a SetPlaybackEnabled
// toggle at any moment. See the single-port unification (2026-05-06): the listener
// is bound for the duration of a connection so heartbeat packets always reach
// OnHeartbeatReceived, regardless of the user's "Receive audio" tick state.
private volatile bool playbackEnabled;
// Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches
// the playout. A null set means "no filter" (legacy behaviour). An empty set means "block
// everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry
// the sender's *outbound* (ephemeral) source port, not the port we'd see in their
// announcement — comparing port-included would always fail. The peer is identified by
// machine IP; we accept audio from any source port on that IP. Read on the network thread,
// updated from the UI thread via SetAllowedSenders.
private volatile HashSet<IPAddress>? allowedSenders;
private long packetsReceived;
private long bytesReceived;
private long packetsDropped;
private long packetsRejectedNotAllowed;
public AudioReceiver()
{
playoutEngine = new PlayoutEngine(diagnostics);
multiOutput = new CompositeRenderBackend(AudioMode.WasapiOnly, null, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}"));
listener = new NetworkListener(HandleRawPacket, msg => diagnosticSink?.Invoke($"network: {msg}"));
}
/// <summary>
/// Sets the audio backend mode (and ASIO driver, when ASIO is involved) for the render side.
/// Mirrors AudioSender.SetAudioMode. The App should re-issue SetOutputDevices afterwards with
/// the current device-id selection.
/// </summary>
public void SetAudioMode(AudioMode mode, string? asioDriverName)
{
var wasRunning = multiOutput.IsRunning;
try { multiOutput.Stop(); } catch { /* ignore */ }
try { multiOutput.Dispose(); } catch { /* ignore */ }
multiOutput = new CompositeRenderBackend(mode, asioDriverName, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}"));
if (wasRunning) multiOutput.Start();
}
public bool IsAsioBackend => multiOutput is CompositeRenderBackend;
/// <summary>Sets the Buffer-smoothness knob (1 = aggressive — clicks the buffer back
/// to target on any drift, holds the user's latency tightly; 10 = smooth — no clicks
/// but the queue can creep up under jitter or sustained clock drift). Knob drives a
/// click-based DropOldest trim in <see cref="SessionPlayout.ReadFloats"/>. As of the
/// 2026-05-06 cleanup (Phase 3) this is mostly a safety knob — the Phase-2 drift
/// corrector keeps the buffer near target so the trim should rarely fire regardless of
/// this value.</summary>
public void SetSmoothness(int value) => playoutEngine.SetSmoothness(value);
/// <summary>Sets the concealment artifact used when the playout buffer comes up empty
/// on a render-side read. Pure receiver-side cosmetic — sender doesn't see this.
/// Live: takes effect on the next underrun, no need to restart playback.</summary>
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
playoutEngine.SetConcealmentArtifact(artifact);
/// <summary>
/// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set
/// to block all (the user has selected no peers); pass null to disable filtering and accept
/// everyone (test/diagnostic only — production UI always passes a real set).
///
/// Why this exists: without an allow-list, anyone who can reach our UDP port (e.g. a peer
/// who has us in *their* selected list, or a stale broadcast announcement that another
/// instance starts honouring) gets their audio rendered to our speakers automatically. The
/// user expects audio to play only after they explicitly tick a peer's checkbox; this gate
/// implements that contract.
///
/// The filter is applied at packet receipt — Format and Audio packets from non-allowed
/// endpoints are counted but discarded, no SessionPlayout is created, no playout buffer
/// fills. Discovery and heartbeat (separate UDP ports) are unaffected, so non-allowed
/// peers still appear as "discovered" in the UI ready to be ticked.
/// </summary>
public void SetAllowedSenders(IEnumerable<IPEndPoint>? allowed)
{
// Reduce IPEndPoint inputs to bare IPAddress for the gate; see field-comment for why.
var snapshot = allowed is null ? null : new HashSet<IPAddress>(allowed.Select(ep => ep.Address));
allowedSenders = snapshot;
// Tear down sessions for endpoints that just got removed from the allow-list — without
// this, audio would keep playing from a session that was opened before the user
// unticked its checkbox. Match by IP since that's how the gate works.
if (snapshot is not null)
{
List<StreamSession> toClose = [];
lock (sessionsLock)
{
foreach (var (key, session) in sessions)
{
if (!snapshot.Contains(key.Endpoint.Address))
{
toClose.Add(session);
}
}
foreach (var session in toClose)
{
sessions.Remove((session.Endpoint, session.StreamId));
}
}
foreach (var session in toClose)
{
playoutEngine.RemoveSession(session.Endpoint, session.StreamId);
session.Dispose();
diagnosticSink?.Invoke($"stream session closed (sender no longer in selected peers): {session.Endpoint} stream={session.StreamId}");
}
}
}
/// <summary>Cumulative count of audio/format packets dropped because the sender wasn't in
/// the allow-list. Surfaced via diagnostics so we can confirm the filter is working.</summary>
public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed);
private bool IsSenderAllowed(IPEndPoint remote)
{
var snapshot = allowedSenders;
if (snapshot is null) return true; // null = no filter
return snapshot.Contains(remote.Address);
}
/// <summary>Optional diagnostic sink (App writes to log file).</summary>
public Action<string>? Diagnostic { get => diagnosticSink; set => diagnosticSink = value; }
/// <summary>True when audio playback is active — i.e. <see cref="SetPlaybackEnabled"/>
/// has been called with <c>true</c> and the underlying render backend is running. This
/// matches the previous semantic of "the user has Receive audio on and we're rendering".
/// The UDP listener socket is NOT covered by this flag — see <see cref="IsListenerRunning"/>.
/// In single-port mode (post-2026-05-06) the listener stays bound for the whole connection
/// so heartbeat packets always reach us; this flag tracks only the playback half.</summary>
public bool IsRunning => multiOutput.IsRunning;
/// <summary>True when the UDP listener socket is bound. Independent of playback state.
/// Surfaced for diagnostic/symmetry only — most callers want <see cref="IsRunning"/>.</summary>
public bool IsListenerRunning => listener.IsRunning;
/// <summary>Max time-in-user-handler (the work between Socket.ReceiveFrom returning and
/// onPacket finishing) observed since the last call. The SNAP loop reads this each
/// second to split observed inter-packet jitter into network vs receiver-processing
/// contributions. Resets on read.</summary>
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
/// render lane is holding samples (slow consumer holding back compaction, or the fast
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly
/// inflating latency on either lane.</summary>
public int TakeMaxFanOutCacheMs()
{
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec.
const int MixBytesPerSecond = 48000 * 2 * 4;
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0;
return bytes * 1000 / MixBytesPerSecond;
}
public string OutputDeviceName => multiOutput.ActiveDeviceSummary;
public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
/// <summary>
/// Frame duration of the most-recently-active stream (10 ms PCM, 20 ms Opus). null when no
/// stream is active. With multiple senders this picks the largest frame duration as the
/// codec floor — most conservative for the auto-tune.
/// </summary>
public int? ActiveStreamFrameMs
{
get
{
lock (sessionsLock)
{
if (sessions.Count == 0) return null;
var maxFrame = 0;
foreach (var s in sessions.Values)
{
if (s.Format.FrameDurationMilliseconds > maxFrame) maxFrame = s.Format.FrameDurationMilliseconds;
}
return maxFrame;
}
}
}
/// <summary>Aggregate count across all active PCM sessions of frames the assembler rejected.
/// Resets per-session when a session ends; the receiver-level number is the live sum.</summary>
public long PcmFrameRejections
{
get
{
lock (sessionsLock)
{
long total = 0;
foreach (var s in sessions.Values) total += s.PcmFrameRejections;
return total;
}
}
}
public long PcmFrameDiscardedPartials
{
get
{
lock (sessionsLock)
{
long total = 0;
foreach (var s in sessions.Values) total += s.PcmFrameDiscardedPartials;
return total;
}
}
}
public int MaxLatencyMs
{
get => playoutEngine.MaxLatencyMs;
set => playoutEngine.SetMaxLatencyMs(value);
}
/// <summary>Soft variant: same as setting MaxLatencyMs, but on a LOWER does not drain
/// the buffer / disarm the session. The drift corrector's adaptive gain shrinks the
/// buffer gradually over a few seconds instead. Used by auto-tune so its slider
/// adjustments are inaudible — the user didn't ask for an immediate change and shouldn't
/// hear one. On a RAISE behaves identically to the regular setter (no drain ever fires
/// on raise).</summary>
public void SetMaxLatencyMsSoft(int value) =>
playoutEngine.SetMaxLatencyMs(value, drainOnLower: false);
/// <summary>Per-route latency accessors — used in BothIndependent mode where the WASAPI
/// lane and the ASIO lane each have their own slider. In classic modes only the Mixed
/// route has sessions, so the route-specific values are configured but never observed.</summary>
public int MaxLatencyMsFor(RenderRoute route) => playoutEngine.MaxLatencyMsFor(route);
public int TargetLatencyMsFor(RenderRoute route) => playoutEngine.TargetLatencyMsFor(route);
public void SetMaxLatencyMsFor(RenderRoute route, int value) =>
playoutEngine.SetMaxLatencyMs(route, value);
public void SetMaxLatencyMsSoftFor(RenderRoute route, int value) =>
playoutEngine.SetMaxLatencyMs(route, value, drainOnLower: false);
/// <summary>Per-route underrun count for the auto-tune skip-while-underrunning gate. In
/// BothIndependent the WASAPI lane's underruns should not make the ASIO auto-tune defer
/// (and vice versa); reading per-route fixes that.</summary>
public long UnderrunsFor(RenderRoute route) => playoutEngine.AggregateUnderrunsFor(route);
/// <summary>True when at least one stream session is currently tagged for this route —
/// used by MainForm's continuous auto-tune to skip routes with no audio in flight, so a
/// lane's auto-tune can't pre-inflate its target by reacting to shared network-gap data
/// from a different lane's packets.</summary>
public bool HasSessionsForRoute(RenderRoute route) => playoutEngine.HasSessionsForRoute(route);
public long Underruns => playoutEngine.AggregateUnderruns;
public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped);
/// <summary>Per-cause split of the legacy `Drops` rollup. Useful in the diag log to tell
/// "we deliberately trimmed the buffer to track the latency target" (TrimDropBytes) from
/// "we got malformed packets" (PacketsRejectedMalformed) from "ringbuffer overflowed and
/// the producer dropped oldest" (RingbufferOverflowDropBytes). Without this split a single
/// "Drops" value couldn't tell us which mechanism was firing.</summary>
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
/// keep up", as opposed to "we deliberately reshaped the buffer".</summary>
public long RingbufferOverflowDropBytes
=> Math.Max(0, playoutEngine.AggregateDrops - TrimDropBytes - DrainDropBytes);
public long PacketsRejectedMalformed => Interlocked.Read(ref packetsDropped);
public long PacketsReceived => Interlocked.Read(ref packetsReceived);
public long BytesReceived => Interlocked.Read(ref bytesReceived);
public TimeSpan Uptime => uptime.Elapsed;
/// <summary>Total times we used Opus inband FEC to recover a single-packet gap, across all active sessions.</summary>
public long OpusFecRecoveries
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.OpusFecRecoveries;
}
return total;
}
}
/// <summary>Total times we saw a multi-packet gap that FEC could not fill, across all active sessions.</summary>
public long OpusUnrecoveredGaps
{
get
{
long total = 0;
lock (sessionsLock)
{
foreach (var s in sessions.Values) total += s.OpusUnrecoveredGaps;
}
return total;
}
}
public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; }
public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; }
/// <summary>
/// Sets the list of output devices to render received audio to. The receiver mixes once and
/// fans out to every device in this list — pass an empty list to mute all output without
/// stopping the receive path. Per session policy, the App does NOT persist this selection;
/// every session starts with no outputs ticked.
/// </summary>
public void SetOutputDevices(IReadOnlyList<string> deviceIds) => multiOutput.SetOutputDevices(deviceIds);
/// <summary>Take a snapshot of the rolling diagnostic counters. Caller drives at 1 Hz.</summary>
public ReceiverDiagnostics.DiagSnapshot TakeDiagnosticsSnapshot() => diagnostics.Take(MixBytesPerSecond);
/// <summary>
/// Bind the UDP listener socket on <paramref name="udpPort"/>. Does NOT start audio
/// playback — call <see cref="SetPlaybackEnabled"/>(true) for that. Splitting these
/// lets the single-port heartbeat path keep working while the user has "Receive audio"
/// off: the socket stays bound so heartbeat packets reach <see cref="OnHeartbeatReceived"/>,
/// but Format/Audio packets are discarded at receipt (no decode, no buffer growth).
/// </summary>
public void Start(int udpPort = RemPacket.DefaultPort)
{
if (listener.IsRunning) return;
Interlocked.Exchange(ref packetsReceived, 0);
Interlocked.Exchange(ref bytesReceived, 0);
Interlocked.Exchange(ref packetsDropped, 0);
// Tear down any sessions left over from a previous Start (in case Stop wasn't called).
DisposeAllSessionsLocked();
playoutEngine.ResetAll();
listener.Start(udpPort);
uptime.Restart();
}
/// <summary>
/// Toggles audio playback on or off. When <paramref name="enabled"/> goes false, the
/// render backend is stopped and any open sessions are disposed (so a re-enable doesn't
/// drain stale audio). Heartbeat packet routing is unaffected — the listener stays
/// bound either way as long as <see cref="Start"/> has been called. Idempotent.
/// </summary>
public void SetPlaybackEnabled(bool enabled)
{
if (enabled == multiOutput.IsRunning)
{
playbackEnabled = enabled;
return;
}
if (enabled)
{
// Reset packet handlers' gate before starting the backend, so packets that arrive
// between multiOutput.Start and the next handler invocation aren't misrouted.
playbackEnabled = true;
multiOutput.Start();
}
else
{
// Flip the gate first so HandleFormat/HandleAudio stop opening new sessions, then
// tear down the backend and any in-flight sessions. Order matters — if we stopped
// the backend first, in-flight packets could open a fresh session that nothing
// would ever drain.
playbackEnabled = false;
multiOutput.Stop();
lock (sessionsLock)
{
DisposeAllSessionsLocked();
}
playoutEngine.ResetAll();
}
}
public void Stop()
{
listener.Stop();
playbackEnabled = false;
multiOutput.Stop();
uptime.Stop();
lock (sessionsLock)
{
DisposeAllSessionsLocked();
}
playoutEngine.ResetAll();
}
public void Dispose()
{
Stop();
listener.Dispose();
multiOutput.Dispose();
}
/// <summary>
/// Drop sessions that haven't received audio data in <see cref="SessionIdleTimeout"/>. Caller
/// (the App's snapshot tick) drives this so it stays serialised with the network thread on
/// the same lock the packet handlers use.
/// </summary>
public void PruneIdleSessions()
{
var now = DateTime.UtcNow;
List<(IPEndPoint Endpoint, ushort StreamId)>? toRemove = null;
lock (sessionsLock)
{
foreach (var (key, session) in sessions)
{
// Match SessionPlayout by full key so two streams from the same peer don't
// share a single SessionPlayout entry. ActiveSessions iteration is small
// (one per active stream).
var sp = playoutEngine.ActiveSessions.FirstOrDefault(x =>
x.Endpoint.Equals(key.Endpoint) && x.StreamId == key.StreamId);
if (sp is null) continue;
if (now - sp.LastWriteUtc <= SessionIdleTimeout) continue;
toRemove ??= [];
toRemove.Add(key);
}
if (toRemove is not null)
{
foreach (var key in toRemove)
{
if (sessions.Remove(key, out var session)) session.Dispose();
playoutEngine.RemoveSession(key.Endpoint, key.StreamId);
diagnosticSink?.Invoke($"stream session pruned (idle): {key.Endpoint} stream={key.StreamId}");
}
}
}
}
private void DisposeAllSessionsLocked()
{
foreach (var s in sessions.Values) s.Dispose();
sessions.Clear();
}
/// <summary>
/// Whether we have a recent audio stream session from the given peer IP. "Recent" matches the
/// playout-engine's idle-prune timeout — i.e. a session whose last write is within
/// <see cref="SessionIdleTimeout"/>. Compares on IP only, not port (incoming packets carry the
/// sender's outbound source port, which won't equal their announced audio port). Lockless and
/// safe to call from any thread.
/// </summary>
public bool IsReceivingFromAddress(IPAddress address)
{
var now = DateTime.UtcNow;
foreach (var sp in playoutEngine.ActiveSessions)
{
if (!sp.Endpoint.Address.Equals(address)) continue;
if (now - sp.LastWriteUtc <= SessionIdleTimeout) return true;
}
return false;
}
/// <summary>
/// The codec format being received from the given peer IP, or null if no recent session.
/// Useful for surfacing "we're receiving Opus 10ms from this peer" in the UI.
/// </summary>
public AudioFormatInfo? ActiveFormatFromAddress(IPAddress address)
{
var now = DateTime.UtcNow;
SessionPlayout? freshest = null;
foreach (var sp in playoutEngine.ActiveSessions)
{
if (!sp.Endpoint.Address.Equals(address)) continue;
if (now - sp.LastWriteUtc > SessionIdleTimeout) continue;
if (freshest is null || sp.LastWriteUtc > freshest.LastWriteUtc) freshest = sp;
}
if (freshest is null) return null;
lock (sessionsLock)
{
if (sessions.TryGetValue((freshest.Endpoint, freshest.StreamId), out var session))
{
return session.Format;
}
}
return null;
}
// === Packet routing (called on network thread) ===
/// <summary>Hook for Heartbeat packets that arrive on the audio receiver's socket. The
/// App wires this to <see cref="HeartbeatService.HandleInjectedPacket"/>. Set this *before*
/// starting the receiver, otherwise heartbeats arriving on this socket will be silently
/// dropped as unknown packet type. In single-port mode (the only mode since 2026-05-06)
/// every heartbeat reaches us via this hook — the audio sender writes to the peer's
/// audio port, which is this receiver's bound socket; there is no separate heartbeat
/// socket on either end any more.</summary>
public Action<byte[], int, IPEndPoint>? OnHeartbeatReceived { get; set; }
/// <summary>Hook for Control packets that arrive on the audio receiver's socket. The
/// App wires this to a handler that validates the source against the allow-list (the
/// peer must be in the user's selected-peers set), checks the user's "accept remote
/// volume commands" preference, and applies the requested change to the local volume
/// slider. Set this BEFORE starting the receiver; null = packet is silently dropped.
/// Travels on the same UDP socket as audio + heartbeat (single-port model 2026-05-07).</summary>
public Action<RemoteControlKind, sbyte, IPEndPoint>? OnRemoteControlReceived { get; set; }
private void HandleRawPacket(byte[] packet, int length, IPEndPoint remote)
{
Interlocked.Increment(ref packetsReceived);
Interlocked.Add(ref bytesReceived, length);
var packetSpan = packet.AsSpan(0, length);
if (!RemPacket.TryReadHeader(packetSpan, out var type, out var streamId, out var sequence))
{
Interlocked.Increment(ref packetsDropped);
return;
}
var payload = packetSpan[RemPacket.HeaderSize..];
switch (type)
{
case RemPacketType.Format:
HandleFormat(remote, streamId, payload);
break;
case RemPacketType.Audio:
HandleAudio(remote, streamId, sequence, payload);
break;
case RemPacketType.KeepAlive:
// Informational only at this layer.
break;
case RemPacketType.Heartbeat:
// Route to the heartbeat service via the App-supplied delegate. In single-port
// mode this is the primary inbound path for heartbeats (the heartbeat service
// no longer binds its own socket). The hook MUST be wired before Start();
// otherwise heartbeats are dropped and peer health stays "unreachable".
OnHeartbeatReceived?.Invoke(packet, length, remote);
break;
case RemPacketType.Control:
// Remote-control message (volume up/down, mute toggle). Parse the payload
// here so the handler doesn't need to know about RemPacket layout. Caller
// is expected to gate on allow-list AND the user's opt-in preference.
if (RemPacket.TryReadControl(payload, out var ctrlKind, out var ctrlDelta))
{
OnRemoteControlReceived?.Invoke(ctrlKind, ctrlDelta, remote);
}
else
{
Interlocked.Increment(ref packetsDropped);
}
break;
default:
Interlocked.Increment(ref packetsDropped);
break;
}
}
/// <summary>
/// Inject a packet that arrived on a non-listener socket (e.g. the AudioSender's socket
/// in relay mode). Runs the same dispatch logic as the listener thread. Caller is
/// responsible for filtering out packet types it has handled itself (typically Heartbeat,
/// which goes to <see cref="HeartbeatService"/>) — passing a Heartbeat packet here is
/// safe (it'll be counted and dropped) but wasteful.
/// </summary>
public void InjectExternalPacket(byte[] packet, int length, IPEndPoint remote)
{
HandleRawPacket(packet, length, remote);
}
private void HandleFormat(IPEndPoint remote, ushort streamId, ReadOnlySpan<byte> payload)
{
// Single-port mode: the listener stays bound when playback is off (so heartbeats
// keep flowing on the same socket), but Format/Audio are dropped without opening a
// session. Doing this BEFORE the format-parse keeps the malformed-packet counter
// honest — disabled-playback drops aren't a malformedness signal.
if (!playbackEnabled) return;
if (!RemPacket.TryReadFormat(payload, out var format))
{
Interlocked.Increment(ref packetsDropped);
return;
}
if (!IsSenderAllowed(remote))
{
// Sender isn't in the user's selected-peers set. Don't open a session, don't play
// their audio. They'll appear in discovery / heartbeat as a peer the user can tick
// if they want; until then, silence on our side. Counted separately so it shows in
// diagnostics without inflating the generic "drops" stat.
Interlocked.Increment(ref packetsRejectedNotAllowed);
return;
}
SessionPlayout sp;
StreamSession? newSession = null;
bool isNewSession = false;
bool isFormatChange = false;
// Older sessions from the same peer that are being replaced because we're in
// single-stream mode (AllowMultipleStreamsPerPeer=false) and the sender rotated
// its streamId (codec change / engine restart). Disposed AFTER releasing the
// sessionsLock so their tear-down doesn't extend the critical section.
List<StreamSession>? supersededByStreamIdChange = null;
var key = (remote, streamId);
lock (sessionsLock)
{
sessions.TryGetValue(key, out var existing);
if (existing is not null && existing.MatchesFormat(remote, streamId, format))
{
return; // same session; nothing to do
}
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;
if (existing is null)
{
isNewSession = true;
}
else
{
// Same (endpoint, streamId), different format (codec change within the same lane).
// Replace the StreamSession but keep its SessionPlayout — buffered audio drains
// naturally and avoids a gap. Matches the behaviour the single-source code
// preserved for codec switches.
existing.Dispose();
isFormatChange = true;
}
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs));
sessions[key] = newSession;
// Same-lane streamId rotation: drop other sessions from this peer that share the
// SAME render route as the new format. The sender rotates streamId on codec
// changes and engine restarts; the old session sits empty otherwise, racking up
// phantom underruns from render-thread polling. The lane-match qualifier is
// critical for BothIndependent mode (added 2026-05-11) where the same peer
// legitimately produces TWO concurrent streamIds — one per lane — and each lane's
// Format-resend packets must NOT supersede the other lane's session. Without the
// lane match, the two lanes' 250 ms format announces took turns killing each
// other 8× per second, neither lane could stay alive long enough to arm, and
// BothIndependent appeared to "produce no audio" on the receiver. AllowMultiple-
// StreamsPerPeer is preserved as an override knob (default false) for unusual
// setups; even with it true, lane-mismatched sessions would still coexist, so the
// flag now only governs same-lane-different-streamId behaviour.
if (!AllowMultipleStreamsPerPeer)
{
foreach (var (otherKey, otherSession) in sessions)
{
if (otherKey.Endpoint.Equals(remote)
&& otherKey.StreamId != streamId
&& otherSession.Format.Lane == format.Lane)
{
supersededByStreamIdChange ??= [];
supersededByStreamIdChange.Add(otherSession);
}
}
if (supersededByStreamIdChange is not null)
{
foreach (var s in supersededByStreamIdChange)
{
sessions.Remove((s.Endpoint, s.StreamId));
}
}
}
}
if (supersededByStreamIdChange is not null)
{
foreach (var s in supersededByStreamIdChange)
{
playoutEngine.RemoveSession(s.Endpoint, s.StreamId);
s.Dispose();
diagnosticSink?.Invoke($"stream session superseded (sender rotated streamId): {s.Endpoint} oldStream={s.StreamId} newStream={streamId}");
}
}
if (isNewSession)
{
// Reset the global inter-packet / inter-render-callback gap timers. If we don't,
// the first audio packet of this new session records a gap measured from the LAST
// packet of the previous session — which on a mode switch or codec change can be
// tens of seconds of user-idle time. That bogus gap then feeds the auto-tune's
// recent-gap window and makes it recommend an absurd latency target (e.g. 27 s
// observed → recommendation clamped to 200 ms hard cap → fresh session never
// arms because its buffer can't reach 200 ms before underrun). 2026-05-11 fix.
diagnostics.ResetGapMeasurements();
Interlocked.Increment(ref sessionsOpenedCount);
diagnosticSink?.Invoke($"stream session opened: {remote} stream={streamId} {format}");
}
else if (isFormatChange)
{
diagnosticSink?.Invoke($"stream format changed: {remote} stream={streamId} {format}");
}
}
private long sessionsOpenedCount;
/// <summary>
/// Monotonic count of new <c>StreamSession</c> instances opened since this receiver
/// started. Exposed so the App can detect a fresh session and reset its rolling
/// observation windows (recentMaxGaps etc.) — see the matching reset in MainForm's
/// SNAP loop. Increments only on truly-new sessions, not on format-change-keep-buffer.
/// </summary>
public long SessionsOpenedCount => Interlocked.Read(ref sessionsOpenedCount);
private void HandleAudio(IPEndPoint remote, ushort streamId, uint sequence, ReadOnlySpan<byte> payload)
{
// See HandleFormat — same single-port gate. We drop Audio packets silently when
// playback is off; the underlying NAT pinhole / heartbeat path isn't affected since
// Heartbeat packets are dispatched in HandleRawPacket before reaching here.
if (!playbackEnabled) return;
if (!IsSenderAllowed(remote))
{
Interlocked.Increment(ref packetsRejectedNotAllowed);
return;
}
StreamSession? session;
lock (sessionsLock)
{
sessions.TryGetValue((remote, streamId), out session);
}
// Key lookup guarantees streamId match — kept the defensive check anyway in case of
// future restructuring (cheap and clarifies intent).
if (session is null) return;
if (session.StreamId != streamId) return;
if (!session.HandleAudioPayload(sequence, payload))
{
Interlocked.Increment(ref packetsDropped);
}
}
private static int MaxBufferCapacityBytes(int maxLatencyMs) =>
Math.Max(maxLatencyMs * CapacityHeadroomMultiplier * MixBytesPerSecond / 1000, 64 * 1024);
}
@@ -0,0 +1,193 @@
using NAudio.Wave;
namespace RemSound.Receiver;
/// <summary>
/// Render backend that runs a WASAPI <see cref="MultiOutputPlayout"/> and an
/// <see cref="AsioRenderBackend"/> in parallel. Two pipeline shapes are reachable today:
/// <list type="bullet">
/// <item>WasapiOnly: WASAPI child reads <see cref="PlayoutEngine"/> directly; no ASIO in
/// the path. Used when no ASIO driver is selected.</item>
/// <item>BothIndependent: WASAPI and ASIO children each get their own consumer view from a
/// shared <see cref="FanOutSource"/>. The FanOut pulls from PlayoutEngine on demand
/// and caches so both views see the same samples without one consumer slowing the
/// other. Neither backend pays the classic-Both master-producer tee's ~510 ms
/// buffer headroom — each lane runs at its native callback rate.</item>
/// </list>
/// The legacy <c>AudioMode.Both</c> tee mode and <c>AudioMode.AsioOnly</c> values are no
/// longer reachable from the UI and are not produced here.
/// </summary>
internal sealed class CompositeRenderBackend : IRenderBackend
{
private readonly PlayoutEngine source;
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
// BothIndependent no longer uses a shared FanOut between the two render backends — each
// backend reads directly from its own lane-filtered source (PlayoutEngine.WasapiLaneOutput /
// AsioLaneOutput). Those surfaces filter PlayoutEngine's session snapshot by RenderRoute,
// so the WASAPI consumer's Read only advances WasapiLane sessions and the ASIO consumer's
// Read only advances AsioLane sessions. The two lanes are fully independent — no shared
// cache, no cross-lane interference, neither lane pays a cache-age penalty when the other
// is also playing.
private readonly MultiOutputPlayout? wasapi;
private readonly AsioRenderBackend? asio;
private readonly string? asioDriverName;
private readonly RemSound.Core.AudioMode mode;
private bool started;
public CompositeRenderBackend(RemSound.Core.AudioMode mode, string? asioDriverName, PlayoutEngine source, Action<string>? onDiagnostic = null)
{
this.source = source;
this.onDiagnostic = onDiagnostic;
this.asioDriverName = asioDriverName;
this.mode = mode;
// Coerce legacy enum values (AsioOnly, Both) into a reachable mode. Anything non-
// WASAPI without a driver demotes to WasapiOnly; anything non-WASAPI with a driver
// is treated as BothIndependent (the only ASIO-using render mode now).
if (mode != RemSound.Core.AudioMode.WasapiOnly)
{
if (string.IsNullOrEmpty(asioDriverName))
{
this.mode = mode = RemSound.Core.AudioMode.WasapiOnly;
}
else if (mode != RemSound.Core.AudioMode.BothIndependent)
{
this.mode = mode = RemSound.Core.AudioMode.BothIndependent;
}
}
if (mode == RemSound.Core.AudioMode.WasapiOnly)
{
// MultiOutputPlayout reads PlayoutEngine directly — no master producer, no tee.
// Sessions in WasapiOnly mode are all on RenderRoute.Mixed (the legacy single-knob
// world), and the all-sessions Read does the right thing.
wasapi = new MultiOutputPlayout(source, msg => onDiagnostic?.Invoke($"wasapi out: {msg}"));
}
else
{
// BothIndependent. Each backend reads its OWN lane-filtered source from
// PlayoutEngine — no FanOut, no shared cache, no inter-lane interference. The
// WasapiLaneOutput surface filters PlayoutEngine's session snapshot down to
// route=WasapiLane sessions; AsioLaneOutput does the same for route=AsioLane.
// Each consumer's Read only advances its own lane's sessions, so the two
// consumers can run on independent threads at independent rates without one
// starving the other. Crucially: neither lane pays a cache-age overhead. ASIO
// reads its own audio at its native callback latency, exactly as it would in a
// hypothetical AsioOnly setup — even when WASAPI is also actively playing.
// The previous implementation wrapped a single FanOut around the whole engine,
// which (a) made both lanes play the combined mix instead of per-lane audio and
// (b) added up to one WASAPI tick (~10 ms) of cache-age latency to whichever
// consumer was the slower of the two.
wasapi = new MultiOutputPlayout(source.WasapiLaneOutput, msg => onDiagnostic?.Invoke($"wasapi out: {msg}"));
asio = new AsioRenderBackend(asioDriverName!, source.AsioLaneOutput, msg => onDiagnostic?.Invoke($"asio out: {msg}"));
}
}
public bool IsRunning => started;
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads
/// per-lane sources directly with no intermediate cache. Kept on the surface so the
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero
/// rather than disappearing. Can be removed once we're confident the per-lane wiring
/// is the right shape long-term.</summary>
public int TakeMaxFanOutCacheBytes() => 0;
public string ActiveDeviceSummary
{
get
{
var parts = new List<string>();
if (wasapi is not null)
{
var wSummary = wasapi.ActiveDeviceSummary;
if (wSummary != "(none)") parts.Add(wSummary);
}
if (asio is not null)
{
var aSummary = asio.ActiveDeviceSummary;
if (aSummary != "(none)") parts.Add(aSummary);
}
return parts.Count == 0 ? "(none)" : string.Join(" + ", parts);
}
}
public IReadOnlyList<string> ActiveDeviceIds
{
get
{
var combined = new List<string>();
if (wasapi is not null) combined.AddRange(wasapi.ActiveDeviceIds);
if (asio is not null) combined.AddRange(asio.ActiveDeviceIds);
return combined;
}
}
public void Start()
{
lock (gate)
{
if (started) return;
wasapi?.Start();
asio?.Start();
started = true;
onDiagnostic?.Invoke($"composite render started (mode={ModeLabel()})");
}
}
public void Stop()
{
lock (gate)
{
if (!started) return;
try { wasapi?.Stop(); } catch { /* ignore */ }
try { asio?.Stop(); } catch { /* ignore */ }
started = false;
}
}
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
{
// Split by id format: ASIO ids start with "asio:". WASAPI ids are MMDevice strings.
var wasapiIds = new List<string>();
var asioIds = new List<string>();
foreach (var id in deviceIds)
{
if (RemSound.Core.AsioDeviceId.TryParse(id, out _))
{
asioIds.Add(id);
}
else
{
wasapiIds.Add(id);
}
}
if (wasapi is not null) wasapi.SetOutputDevices(wasapiIds);
if (asio is not null) asio.SetOutputDevices(asioIds);
// No FanOut bookkeeping any more — each lane's source is independent, so consumer
// activity / inactivity doesn't affect the other lane's read path. The "skip the
// pull when no outputs are ticked" behaviour now lives inside MultiOutputPlayout's
// producer loop, which short-circuits source.Read when outputs.Count == 0.
}
public void Dispose()
{
Stop();
try { wasapi?.Dispose(); } catch { /* ignore */ }
try { asio?.Dispose(); } catch { /* ignore */ }
}
private string ModeLabel() => mode switch
{
RemSound.Core.AudioMode.WasapiOnly => "fast (WASAPI direct)",
RemSound.Core.AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)",
_ => mode.ToString(),
};
// FanOutSource and SwitchableSource have been removed (2026-05-13). The BothIndependent
// rewiring put each lane on its own filtered PlayoutEngine.{Wasapi,Asio}LaneOutput
// surface, so there is no shared source for two consumers to fight over and no cache
// to manage. Either class can be reintroduced if a future routing shape needs them.
}
+31
View File
@@ -0,0 +1,31 @@
namespace RemSound.Receiver;
/// <summary>
/// Abstraction over the render-side audio backend so <see cref="AudioReceiver"/> can be wired
/// to either a WASAPI implementation (today's <see cref="MultiOutputPlayout"/>) or an ASIO
/// implementation (<see cref="AsioRenderBackend"/>) without caring which is in use.
///
/// Both backends pull mixed audio from <see cref="PlayoutEngine"/>'s <see cref="IWaveProvider"/>
/// surface and route it to one or more output destinations. WASAPI destinations are MMDevice
/// IDs; ASIO destinations are synthetic IDs of the form
/// <c>"asio:&lt;driver-name&gt;|&lt;channel-pair-index&gt;"</c>.
/// </summary>
internal interface IRenderBackend : IDisposable
{
bool IsRunning { get; }
/// <summary>Friendly summary for the snapshot log column. "(none)" when nothing is
/// configured, comma-joined names for ≤3 outputs, "(N outputs)" otherwise.</summary>
string ActiveDeviceSummary { get; }
IReadOnlyList<string> ActiveDeviceIds { get; }
void Start();
void Stop();
/// <summary>Live-update of the output set. Devices already present stay live; removed ones
/// are torn down; new ones are opened. Empty list = render to nothing without stopping the
/// mixer (so receive-side state stays alive).</summary>
void SetOutputDevices(IReadOnlyList<string> deviceIds);
}
+233
View File
@@ -0,0 +1,233 @@
using System.Diagnostics;
using NAudio.CoreAudioApi;
using NAudio.Wave;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Drives N WASAPI output devices from a single shared <see cref="PlayoutEngine"/>. A master
/// producer task running on a Stopwatch-based 10 ms tick reads mixed audio from the engine and
/// fans it out to each device's <see cref="BufferedWaveProvider"/>; each <see cref="WasapiOut"/>
/// consumes from its own buffer at its own device clock.
///
/// Why a master producer loop instead of letting one WasapiOut drive PlayoutEngine.Read directly:
/// - With multiple WasapiOuts, each render thread would call Read independently and only one
/// output would get each frame; the others would starve.
/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one
/// device's clock. Per-device drift is absorbed by the BufferedWaveProvider's headroom.
///
/// Output-device set is diffed on <see cref="SetOutputDevices"/>: existing devices stay live,
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
/// </summary>
internal sealed class MultiOutputPlayout : IRenderBackend
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int MixBytesPerFrame = MixChannels * sizeof(float);
private const int FrameMs = 10;
private const int FrameBytes = MixSampleRate * MixBytesPerFrame * FrameMs / 1000; // 3840 bytes
private const int OutputBufferMs = 100; // per-device BufferedWaveProvider capacity
// Source typed as IWaveProvider (rather than concrete PlayoutEngine) so the composite
// backend can hand us a tee'd buffer instead of the engine directly. Single-backend usage
// still passes the engine in unchanged.
private readonly IWaveProvider source;
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
private readonly byte[] frameScratch = new byte[FrameBytes];
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
private CancellationTokenSource? cts;
private Task? produceTask;
public MultiOutputPlayout(IWaveProvider source, Action<string>? onDiagnostic = null)
{
this.source = source;
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => produceTask is { IsCompleted: false };
/// <summary>
/// Friendly names of currently-active output devices, comma-joined. "(none)" when no
/// device is enabled. Used by the snapshot log column.
/// </summary>
public string ActiveDeviceSummary
{
get
{
lock (gate)
{
if (outputs.Count == 0) return "(none)";
if (outputs.Count <= 3) return string.Join(", ", outputs.Values.Select(o => o.Name));
return $"({outputs.Count} outputs)";
}
}
}
public IReadOnlyList<string> ActiveDeviceIds
{
get { lock (gate) return outputs.Keys.ToList(); }
}
public void Start()
{
lock (gate)
{
if (IsRunning) return;
cts = new CancellationTokenSource();
produceTask = Task.Run(() => ProduceLoop(cts.Token));
onDiagnostic?.Invoke("multi-output producer started");
}
}
public void Stop()
{
lock (gate)
{
try { cts?.Cancel(); } catch { /* ignore */ }
try { produceTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ }
cts?.Dispose();
cts = null;
produceTask = null;
foreach (var o in outputs.Values) DisposeOutput(o);
outputs.Clear();
}
}
public void Dispose() => Stop();
/// <summary>
/// Live-update of the output device set. Devices already present stay live (no audio
/// interruption); removed devices are stopped + disposed; new devices are opened. Caller
/// supplies device IDs (from MMDeviceEnumerator). An empty set means "render to nothing"
/// — the producer loop keeps running so receive-side mixing/auto-tune state stays alive.
/// </summary>
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
{
lock (gate)
{
var desired = new HashSet<string>(deviceIds, StringComparer.OrdinalIgnoreCase);
// Remove outputs no longer wanted.
foreach (var id in outputs.Keys.Where(k => !desired.Contains(k)).ToList())
{
if (outputs.Remove(id, out var o))
{
onDiagnostic?.Invoke($"output removed: \"{o.Name}\"");
DisposeOutput(o);
}
}
// Add new outputs.
using var enumerator = new MMDeviceEnumerator();
foreach (var id in deviceIds)
{
if (outputs.ContainsKey(id)) continue;
MMDevice? device = null;
WasapiOut? wasapi = null;
try
{
device = enumerator.GetDevice(id);
var name = device.FriendlyName;
var buffer = new BufferedWaveProvider(sharedFormat)
{
ReadFully = true,
DiscardOnBufferOverflow = true,
BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs),
};
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 15);
wasapi.Init(buffer);
wasapi.Play();
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Name = name };
onDiagnostic?.Invoke($"output added: \"{name}\"");
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"failed to open output \"{id}\": {ex.GetType().Name}: {ex.Message}");
try { wasapi?.Dispose(); } catch { /* ignore */ }
try { device?.Dispose(); } catch { /* ignore */ }
}
}
}
}
private static void DisposeOutput(OutputEntry o)
{
try { o.Output.Stop(); } catch { /* ignore */ }
try { o.Output.Dispose(); } catch { /* ignore */ }
try { o.Device.Dispose(); } catch { /* ignore */ }
}
private async Task ProduceLoop(CancellationToken ct)
{
// Pro Audio MMCSS for the producer thread — it's the one feeding all WASAPI outputs.
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
var ticksPerFrame = Stopwatch.Frequency * FrameMs / 1000;
var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
while (!ct.IsCancellationRequested)
{
try
{
var now = Stopwatch.GetTimestamp();
if (nextTickStopwatch > now)
{
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50);
if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break;
continue;
}
if (now - nextTickStopwatch > ticksPerFrame * 4)
{
nextTickStopwatch = now;
}
nextTickStopwatch += ticksPerFrame;
// Snapshot the buffers under the gate so we don't iterate a mid-mutation dict.
// Also skip the source.Read entirely when no outputs are ticked: in
// BothIndependent mode the source is a FanOutSource view shared with the ASIO
// lane, and pulling here when WASAPI has nothing ticked makes the FanOut
// consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the
// ASIO lane permanently reading from a cache 10 ms behind the source. That
// showed up in test logs as fanCacheMs sustained at 1214 ms with bufAvg=0,
// and audibly as an extra 10 ms baked into the ASIO lane's perceived latency.
// The gate-then-read order matters; the previous order (read first, then
// check outputs.Count) was the bug.
BufferedWaveProvider[] targets;
lock (gate)
{
if (outputs.Count == 0) continue;
targets = outputs.Values.Select(o => o.Buffer).ToArray();
}
var produced = source.Read(frameScratch, 0, FrameBytes);
if (produced <= 0) continue;
foreach (var buffer in targets)
{
try { buffer.AddSamples(frameScratch, 0, produced); }
catch { /* per-output failure shouldn't kill the loop */ }
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
onDiagnostic?.Invoke($"producer loop error: {ex.GetType().Name}: {ex.Message}");
await Task.Delay(50, ct).ConfigureAwait(false);
}
}
}
private sealed class OutputEntry
{
public required MMDevice Device { get; init; }
public required WasapiOut Output { get; init; }
public required BufferedWaveProvider Buffer { get; init; }
public required string Name { get; init; }
}
}
+121
View File
@@ -0,0 +1,121 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Owns the UDP receive socket and a single dedicated foreground thread that drains it.
/// Hands raw packets (byte buffer + length + remote endpoint) up to a callback supplied by the
/// owner — has no idea what's inside the packets.
///
/// Allocation-free in steady state: one fixed receive buffer reused across calls,
/// <see cref="Socket.ReceiveFrom"/> with <see cref="SocketAddress"/> avoids the per-call
/// IPEndPoint boxing that <see cref="UdpClient.ReceiveAsync"/> incurred.
/// </summary>
internal sealed class NetworkListener : IDisposable
{
private readonly Action<byte[], int, IPEndPoint> onPacket;
private readonly Action<string> onDiagnostic;
private CancellationTokenSource? cts;
private Socket? socket;
private Thread? thread;
// Time-in-user-handler instrumentation. We measure from "ReceiveFrom returned" to
// "onPacket returned" so the SNAP can split observed inter-packet jitter at the
// receiver. If this metric is consistently in the multi-ms range, the receiver's
// own processing chain is the source of the gap (lock contention with the audio
// thread, GC, decode work backing up) rather than the network or the sender.
private long maxOnPacketTicks;
public int TakeMaxOnPacketMs() =>
(int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency);
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
{
this.onPacket = onPacket;
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => socket is not null;
public void Start(int udpPort)
{
Stop();
cts = new CancellationTokenSource();
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
socket.ReceiveBufferSize = 512 * 1024;
socket.Bind(new IPEndPoint(IPAddress.Any, udpPort));
var startedSocket = socket;
var token = cts.Token;
thread = new Thread(() => ReceiveLoop(startedSocket, token))
{
IsBackground = true,
Name = "RemSound.Receive",
};
thread.Start();
onDiagnostic($"network listener bound to UDP :{udpPort}");
}
public void Stop()
{
cts?.Cancel();
try { socket?.Close(); } catch { /* ignore */ }
socket = null;
try { thread?.Join(500); } catch { /* ignore */ }
thread = null;
cts?.Dispose();
cts = null;
}
public void Dispose() => Stop();
private void ReceiveLoop(Socket activeSocket, CancellationToken token)
{
using var threadBoost = new WindowsAudioThreadBoost("Capture");
var buffer = new byte[2048];
EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0);
while (!token.IsCancellationRequested)
{
int received;
try
{
received = activeSocket.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref anyEndpoint);
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) { break; }
catch (ObjectDisposedException) { break; }
catch (SocketException) { continue; }
catch (OperationCanceledException) { break; }
if (received <= 0) continue;
if (anyEndpoint is not IPEndPoint remote) continue;
try
{
// Dispatch timing feeds the SNAP's rxDispMs column. Skipped when diagnostics
// are off so the receive loop isn't paying two Stopwatch reads + a CAS loop
// per packet for a number nobody is going to log.
if (RemSound.Core.DiagnosticsGate.Enabled)
{
var dispatchStart = Stopwatch.GetTimestamp();
onPacket(buffer, received, remote);
var elapsed = Stopwatch.GetTimestamp() - dispatchStart;
long current;
do { current = Volatile.Read(ref maxOnPacketTicks); }
while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current);
}
else
{
onPacket(buffer, received, remote);
}
}
catch (Exception ex)
{
onDiagnostic($"packet handler threw: {ex.GetType().Name}: {ex.Message}");
}
}
}
}
@@ -0,0 +1,98 @@
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Assembles multi-part PCM transport frames back into a single contiguous payload.
/// PCM frames at 48 kHz × 24-bit × 2 ch × 10 ms = 2880 bytes, split into 2 UDP parts.
///
/// On a healthy LAN, parts arrive in order. If a part is missed we drop the whole frame
/// rather than wait — at 10 ms cadence, waiting more than ~5 ms is worse than a single dropped frame.
/// </summary>
internal sealed class PcmFrameAssembler
{
private uint pendingFrameId;
private byte pendingPartIndex; // index of the NEXT expected part
private byte pendingTotalParts;
private readonly byte[] assemblyBuffer = new byte[8192]; // largest reasonable PCM frame
private int assemblyWritten;
private long rejectionCount;
private long discardedPartialCount;
/// <summary>
/// Number of incoming parts that were rejected outright (out-of-order, malformed, overflow).
/// Each rejection means at least one PCM frame's audio is lost.
/// </summary>
public long RejectionCount => Interlocked.Read(ref rejectionCount);
/// <summary>
/// Number of partially-assembled frames discarded because a new frame started before the
/// previous one's parts all arrived. Each discard means a half-finished frame's audio is lost.
/// </summary>
public long DiscardedPartialCount => Interlocked.Read(ref discardedPartialCount);
public bool TryAssemble(ReadOnlySpan<byte> partBytes, uint frameId, byte partIndex, byte totalParts, out ReadOnlySpan<byte> assembled)
{
assembled = default;
if (totalParts == 0)
{
Interlocked.Increment(ref rejectionCount);
return false;
}
// First part of a new frame? Start fresh, regardless of whether the previous one finished.
if (partIndex == 0)
{
// If we had a partial frame waiting, count it as a discard — its audio is lost.
if (pendingTotalParts != 0 && assemblyWritten > 0)
{
Interlocked.Increment(ref discardedPartialCount);
}
pendingFrameId = frameId;
pendingPartIndex = 0;
pendingTotalParts = totalParts;
assemblyWritten = 0;
}
else if (frameId != pendingFrameId || partIndex != pendingPartIndex || totalParts != pendingTotalParts)
{
// Mismatch — we missed the start, or this is from a different frame. Discard.
assemblyWritten = 0;
pendingTotalParts = 0;
Interlocked.Increment(ref rejectionCount);
return false;
}
if (assemblyWritten + partBytes.Length > assemblyBuffer.Length)
{
// Frame larger than expected — defensive, should never happen with our packetization.
assemblyWritten = 0;
pendingTotalParts = 0;
Interlocked.Increment(ref rejectionCount);
return false;
}
partBytes.CopyTo(assemblyBuffer.AsSpan(assemblyWritten));
assemblyWritten += partBytes.Length;
pendingPartIndex++;
if (pendingPartIndex == pendingTotalParts)
{
assembled = assemblyBuffer.AsSpan(0, assemblyWritten);
// Reset for next frame after the caller consumes.
pendingTotalParts = 0;
assemblyWritten = 0;
return true;
}
return false;
}
public void Reset()
{
pendingFrameId = 0;
pendingPartIndex = 0;
pendingTotalParts = 0;
assemblyWritten = 0;
}
}
+611
View File
@@ -0,0 +1,611 @@
using System.Net;
using NAudio.Wave;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Multi-source playout coordinator. Holds one <see cref="SessionPlayout"/> per active sender and
/// implements <see cref="IWaveProvider"/> by reading from all of them per WASAPI render callback
/// and summing into the output buffer. Volume / mute / clipping live here; per-session adaptive
/// rate lives inside each SessionPlayout.
///
/// Why multi-source: the previous design supported only one sender at a time and reset the
/// playout buffer on every endpoint change. With two senders simultaneously sending to the same
/// receiver (e.g. a peer and your own loopback for monitoring), Format packets arrived alternately
/// from each endpoint and the buffer was flushed several times per second — horrible crackle.
/// Now each sender owns its own buffer + drift corrector, and the mix bus sums them.
///
/// Concurrent-modification safety: <see cref="GetOrCreateSession"/> / <see cref="RemoveSession"/>
/// take a lock and mutate the dictionary; <see cref="Read"/> snapshots the current values list
/// (no allocation in steady state once the snapshot array has stabilised) before iterating, so it
/// never iterates a mid-mutation collection. The per-session Read is lock-free.
/// </summary>
internal sealed class PlayoutEngine : IWaveProvider
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int MixBytesPerFrame = MixChannels * sizeof(float);
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
// Soft-limiter parameters. Below the threshold, samples pass through untouched. Above it,
// a tanh-based soft-knee smoothly compresses excess so the output asymptotes to ±1 without
// ever clipping hard. This replaces the previous straight `Math.Clamp(-1, +1)` which slams
// peaks into a square wave on transient summation. Standard pattern in audio mixers — see
// research notes on conferencing-mixer clipping (NetEQ uses similar; PJSIP / RTP mixers too).
private const float LimiterThreshold = 0.9f;
private const float LimiterKnee = 1.0f - LimiterThreshold;
private readonly ReceiverDiagnostics diagnostics;
private readonly object sessionsLock = new();
// Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. One peer can produce
// multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native-
// independent audio mode). For the existing single-lane modes (WasapiOnly / AsioOnly /
// Both) the sender emits a single streamId so the dict still has one entry per peer,
// identical to the pre-refactor behaviour. The new mode adds a second entry per peer.
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), SessionPlayout> sessions = new();
private SessionPlayout[] sessionsSnapshot = [];
// 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
// garble output. The Mixed-route scratch keeps the original field names because that's
// what the legacy Read still uses; the lane-route surfaces own their own copies.
private float[] mixScratch = new float[8192];
private float[] sessionScratch = new float[8192];
private readonly LaneOutput wasapiLaneOutput;
private readonly LaneOutput asioLaneOutput;
// Per-route latency state. Stage 4.5 (2026-05-11): added so BothIndependent mode can run
// each lane at its own target/max without one lane's auto-tune dragging the other up.
// In classic modes only the Mixed route is ever read from; the others sit at defaults
// and consume no resources. Each LaneLatency's fields are volatile so UI-thread writes
// are visible to the audio render thread without locks.
private sealed class LaneLatency
{
public volatile int TargetMs = 30;
public volatile int MaxMs = 80;
}
private readonly LaneLatency mixedLatency = new();
private readonly LaneLatency wasapiLaneLatency = new();
private readonly LaneLatency asioLaneLatency = new();
private volatile bool muted;
private volatile float volume = 1f;
// 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI.
// Now mostly a safety-knob for the click-trim catastrophic path; in normal operation the
// Phase-2 drift corrector (in SessionPlayout) keeps the buffer near target so the trim
// never fires regardless of this value.
private volatile int smoothness = 3;
// User-pickable artifact for underrun gaps. Stored as raw int because volatile doesn't
// play with enum types directly. Push to existing SessionPlayouts on change so already-
// running streams pick the new artifact up on the very next gap.
private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst;
public void SetSmoothness(int value) => smoothness = Math.Clamp(value, 1, 10);
/// <summary>Sets the concealment artifact for every active session and for any future
/// session created after this call. Live-updates: the next time a session sees an
/// underrun, it uses the new artifact.</summary>
public void SetConcealmentArtifact(ConcealmentArtifact artifact)
{
concealmentArtifactRaw = (int)artifact;
var snap = sessionsSnapshot;
foreach (var s in snap) s.SetConcealmentArtifact(artifact);
}
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
/// <summary>Legacy property returning the Mixed route's target. Used by code paths that
/// don't care about per-route routing (every classic mode, plus diagnostics that report
/// "the" target latency in non-BothIndependent setups).</summary>
public int TargetLatencyMs => mixedLatency.TargetMs;
/// <summary>Legacy property returning the Mixed route's max.</summary>
public int MaxLatencyMs => mixedLatency.MaxMs;
/// <summary>Per-route target accessor. In BothIndependent the WASAPI and ASIO routes have
/// independent targets so each lane can settle at its native latency without the other
/// pulling it. In classic modes only Mixed is meaningful; the other two routes return
/// their defaults.</summary>
public int TargetLatencyMsFor(RenderRoute route) => LatencyFor(route).TargetMs;
public int MaxLatencyMsFor(RenderRoute route) => LatencyFor(route).MaxMs;
private LaneLatency LatencyFor(RenderRoute route) => route switch
{
RenderRoute.WasapiLane => wasapiLaneLatency,
RenderRoute.AsioLane => asioLaneLatency,
_ => mixedLatency,
};
/// <summary>Aggregate buffered ms across all active sessions. Used by the App's diagnostic
/// snapshot row. Per-session levels are not currently exposed (single number is enough for
/// the existing snapshot column; the auto-tune doesn't depend on it).</summary>
public int CurrentBufferMs
{
get
{
var snap = sessionsSnapshot;
if (snap.Length == 0) return 0;
var totalBytes = 0;
foreach (var s in snap) totalBytes += s.BufferedBytes;
return totalBytes / MixBytesPerFrame * 1000 / MixSampleRate;
}
}
public bool IsArmed
{
get
{
var snap = sessionsSnapshot;
foreach (var s in snap) if (s.IsArmed) return true;
return false;
}
}
public float Volume
{
get => volume;
set => volume = Math.Clamp(value, 0f, 1f);
}
public bool IsMuted
{
get => muted;
set => muted = value;
}
public PlayoutEngine(ReceiverDiagnostics diagnostics)
{
this.diagnostics = diagnostics;
wasapiLaneOutput = new LaneOutput(this, RenderRoute.WasapiLane);
asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane);
}
/// <summary>
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only
/// used in BothIndependent mode where the WASAPI render backend reads its own lane
/// independently of the ASIO render. In the three classic modes (WasapiOnly / AsioOnly /
/// Both) nothing ever reads from this surface and no session is ever tagged WasapiLane,
/// so it returns silence and consumes no resources.
/// </summary>
public IWaveProvider WasapiLaneOutput => wasapiLaneOutput;
/// <summary>
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.AsioLane"/>. Same
/// contract as <see cref="WasapiLaneOutput"/>; only used in BothIndependent mode.
/// </summary>
public IWaveProvider AsioLaneOutput => asioLaneOutput;
/// <summary>
/// Sets the user's delay knob. Slider value drives the playout target directly so the change
/// is audible immediately.
///
/// LOWER: by default disarms + drains every session. The buffer is now above the new target
/// and has to actually shrink before playback resumes. Brief silence is unavoidable on this
/// path; the user is asking for tighter latency and accepting the cost. Set
/// <paramref name="drainOnLower"/> = false to take the SOFT path instead — the buffer keeps
/// playing and the drift corrector's adaptive gain ramps it down over a few seconds. Used
/// for auto-tune-driven lowers, where the user didn't ask for an immediate change and
/// shouldn't hear one.
///
/// RAISE (2026-05-06 change): NO disarm, NO drain regardless of <paramref name="drainOnLower"/>.
/// The buffer is now below the new target but audio keeps playing — the drift corrector's
/// adaptive-gain term (see SessionPlayout) ramps the buffer up to the new target within
/// seconds without the user ever hearing silence. Previously every raise produced an
/// audible stop-start because the always-drain path blew the buffer away. Tweaking the
/// slider in tiny increments is now silent.
///
/// Equal value: no-op.
/// </summary>
/// <summary>Legacy single-route setter — operates on the Mixed route. Every classic-mode
/// call site continues to use this and behaves identically to pre-2026-05-11.</summary>
public void SetMaxLatencyMs(int value, bool drainOnLower = true) =>
SetMaxLatencyMs(RenderRoute.Mixed, value, drainOnLower);
/// <summary>
/// Per-route setter. Identical algorithm to the legacy one but only drains sessions
/// tagged with the matching route — so lowering the WASAPI lane's target won't disarm
/// the ASIO lane's session (and vice versa). In BothIndependent the WASAPI/ASIO routes
/// have their own slider in the UI driving each call.
/// </summary>
public void SetMaxLatencyMs(RenderRoute route, int value, bool drainOnLower = true)
{
var clamped = Math.Clamp(value, 1, 500);
var lane = LatencyFor(route);
var previousTarget = lane.TargetMs;
lane.MaxMs = clamped;
lane.TargetMs = clamped;
if (clamped < previousTarget && drainOnLower)
{
// Only drain sessions on THIS route — leaves other-route sessions playing.
var snap = sessionsSnapshot;
foreach (var s in snap)
{
if (s.Route != route) continue;
s.DisarmAndRequestDrain();
}
}
}
public SessionPlayout GetOrCreateSession(IPEndPoint endpoint, ushort streamId, int capacityBytes)
{
var key = (endpoint, streamId);
lock (sessionsLock)
{
if (!sessions.TryGetValue(key, out var sp))
{
sp = new SessionPlayout(endpoint, streamId, capacityBytes);
// Inherit the engine-wide artifact selection so a session created mid-stream
// gets the right artifact from frame zero (rather than the SessionPlayout
// default, which would only get overridden on the next SetConcealmentArtifact).
sp.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw);
sessions[key] = sp;
sessionsSnapshot = sessions.Values.ToArray();
}
return sp;
}
}
public bool RemoveSession(IPEndPoint endpoint, ushort streamId)
{
var key = (endpoint, streamId);
lock (sessionsLock)
{
if (sessions.Remove(key, out var sp))
{
sp.Dispose();
sessionsSnapshot = sessions.Values.ToArray();
return true;
}
return false;
}
}
public IReadOnlyList<SessionPlayout> ActiveSessions
{
get { lock (sessionsLock) return sessions.Values.ToList(); }
}
public void ResetAll()
{
lock (sessionsLock)
{
foreach (var s in sessions.Values) s.Dispose();
sessions.Clear();
sessionsSnapshot = [];
}
}
public long AggregateUnderruns
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.UnderrunCount;
return total;
}
}
/// <summary>Per-route underrun aggregator. The continuous auto-tune uses this in
/// BothIndependent mode so the WASAPI lane's underruns don't make the ASIO auto-tune
/// skip a tick (and vice versa). In classic modes only the Mixed route has sessions,
/// so AggregateUnderrunsFor(Mixed) == AggregateUnderruns.</summary>
public long AggregateUnderrunsFor(RenderRoute route)
{
long total = 0;
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) total += s.UnderrunCount;
}
return total;
}
/// <summary>True if at least one session is currently tagged for this route. Used by
/// the auto-tune to skip ticking a lane that has nobody to tune — without this gate
/// the ASIO auto-tune (for example) would react to the shared network-gap signal
/// populated by WASAPI-lane traffic and silently inflate its own target before the
/// user has even started an ASIO source, so the next time ASIO actually goes live the
/// receiver would already be pre-loaded with a high target.</summary>
public bool HasSessionsForRoute(RenderRoute route)
{
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) return true;
}
return false;
}
public long AggregateDrops
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DropCount;
return total;
}
}
/// <summary>Sum of click-trim drop bytes across all active sessions.</summary>
public long AggregateTrimDropBytes
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.TrimDropBytes;
return total;
}
}
/// <summary>Sum of slider-drain drop bytes across all active sessions.</summary>
public long AggregateDrainDropBytes
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DrainDropBytes;
return total;
}
}
/// <summary>Total click-trim fires (one per trim event, regardless of bytes dropped).</summary>
public long AggregateTrimFireCount
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.TrimFireCount;
return total;
}
}
/// <summary>Cumulative count of single-frame drops the Phase-2 drift corrector has applied.</summary>
public long AggregateDriftDropFrames
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal;
return total;
}
}
/// <summary>Cumulative count of single-frame repeats the Phase-2 drift corrector has applied.</summary>
public long AggregateDriftRepeatFrames
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal;
return total;
}
}
// === WASAPI render thread ===
/// <summary>
/// Render-side audio pull. Iterates every session regardless of lane tag and sums them
/// into one mixed bus. This is what every render backend (WasapiOnly, AsioOnly, classic
/// Both via the tee, and BothIndependent via the tee) reads from, so a user can pick any
/// output device for any received audio — independently of which capture technology the
/// sender used. Per-lane latency targets are still honoured: each session reads its own
/// route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured stream
/// can buffer at one latency and the ASIO-captured stream at another within the same
/// output mix. The lane-specific <see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>
/// surfaces are kept around for future per-route routing options but are not used by the
/// default render path (see <c>CompositeRenderBackend</c>). 2026-05-11 revision: previous
/// implementation filtered by route, which made it impossible to route a WASAPI-captured
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
/// long-standing cross-backend send/receive flow.
/// </summary>
public int Read(byte[] buffer, int offset, int count) =>
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
/// <summary>
/// Shared per-route render pull. Iterates the session snapshot, summing only those
/// sessions whose <see cref="SessionPlayout.Route"/> matches the requested filter into
/// the caller's scratch buffers, applies volume/mute/limiter, and packs to bytes. The
/// Mixed route additionally feeds <see cref="ReceiverDiagnostics"/> (output-step + buffer
/// level) — lane routes skip diagnostics to avoid double-counting in BothIndependent mode
/// where both lanes run their own ReadForRoute concurrently and the legacy single
/// per-tick stats columns are still the user-visible source of truth.
/// </summary>
internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
{
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
var outFrames = count / MixBytesPerFrame;
var outFloats = outFrames * MixChannels;
// Each route owns its scratch buffers; grow them in place if the consumer is asking
// for a bigger block than we've ever served before. Per-route ownership means the
// BothIndependent threads don't fight over one buffer.
if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats)
{
(mixBuf, sessionBuf) = GrowScratch(route, outFloats);
}
Array.Clear(mixBuf, 0, outFloats);
// Snapshot — local copy so the iteration is safe against concurrent dict mutations.
var snap = sessionsSnapshot;
// Pull this route's target/max from the per-route state. In Mixed (classic modes)
// this reads mixedLatency, identical to pre-Stage-4.5 behaviour. In BothIndependent
// the WASAPI and ASIO route reads pick up their respective LaneLatency entries so
// each lane's session is paced against its own slider value.
var routeLatency = LatencyFor(route);
var routeTargetMs = routeLatency.TargetMs;
var routeMaxMs = routeLatency.MaxMs;
var aggregateBufferedBytes = 0;
var anyContributed = false;
foreach (var session in snap)
{
if (session.Route != route) continue;
aggregateBufferedBytes += session.BufferedBytes;
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness);
if (produced <= 0) continue;
anyContributed = true;
var summed = produced * MixChannels;
for (var i = 0; i < summed; i++)
{
mixBuf[i] += sessionBuf[i];
}
}
if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes);
if (!anyContributed)
{
Array.Clear(buffer, offset, count);
return count;
}
// Apply volume / mute and the soft-knee tanh limiter before packing. Both the volume
// knob and the limiter are receiver-engine-wide concerns, so they apply equally to
// every route (matching the principle that a single user-set volume affects every
// output device regardless of which lane it belongs to).
var localVolume = muted ? 0f : volume;
for (var i = 0; i < outFloats; i++)
{
var v = mixBuf[i] * localVolume;
var sign = v < 0f ? -1f : 1f;
var abs = v * sign;
if (abs > LimiterThreshold)
{
var excess = abs - LimiterThreshold;
var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee);
v = sign * (LimiterThreshold + compressed);
}
mixBuf[i] = v;
}
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count;
}
/// <summary>
/// Read all sessions, regardless of lane tag, into a single mixed bus. Each session is
/// paced against ITS OWN lane's target/max latency, so a WASAPI-captured session and an
/// ASIO-captured session in BothIndependent mode each maintain their independent buffer
/// depths even though they end up in the same output mix. This is the path every render
/// backend reads from in normal operation — the lane surfaces above are kept for
/// potential per-output-device routing in a future revision but are not used today.
/// </summary>
private int ReadAllSessions(byte[] buffer, int offset, int count, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
{
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
var outFrames = count / MixBytesPerFrame;
var outFloats = outFrames * MixChannels;
if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats)
{
(mixBuf, sessionBuf) = GrowScratch(RenderRoute.Mixed, outFloats);
}
Array.Clear(mixBuf, 0, outFloats);
var snap = sessionsSnapshot;
var aggregateBufferedBytes = 0;
var anyContributed = false;
foreach (var session in snap)
{
// Per-session latency: each session's own lane governs its buffer behaviour, so a
// WASAPI-captured stream can sit at one target depth and an ASIO-captured stream
// at another. Mixing them at the output level doesn't collapse those targets.
var laneLatency = LatencyFor(session.Route);
aggregateBufferedBytes += session.BufferedBytes;
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, laneLatency.TargetMs, laneLatency.MaxMs, smoothness);
if (produced <= 0) continue;
anyContributed = true;
var summed = produced * MixChannels;
for (var i = 0; i < summed; i++)
{
mixBuf[i] += sessionBuf[i];
}
}
if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes);
if (!anyContributed)
{
Array.Clear(buffer, offset, count);
return count;
}
var localVolume = muted ? 0f : volume;
for (var i = 0; i < outFloats; i++)
{
var v = mixBuf[i] * localVolume;
var sign = v < 0f ? -1f : 1f;
var abs = v * sign;
if (abs > LimiterThreshold)
{
var excess = abs - LimiterThreshold;
var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee);
v = sign * (LimiterThreshold + compressed);
}
mixBuf[i] = v;
}
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
return count;
}
/// <summary>
/// Grow the per-route scratch buffers in place when a render backend asks for a bigger
/// block than we've previously served. Writes the new arrays back to the route-owning
/// fields (so subsequent reads from this route see the larger buffer) and returns them
/// to the caller for use in the current Read. The Mixed route's buffers live on the
/// PlayoutEngine itself for legacy reasons; the two lane routes own their buffers on
/// the corresponding LaneOutput instance. Each route is single-threaded (one consumer
/// per surface) so we don't need to lock around the realloc.
/// </summary>
private (float[] mix, float[] session) GrowScratch(RenderRoute route, int neededFloats)
{
switch (route)
{
case RenderRoute.Mixed:
if (mixScratch.Length < neededFloats) mixScratch = new float[neededFloats];
if (sessionScratch.Length < neededFloats) sessionScratch = new float[neededFloats];
return (mixScratch, sessionScratch);
case RenderRoute.WasapiLane:
if (wasapiLaneOutput.MixScratch.Length < neededFloats) wasapiLaneOutput.MixScratch = new float[neededFloats];
if (wasapiLaneOutput.SessionScratch.Length < neededFloats) wasapiLaneOutput.SessionScratch = new float[neededFloats];
return (wasapiLaneOutput.MixScratch, wasapiLaneOutput.SessionScratch);
case RenderRoute.AsioLane:
if (asioLaneOutput.MixScratch.Length < neededFloats) asioLaneOutput.MixScratch = new float[neededFloats];
if (asioLaneOutput.SessionScratch.Length < neededFloats) asioLaneOutput.SessionScratch = new float[neededFloats];
return (asioLaneOutput.MixScratch, asioLaneOutput.SessionScratch);
default:
return (mixScratch, sessionScratch);
}
}
/// <summary>
/// Per-lane IWaveProvider. Each instance filters PlayoutEngine's session snapshot down
/// to sessions tagged with a specific <see cref="RenderRoute"/> and runs the standard
/// volume/mute/limiter pipeline against just that subset. Only meaningful in
/// BothIndependent mode; in classic modes nothing reads from these surfaces.
/// </summary>
private sealed class LaneOutput : IWaveProvider
{
private readonly PlayoutEngine owner;
private readonly RenderRoute route;
// Each lane owns its own scratch (fields exposed to the owner so ReadForRoute can
// grow them via the same helper). Public-internal exposure rather than method-call
// because the grow path needs a ref to the slot, and there's exactly one caller per
// field — the owner. Keeping these private to the outer class via internal access.
internal float[] MixScratch = new float[8192];
internal float[] SessionScratch = new float[8192];
public WaveFormat WaveFormat => owner.WaveFormat;
public LaneOutput(PlayoutEngine owner, RenderRoute route)
{
this.owner = owner;
this.route = route;
}
public int Read(byte[] buffer, int offset, int count) =>
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false);
}
}
@@ -0,0 +1,265 @@
using System.Diagnostics;
namespace RemSound.Receiver;
/// <summary>
/// Sub-second telemetry that the App pulls once per second for the log file.
/// Tracks rolling stats so a 1 Hz snapshot reveals what's actually happening
/// at audio-rate resolution. All counters are interlocked or volatile so the
/// network thread, render thread, and App thread can read/write without locks.
///
/// Naming convention:
/// *PerSecond → reset on every second-boundary read
/// *Rolling → averaged over the last second
/// *Cumulative → since session start
/// </summary>
public sealed class ReceiverDiagnostics
{
// Network arrival timing.
private long lastPacketTicks;
private long maxArrivalGapTicks;
private long packetCountSinceLastReport;
// Buffer sampling. Each Read call records the buffer level it observed.
// We keep a tiny rolling window so the App can show min/avg/max for the last second.
private long bufferSampleSumBytes;
private int bufferSampleCount;
private int bufferSampleMinBytes = int.MaxValue;
private int bufferSampleMaxBytes;
// WASAPI render Read sizes.
private int maxRenderReadBytes;
private int renderReadCount;
// Render-callback timing — the parallel of sender's capture-callback gap. PlayoutEngine.Read
// is invoked by the audio device's render callback (NAudio's WASAPI or ASIO output wrapper).
// Healthy systems show sub-ms variance from a strict period (= ASIO buffer / sample rate, or
// WASAPI engine period). Spikes here mean the audio output thread is being scheduled with
// jitter — which manifests as audible discontinuities even when RemSound's playout buffer is
// healthy, because the audio HARDWARE expects samples on a rigid clock and gets them late.
// RemSound's "Underruns" counter measures whether RemSound's buffer ran dry; this measures
// whether the audio device's own buffer was being fed punctually.
private long lastRenderCallbackTicks;
private long maxRenderCallbackGapTicks;
// Sample-step diagnostic. Two quantities:
//
// 1. maxSampleStep — the largest |sample[n] - sample[n-1]| in the diag window. Peak
// indicator, prone to false positives on bright music. Kept for visibility.
//
// 2. spikeCount — adaptive second-derivative outlier detector. A click manifests as a
// second derivative |s[i+1] - 2*s[i] + s[i-1]| that's anomalously large *relative to
// its recent typical value*. Smooth music has consistent (low) second-derivative
// energy. Bright music has consistent (medium) second-derivative energy. A click has
// *suddenly* much larger second-derivative energy than recent norm, regardless of
// overall content level.
//
// The detector tracks an EMA of |second derivative| over ~64 samples (~1.3 ms at 48 kHz)
// and flags samples whose own second-derivative exceeds that EMA by a multiplier.
// Multiplier of 5× = "this sample's discontinuity is 5× louder than the recent local
// discontinuity baseline." Plus an absolute floor so quiet-content noise doesn't trip it.
//
// Behaviour by signal type:
// - Silence: zero second derivative → spikeCount stays 0.
// - Smooth tone: low, consistent 2nd derivative → ratio ~1 → spikeCount stays 0.
// - Bright tone: high but consistent 2nd derivative → ratio ~1 → spikeCount stays 0.
// - Click on top of any of the above: 2nd derivative spikes for one sample, ratio >> 5
// → spikeCount increments by 1 per click sample.
private const float SpikeEnvAlpha = 1f / 64f; // ~1.3 ms half-life at 48 kHz
private const float SpikeRatioThreshold = 5.0f; // sample's 2nd-deriv must be 5× recent norm
private const float SpikeAbsoluteFloor = 0.02f; // ignore near-silence noise
private float maxSampleStep;
private int spikeCount;
private float secondDerivEMA; // running average of |2nd derivative|
private bool spikeStateSeeded;
private float prevPrevSample; // s[i-2] when computing s[i]
// Last sample of the previous Read call — used as the seed for the first sample of the
// next Read so step measurement spans Read boundaries (otherwise we'd miss clicks that
// sit on the boundary between two Reads, which is exactly where buffer-edge clicks live).
private float lastWrittenSample;
private bool lastSampleSeeded;
public void RecordPacketArrived()
{
// Diagnostics-gate first. packetCountSinceLastReport feeds the SNAP diag line too so
// it isn't worth the audio-thread cost to keep incrementing it when nobody is reading.
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
var now = Stopwatch.GetTimestamp();
var prev = Interlocked.Exchange(ref lastPacketTicks, now);
if (prev != 0)
{
var gap = now - prev;
// Track max gap (lock-free max via CAS).
long currentMax;
do { currentMax = Volatile.Read(ref maxArrivalGapTicks); }
while (gap > currentMax && Interlocked.CompareExchange(ref maxArrivalGapTicks, gap, currentMax) != currentMax);
}
Interlocked.Increment(ref packetCountSinceLastReport);
}
/// <summary>
/// Zeros the inter-packet and render-callback timestamps so the next sample taken doesn't
/// measure a gap across a stream-session boundary. Called from <c>AudioReceiver</c>
/// whenever a new <c>StreamSession</c> opens — without this, the first packet of the new
/// session would record a gap equal to the entire idle duration between the previous
/// session ending and this one starting (potentially tens of seconds), poisoning the
/// auto-tune's recent-gap window and causing it to recommend an absurdly large latency
/// target. The same applies to the render-callback timing: a new audio output device or
/// re-opened ASIO driver should start its own gap measurement, not inherit one from the
/// previous backend's last render.
/// </summary>
public void ResetGapMeasurements()
{
Interlocked.Exchange(ref lastPacketTicks, 0);
Interlocked.Exchange(ref maxArrivalGapTicks, 0);
Interlocked.Exchange(ref lastRenderCallbackTicks, 0);
Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0);
}
public void RecordBufferLevel(int bufferedBytes)
{
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
Interlocked.Add(ref bufferSampleSumBytes, bufferedBytes);
Interlocked.Increment(ref bufferSampleCount);
// Track min/max via CAS.
int curMin;
do { curMin = Volatile.Read(ref bufferSampleMinBytes); }
while (bufferedBytes < curMin && Interlocked.CompareExchange(ref bufferSampleMinBytes, bufferedBytes, curMin) != curMin);
int curMax;
do { curMax = Volatile.Read(ref bufferSampleMaxBytes); }
while (bufferedBytes > curMax && Interlocked.CompareExchange(ref bufferSampleMaxBytes, bufferedBytes, curMax) != curMax);
}
public void RecordRenderRead(int bytesRequested)
{
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
Interlocked.Increment(ref renderReadCount);
int curMax;
do { curMax = Volatile.Read(ref maxRenderReadBytes); }
while (bytesRequested > curMax && Interlocked.CompareExchange(ref maxRenderReadBytes, bytesRequested, curMax) != curMax);
// Track the gap since the previous render callback. First call seeds the timestamp
// without recording a gap (no prior reference). Lock-free max-update via CAS.
var now = Stopwatch.GetTimestamp();
var prev = Interlocked.Exchange(ref lastRenderCallbackTicks, now);
if (prev != 0)
{
var gap = now - prev;
long currentMax;
do { currentMax = Volatile.Read(ref maxRenderCallbackGapTicks); }
while (gap > currentMax && Interlocked.CompareExchange(ref maxRenderCallbackGapTicks, gap, currentMax) != currentMax);
}
}
/// <summary>Scan a span of float samples that RemSound is about to hand to NAudio and
/// record (a) the peak sample-to-sample step and (b) an adaptive count of second-
/// derivative outliers — samples whose discontinuity is anomalously large relative to
/// recent local norm. The latter is the click-specific signal: it's content-INVARIANT,
/// triggering only when a sample really does break the local audio's predictability.</summary>
public void RecordOutputSampleSteps(ReadOnlySpan<float> samples)
{
// Most expensive probe in the engine — per-sample second-derivative arithmetic on
// every render block. Gate it at the top so the render thread doesn't pay any of
// this when nobody is going to read the column.
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
if (samples.IsEmpty) return;
var localMax = maxSampleStep;
var localSpikes = spikeCount;
var prev = lastSampleSeeded ? lastWrittenSample : samples[0];
var prevPrev = spikeStateSeeded ? prevPrevSample : prev;
// Initial seed for the EMA on first-ever call: small positive value so the first
// few samples can't all register as anomalies before the EMA has a chance to learn.
var derivEMA = spikeStateSeeded ? secondDerivEMA : 0.01f;
var oneMinusAlpha = 1f - SpikeEnvAlpha;
for (var i = 0; i < samples.Length; i++)
{
var cur = samples[i];
var step = cur - prev;
if (step < 0) step = -step;
if (step > localMax) localMax = step;
// Second derivative: |s[i] - 2*s[i-1] + s[i-2]|. Smooth audio has low and
// consistent values; a click introduces a sudden large value at one sample.
var d2 = cur - 2f * prev + prevPrev;
if (d2 < 0f) d2 = -d2;
// Spike detector: anomalously high second derivative relative to recent norm.
// The absolute floor (0.02) prevents counting in near-silence where the EMA
// is tiny and any small sample noise would technically exceed N× the EMA.
// Math.Max ensures we don't divide-by-zero or trip on EMA close to 0.
var dynamicThreshold = Math.Max(derivEMA * SpikeRatioThreshold, SpikeAbsoluteFloor);
if (d2 > dynamicThreshold)
{
localSpikes++;
// Update the EMA WITHOUT folding this anomaly in (so a click doesn't poison
// the baseline and mask subsequent clicks). Re-feed the EMA with its current
// value, effectively a no-op update on click samples.
}
else
{
// Update EMA only on non-anomalous samples — keeps the baseline tracking
// smooth audio character, not click events.
derivEMA = derivEMA * oneMinusAlpha + d2 * SpikeEnvAlpha;
}
prevPrev = prev;
prev = cur;
}
maxSampleStep = localMax;
spikeCount = localSpikes;
secondDerivEMA = derivEMA;
prevPrevSample = prevPrev;
spikeStateSeeded = true;
lastWrittenSample = prev;
lastSampleSeeded = true;
}
/// <summary>
/// Snapshot the rolling counters and reset them. Called by the App once per second.
/// </summary>
public DiagSnapshot Take(int mixBytesPerSecond)
{
var maxGapTicks = Interlocked.Exchange(ref maxArrivalGapTicks, 0);
var pktCount = Interlocked.Exchange(ref packetCountSinceLastReport, 0);
var sumBytes = Interlocked.Exchange(ref bufferSampleSumBytes, 0);
var sampleCount = Interlocked.Exchange(ref bufferSampleCount, 0);
var minBytes = Interlocked.Exchange(ref bufferSampleMinBytes, int.MaxValue);
var maxBytes = Interlocked.Exchange(ref bufferSampleMaxBytes, 0);
var maxReadBytes = Interlocked.Exchange(ref maxRenderReadBytes, 0);
var readCount = Interlocked.Exchange(ref renderReadCount, 0);
var maxRenderCbGap = Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0);
// Sample-step is read from the render thread (which is the only writer); diag thread
// reads + zeroes. The reader sees a slightly stale value if a Read is in flight, which
// is fine — values will fold into the next snapshot.
var maxStep = maxSampleStep;
maxSampleStep = 0f;
var bigSteps = spikeCount;
spikeCount = 0;
var ticksToMsScale = 1000.0 / Stopwatch.Frequency;
return new DiagSnapshot(
PacketCount: pktCount,
MaxArrivalGapMs: (int)(maxGapTicks * ticksToMsScale),
BufferAvgMs: sampleCount > 0 ? (int)(sumBytes / sampleCount * 1000.0 / mixBytesPerSecond) : 0,
BufferMinMs: minBytes == int.MaxValue ? 0 : (int)(minBytes * 1000.0 / mixBytesPerSecond),
BufferMaxMs: (int)(maxBytes * 1000.0 / mixBytesPerSecond),
BufferSampleCount: sampleCount,
MaxRenderReadMs: (int)(maxReadBytes * 1000.0 / mixBytesPerSecond),
MaxRenderCallbackGapMs: (int)(maxRenderCbGap * ticksToMsScale),
RenderReadCount: readCount,
MaxOutputSampleStep: maxStep,
EnvelopeSpikeCount: bigSteps);
}
public readonly record struct DiagSnapshot(
long PacketCount,
int MaxArrivalGapMs,
int BufferAvgMs,
int BufferMinMs,
int BufferMaxMs,
int BufferSampleCount,
int MaxRenderReadMs,
int MaxRenderCallbackGapMs,
int RenderReadCount,
float MaxOutputSampleStep,
int EnvelopeSpikeCount);
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>RemSound.Receiver</RootNamespace>
<AssemblyName>RemSound.Receiver</AssemblyName>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="Concentus" Version="2.2.2" />
</ItemGroup>
</Project>
+749
View File
@@ -0,0 +1,749 @@
using System.Diagnostics;
using System.Net;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// One incoming sender's playout state: its own SPSC ring buffer plus a small set of drift /
/// concealment / smoothness state. Each remote endpoint that's actively sending audio gets
/// exactly one SessionPlayout. <see cref="PlayoutEngine"/> owns the collection and reads from
/// all of them per render callback, summing into the mix bus.
///
/// Drift correction: each sender has its own audio crystal that runs at slightly different
/// rate from the receiver's. This class compensates with a slow integrator that drops or
/// repeats one stereo frame at a time when sustained drift is detected, with a short cosine
/// crossfade across each splice for inaudibility. See <c>DriftGain</c> / <c>DriftCrossfadeFrames</c>.
///
/// Threading: <see cref="Write"/> runs on the network thread (per-sender producer);
/// <see cref="ReadFloats"/> runs on the WASAPI/ASIO render thread (single consumer). The
/// AudioRingBuffer is SPSC-safe; drift / concealment state is only touched from the consumer.
/// </summary>
internal sealed class SessionPlayout : IDisposable
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int MixBytesPerFrame = MixChannels * sizeof(float);
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
private readonly AudioRingBuffer playout;
// Scratch buffer used by the drift-correction crossfade path. Sized as needed inside
// ReadFloats; persistent here so we don't reallocate per call.
private float[] driftScratch = new float[8192];
private volatile bool playbackArmed;
private volatile bool drainRequested;
// Tracks the largest single Write's audio duration in ms — i.e. the active codec's
// packet-frame size as observed at the buffer level. Used to floor the click-trim
// margin so we don't false-trim during the natural sawtooth caused by packet
// arrival (each packet bumps the buffer by frame-ms, then render drains it down).
// Updated from the network thread; read from the audio thread. Volatile is enough
// because we only ever monotonically increase it within a session lifetime.
private volatile int largestWriteMs;
// === Drop-cause split ===
// Codex pointed out that the legacy `DropCount` on the ring buffer rolled up every reason
// we ever dropped audio bytes, making "Drops" in the diag opaque. These per-cause counters
// let the diag log distinguish:
// * trim drops — smoothness-knob click-trim trimming the buffer toward target
// * drain drops — one-shot drain when the user moves the latency slider
// * catastrophic — TrimFromProducer when the buffer crosses the 1s safety cap
// (Ring-buffer overflow on Write is still counted in playout.DropCount; we expose that
// separately.) Each counter is in BYTES so the magnitudes are comparable.
private long trimDropBytes;
private long drainDropBytes;
// Separate count of how many TIMES the click-trim fired (a tiny number tells us frequency,
// independent of the byte amount).
private long trimFireCount;
// === Underrun concealment state ===
// When the playout ring buffer comes up short on a render-side read, AudioRingBuffer
// silence-fills the missing portion with hard zero. The transient from the last real
// sample (amplitude X) to instant zero produces an audible click, especially on PCM
// (Opus has its own decoder-side PLC for packet loss but doesn't help with audio-thread
// starvation). We replace that hard zero with a brief envelope from the last real sample
// down to silence, and a matching envelope back up when audio resumes. The buffer is still
// silent during a sustained underrun — but the *edges* are smooth, which is where the
// human ear hears the click. ConcealFadeFramesShort at 32 = ~0.67 ms at 48 kHz.
//
// The artifact character is user-pickable (cosine tone short / cosine tone low / noise
// burst / raw click). Each option uses the same edge-smoothing principle but a different
// generator for the burst itself; see ApplyFadeOut / ApplyFadeIn.
private const int ConcealFadeFramesShort = 32;
private const int ConcealFadeFramesLow = 96;
// After this many consecutive empty-buffer reads, stop synthesising concealment and just
// emit silence. Concealment is meant to mask brief transient gaps (a packet late by a few
// ms); it should NOT fire forever when the sender has actually gone away. Without this
// guard, killing the sender produced a "shshshsh" tremolo for ~4 s on the receiver — every
// render callback wrote another noise burst into a buffer that never refilled, until the
// AudioReceiver's idle-prune (4 s) tore the session down. 8 consecutive empties at typical
// 5 ms ASIO render = 40 ms of repeated bursts before we give up; covers normal jitter
// without bleeding into "sender gone" pauses.
private const int ConcealmentMaxConsecutiveEmpties = 8;
private bool inUnderrunConcealment;
private int consecutiveEmptyReads;
private float lastConcealSampleL;
private float lastConcealSampleR;
private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst;
// Per-session RNG for noise concealment. Seeded from process-level Shared so each session
// gets a different sequence — but we don't care about reproducibility, just character.
private readonly Random concealRng = new(Random.Shared.Next());
// === Drift correction (Phase 2, 2026-05-06) ===
// Continuous low-rate clock-drift correction. The receiver and sender each have their own
// audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB
// audio). Without correction, the playout buffer slowly drifts up (sender faster) or down
// (sender slower) and eventually clicks via either overflow or underrun.
//
// The previous design corrected via a continuously-modulated WdlResampler — which produced
// sample-level corruption and was the source of all the per-sample artefacts we hunted for
// weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern:
// **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO
// FRAME at a time when the integrator signals sustained drift.** A single-frame drop or
// repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal
// content, especially when timed by an integrator that fires only on sustained drift, not
// on packet-arrival jitter.
//
// Mechanism per Read:
// 1. Sample the current buffer level vs target.
// 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator.
// 3. If accumulator >= 1, drop one frame from the head of the playout buffer
// (sender faster — we've consumed less than it produced; speed up consumption by
// one frame). Decrement accumulator.
// 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower —
// stall consumption by one frame). Increment accumulator.
//
// Behaviour by drift rate:
// - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0,
// no corrections fire. Silent.
// - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator
// grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio
// dropped or repeated every ~4 seconds. Inaudible.
// - Higher transient drift (e.g. system load briefly): integrator catches up within
// seconds, brief burst of corrections, then settles. Still inaudible.
//
// The existing click-trim block above is kept as a safety net for catastrophic conditions
// (large step changes that the slow integrator can't keep up with). At normal drift rates
// the integrator never lets the buffer reach the click-trim threshold, so the trim should
// effectively never fire in steady-state operation.
private double driftAccumulatorFrames;
private long prevDriftSampleTicks;
private int pendingRepeatFrames;
private long driftDropFramesTotal;
private long driftRepeatFramesTotal;
// Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous
// gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running
// USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with
// single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades
// click rate for buffer drift; combined with the crossfade-on-splice change, each
// correction is also significantly less audible per event.
//
// At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm
// drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec
// steady-state — which combined with crossfaded splices should push perceived click rate
// toward inaudible.
//
// 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady-
// state clock-drift compensation but pathologically slow when the buffer is far from
// target — e.g. after a slider raise the buffer sits below target and drift correction
// takes minutes to fill it. Empirically observed in user testing as "every session sounds
// different": the buffer wandered for tens of seconds at whatever level the initial
// arming chaos left it at. Now the effective gain scales linearly with absolute error
// beyond the small-error band, capped, so:
// * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle)
// * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale)
// At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving
// a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with
// a barely-audible 0.2% rate offset during the fill.
private const double DriftGain = 0.005;
// Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz.
private const double DriftSmallErrorFrames = 50;
// Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch
// (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content).
private const double DriftMaxGainScale = 20.0;
// Number of stereo frames each side of a splice point that get blended when a drop or
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
// characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz —
// shorter than a typical impulse response, so the smear doesn't blur transients audibly.
private const int DriftCrossfadeFrames = 8;
// Pending corrections (sample-aligned single-frame edits at the next Read).
private int pendingDropFrames;
// Public accessors for the diag log.
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
public IPEndPoint Endpoint { get; }
/// <summary>The stream ID this session was opened for. Sessions are keyed by
/// (Endpoint, StreamId) so a single peer can produce multiple simultaneous streams
/// (e.g. WASAPI lane + ASIO lane in the native-independent mode). For single-lane
/// modes there's still one session per peer with whatever streamId the sender chose
/// (currently 1).</summary>
public ushort StreamId { get; }
/// <summary>Which render route this session's audio belongs to. Set by AudioReceiver
/// from the format packet's Lane byte at session-creation (and updated on the rare
/// in-place format change that keeps the same SessionPlayout alive). PlayoutEngine
/// uses this to decide which of its per-route IWaveProvider surfaces this session
/// 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;
public int BufferedBytes => playout.BufferedBytes;
public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
public long UnderrunCount => playout.UnderrunCount;
public long DropCount => playout.DropCount;
public bool IsArmed => playbackArmed;
/// <summary>Per-cause drop accessors (cumulative bytes / counts since session start).
/// Splits the previously-opaque DropCount so the diag log can distinguish click-trim
/// from drain-on-knob-change from ringbuffer overflow. AggregateDrops on the engine
/// continues to expose the rolled-up total for back-compat.</summary>
public long TrimDropBytes => Interlocked.Read(ref trimDropBytes);
public long DrainDropBytes => Interlocked.Read(ref drainDropBytes);
public long TrimFireCount => Interlocked.Read(ref trimFireCount);
/// <summary>Sets the concealment artifact this session's playout uses on underrun gaps.
/// Takes effect on the very next gap; no need to restart playback. Receiver-side only —
/// the sender doesn't see this and wouldn't behave differently if it did.</summary>
public void SetConcealmentArtifact(ConcealmentArtifact value) =>
concealmentArtifactRaw = (int)value;
/// <summary>UTC time of the most recent successful audio write. Used by <see cref="AudioReceiver"/>
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow;
public SessionPlayout(IPEndPoint endpoint, ushort streamId, int capacityBytes)
{
Endpoint = endpoint;
StreamId = streamId;
playout = new AudioRingBuffer(capacityBytes);
}
public void Write(ReadOnlySpan<byte> source)
{
var ms = source.Length * 1000 / MixBytesPerSecond;
if (ms > largestWriteMs) largestWriteMs = ms;
playout.Write(source);
LastWriteUtc = DateTime.UtcNow;
}
/// <summary>
/// Network-thread callback after a frame has been queued. Arms playback the moment this
/// session's buffer first reaches the user's target; subsequent reads then engage the
/// drift corrector. Each session arms independently, so a newly-arrived sender can start
/// playing without waiting for already-armed sessions.
///
/// Also enforces a CATASTROPHIC-only cap on buffer level: if audio piles up beyond 1 second
/// (because the render thread hasn't started yet, or got stuck), we trim down to 250 ms.
/// The threshold is intentionally far above any reasonable jitter cushion — earlier we used
/// 3× target which fought with the drift corrector on a noisy WAN (target 10 ms, real
/// jitter up to 76 ms ⇒ buffer was being trimmed every second, causing the very clicking
/// it was supposed to avoid). Now the cap is purely a safety net against catastrophic
/// backlogs (multi-second pile-ups while no consumer exists); ordinary jitter is absorbed
/// by the buffer + drift corrector + click-trim combo.
/// </summary>
public void NoteFramesQueued(int targetLatencyMs)
{
const int CatastrophicCapMs = 1000;
const int CatastrophicTrimToMs = 250;
if (playout.BufferedBytes > MillisecondsToBytes(CatastrophicCapMs))
{
playout.TrimFromProducer(MillisecondsToBytes(CatastrophicTrimToMs));
}
if (playbackArmed) return;
if (playout.BufferedBytes >= MillisecondsToBytes(Math.Max(targetLatencyMs, 1)))
{
playbackArmed = true;
}
}
/// <summary>Disarm and request a drain on the next read — used when the user raises or
/// lowers the latency knob. The mix bus continues with whatever's already armed.</summary>
public void DisarmAndRequestDrain()
{
playbackArmed = false;
drainRequested = true;
}
/// <summary>Reset the buffer and per-session state. Used at start/stop. Arming will rebuild
/// from the next packets that arrive.</summary>
public void Reset()
{
playout.Reset();
playbackArmed = false;
largestWriteMs = 0;
inUnderrunConcealment = false;
consecutiveEmptyReads = 0;
lastConcealSampleL = 0f;
lastConcealSampleR = 0f;
driftAccumulatorFrames = 0;
prevDriftSampleTicks = 0;
pendingDropFrames = 0;
pendingRepeatFrames = 0;
}
public void Dispose()
{
// AudioRingBuffer is managed; nothing to free explicitly. Method present for symmetry
// with Stream/Capture sessions and to allow future per-session unmanaged state.
}
/// <summary>
/// WASAPI/ASIO render thread. Pulls <paramref name="outFrames"/> stereo frames from this
/// session's playout ring into <paramref name="output"/>, applying drift correction and
/// underrun concealment along the way. Returns the count of frames actually produced; if
/// the session is disarmed (or drained completely) the return is 0 and
/// <paramref name="output"/> is untouched (caller is responsible for zero-fill).
/// </summary>
public int ReadFloats(Span<float> output, int outFrames, int targetLatencyMs, int currentMaxLatencyMs, int smoothness = 3)
{
// Drain on user knob change.
if (drainRequested)
{
drainRequested = false;
var targetBytes = MillisecondsToBytes(targetLatencyMs);
var buffered = playout.BufferedBytes;
if (buffered > targetBytes)
{
var bytes = buffered - targetBytes;
playout.DropOldest(bytes);
Interlocked.Add(ref drainDropBytes, bytes);
}
}
if (!playbackArmed)
{
return 0;
}
// NOTE: there used to be an "auto-disarm if buffer empty" block here. It was added
// 2026-04-30 to clean up phantom underrun counts after a peer disconnect (the
// 4-second idle-prune fires later, so without auto-disarm the underrun counter would
// climb at ~100/sec while we waited). The comment claimed "mix output unchanged
// either way" — but that was wrong at tight target latency.
//
// What auto-disarm did wrong: any time the buffer dipped to zero even momentarily
// (ordinary sender-side mix-tick jitter — a 16ms gap between packets is normal on
// Windows), it would disarm the session and return 0. ReadFloats then output silence
// until packets refilled the buffer ALL THE WAY BACK to the user's target latency
// and NoteFramesQueued re-armed. Each transient underrun became a 10-15ms silence
// gap instead of a few-ms pad. That's the audible click that Ed kept hearing on
// localhost at target=10 vs the older build that ran clean.
//
// Now: a momentary empty buffer just produces a small silence-pad on this Read (the
// underrun counter still increments via playout.ReadFloats's own silence-fill — fine,
// that's diagnostic noise not audio noise). The 4-second idle-prune in AudioReceiver
// still handles long-term disconnect by tearing the session down entirely. No auto-
// disarm needed.
// === Click-based buffer-smoothness trim ===
//
// The Buffer-smoothness knob (1 = aggressive, 10 = smooth) controls how aggressively
// we DROP oldest samples when the buffer drifts above target. When (bufferedMs >
// target + trimMargin) we drop the excess down to target. Causes a brief click at the
// drop point but holds the queue right at the user's chosen latency.
//
// Largely a safety net post-Phase 2: the drift corrector below keeps the buffer near
// target in normal operation, so the trim only fires under catastrophic conditions
// (large step changes the slow integrator can't keep up with). Replaced an earlier
// resampler-rate controller that pitch-shifted music while correcting drift — clicks
// turned out to be the lesser evil, and the trim itself is a direct DropOldest on the
// ring buffer (no resampler involved), so it's guaranteed to fire when needed.
//
// Margin and drop-destination computation. SPLIT BY KNOB:
//
// smoothness == 1 ("stupid aggressive" — used in ASIO Tight Latency mode for
// sub-10 ms target):
// floor = largestWriteMs * 2 + 4 (min 4)
// drop-to = target + largestWriteMs (one packet's cushion only)
// This is the original "reconnect-feel" tightness — tight threshold,
// tight drop, frequent clicks but glued to target. Don't touch this. It's
// what makes ASIO at target=1 ms snap back to target after every burst.
//
// smoothness >= 2:
// floor = largestWriteMs * 4 + 4 (min 15)
// drop-to = target + largestWriteMs * 2 + 5 (covers render-period +
// frame + jitter pad)
// The looser values prevent default-smoothness-3 from tripping on routine
// startup bursts (96 kHz device + Opus 5 ms saw a 40 ms initial buffer that
// exceeded the old default threshold of 32 ms — the rate controller would
// have handled it in a few seconds, but trim fired and dropped the buffer
// too low to absorb normal sender jitter, causing 20 underruns/sec for the
// rest of the session).
//
// Direct evidence: localhost 96 kHz Opus test 2026-05-02 06:37 with
// smoothness=3 — drops=9600 from one startup trim, then 20 underruns/sec.
// Subsequent 48 kHz test with same smoothness ran clean because trim never
// fired (buffer never quite reached the 32 ms threshold). The looser default
// floor lets the rate controller handle initial transients on either device
// rate.
//
// Examples at target=10 ms:
// smoothness=1:
// PCM Tight 2.5: floor=8. drop to 12.5. trims at >18.
// Opus 10: floor=24. drop to 20. trims at >34.
// smoothness=3 (default):
// PCM Tight 2.5: floor=15. drop to 19. trims at >33.
// PCM 5: floor=24. drop to 25. trims at >42.
// Opus 10: floor=44. drop to 35. trims at >62.
var clampedKnob = Math.Clamp(smoothness, 1, 10);
var aggressive = clampedKnob == 1;
var floorMarginMs = aggressive
? Math.Max(largestWriteMs * 2 + 4, 4)
: Math.Max(largestWriteMs * 4 + 4, 15);
var dropToCushionMs = aggressive
? largestWriteMs
: largestWriteMs * 2 + 5;
var knobExtraMs = clampedKnob switch
{
1 => 0,
2 => 3,
3 => 8,
4 => 16,
5 => 28,
6 => 45,
7 => 70,
8 => 110,
9 => 200,
_ => -1, // 10 = no trim
};
if (knobExtraMs >= 0)
{
var trimMarginMs = floorMarginMs + knobExtraMs;
var trimThresholdBytes = MillisecondsToBytes(targetLatencyMs + trimMarginMs);
if (playout.BufferedBytes > trimThresholdBytes)
{
var keepBytes = MillisecondsToBytes(Math.Max(targetLatencyMs + dropToCushionMs, 1));
var dropBytes = playout.BufferedBytes - keepBytes;
if (dropBytes > 0)
{
playout.DropOldest(dropBytes);
Interlocked.Add(ref trimDropBytes, dropBytes);
Interlocked.Increment(ref trimFireCount);
}
}
}
// === Drift correction (Phase 2) ===
//
// Continuously integrate buffer-level error and drop / repeat single frames at low
// rate to keep buffer aligned with target despite clock-drift between sender and
// receiver crystals. Replaces the continuous adaptive resampling that produced
// sample-level artefacts (analysed 2026-05-06). See the field-block comment above
// for the design rationale.
//
// SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already
// be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g.
// one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on
// the capture side so the wire format is consistent — but if a future change emits
// at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate /
// 48000, computed once, never modulated). The continuous-modulation pattern was the
// bug; a fixed ratio is fine.
var driftTicks = Stopwatch.GetTimestamp();
var driftTargetBytes = MillisecondsToBytes(targetLatencyMs);
if (prevDriftSampleTicks != 0)
{
var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame;
// Adaptive gain: baseline at small errors (gentle steady-state compensation for
// clock drift) but accelerated at large errors (fast convergence after a slider
// raise or initial arming overshoot). Without this, the buffer can sit at any
// level between 0 and target+jitter for tens of seconds — making sessions feel
// randomly different. With this, the buffer reliably reaches target within a few
// seconds of any disturbance.
var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames;
var gainScale = absErrorFrames <= DriftSmallErrorFrames
? 1.0
: Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale);
driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale;
// Clamp to prevent runaway in pathological conditions (e.g. session pause).
if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0;
else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0;
}
prevDriftSampleTicks = driftTicks;
// Queue at most one correction per Read so corrections spread evenly rather than burst.
if (driftAccumulatorFrames >= 1.0)
{
pendingDropFrames++;
driftAccumulatorFrames -= 1.0;
}
else if (driftAccumulatorFrames <= -1.0)
{
pendingRepeatFrames++;
driftAccumulatorFrames += 1.0;
}
// === Read with optional crossfaded drop / repeat ===
//
// The trick to audibly-clean drift correction: don't perform the splice as a hard
// cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then
// CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine
// window blends the audio either side of the splice into a smooth smear instead of
// a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than
// any audible transient and far less perceptible than the original sample-level
// discontinuity.
//
// Splice position: middle of the output buffer. Could choose a low-amplitude moment
// for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on
// typical content and keeps the code simple.
var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
// Don't try to do both in the same Read; they'd cancel anyway.
if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; }
if (dropThisCall > 0)
{
// Read outFrames + 1 frames into the output span by reading the first half,
// skipping the splice with crossfade, then reading the second half. We need
// a small extra-sample scratch for the splice. Reuse driftScratch as
// temp storage (it's already managed and grows with outFrames).
var extraFloats = (outFrames + 1) * MixChannels;
if (driftScratch.Length < extraFloats)
{
driftScratch = new float[extraFloats];
}
var temp = driftScratch.AsSpan(0, extraFloats);
ReadInputWithConcealment(temp);
// Crossfade the splice. Splice position = midpoint of the output frame.
// Result: outFrames samples where one is "elided" via a cosine cross-blend.
ApplyDropCrossfade(temp, output, outFrames);
pendingDropFrames--;
Interlocked.Increment(ref driftDropFramesTotal);
}
else if (repeatThisCall > 0)
{
// Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded
// insertion at the splice point.
var shortFloats = (outFrames - 1) * MixChannels;
if (driftScratch.Length < shortFloats)
{
driftScratch = new float[shortFloats];
}
var temp = driftScratch.AsSpan(0, shortFloats);
ReadInputWithConcealment(temp);
ApplyRepeatCrossfade(temp, output, outFrames);
pendingRepeatFrames--;
Interlocked.Increment(ref driftRepeatFramesTotal);
}
else
{
ReadInputWithConcealment(output);
}
return outFrames;
}
/// <summary>Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames
/// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames
/// samples on each side.</summary>
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
{
// Splice at midpoint of output frames. The "skipped" sample in temp lives at index
// spliceIdx; either side of it gets cross-blended.
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
{
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
}
// Window: cosine crossfade. As we walk through `window` output frames, blend from
// temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the
// "after-skip" sample). The blend mixes consecutive temp positions so the splice
// is spread out smoothly.
for (var k = 0; k < window; k++)
{
var t = (k + 1) / (double)(window + 1);
// Cosine-shaped smooth fade from 0 to 1 across the window.
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var fadeOut = 1f - fadeIn;
var beforeIdx = (preEnd + k) * MixChannels;
var afterIdx = (preEnd + 1 + k) * MixChannels;
var dstIdx = (preEnd + k) * MixChannels;
output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn;
output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn;
}
// Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames].
// The "+1" on the source side is the elision: we skip one frame from temp.
var postStartTemp = spliceIdx + halfWindow + 1;
var postStartOut = spliceIdx + halfWindow;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
}
/// <summary>Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames
/// with one synthesised at the splice via a cosine blend that "stretches" temp by one
/// frame.</summary>
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
{
var spliceIdx = outFrames / 2;
var window = DriftCrossfadeFrames;
var halfWindow = window / 2;
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
var preEnd = spliceIdx - halfWindow;
if (preEnd > 0)
{
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
}
// Window of (window + 1) output frames mapped to (window) temp frames. Cosine
// crossfade synthesizes the extra frame: each output sample in the window is a
// blend of two adjacent temp samples, with the blend weight progressing slower than
// the index, effectively inserting a "smoothed" extra sample.
for (var k = 0; k <= window; k++)
{
var t = k / (double)(window + 1);
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var fadeOut = 1f - fadeIn;
// Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k].
// For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone.
var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels;
var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels;
var dstIdx = (preEnd + k) * MixChannels;
output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn;
output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn;
}
// Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames].
var postStartTemp = spliceIdx + halfWindow;
var postStartOut = spliceIdx + halfWindow + 1;
var postLen = outFrames - postStartOut;
if (postLen > 0)
{
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
.CopyTo(output.Slice(postStartOut * MixChannels));
}
}
/// <summary>
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment.
/// On a short read, replaces the silence-filled tail with a brief synthesised burst
/// (character chosen by <see cref="SetConcealmentArtifact"/>) decaying to zero. On the
/// next full read after a gap, applies a matching fade-in so the resumed audio doesn't
/// start with a hard discontinuity. The result is a smooth attack-and-release at the
/// edges of any gap — the human ear is much more forgiving of "dipped briefly then came
/// back" than of "instant click into silence and instant click back".
///
/// Stereo-only (matches the rest of the audio path). Output flows through the mix bus
/// and limiter as usual.
/// </summary>
private void ReadInputWithConcealment(Span<float> inSpan)
{
var requestedFloats = inSpan.Length;
var floatsRead = playout.ReadFloats(inSpan);
var requestedFrames = requestedFloats / MixChannels;
var framesRead = floatsRead / MixChannels;
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
if (framesRead < requestedFrames)
{
// Don't synthesise concealment forever during a sustained empty-buffer state — the
// sender has probably gone away. After N consecutive empty reads we just leave the
// buffer's hard-zero in place; result is true silence rather than a "shshshsh"
// tremolo as the noise/cosine artifact retriggers each render callback.
consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0;
if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties)
{
// AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the
// head of that silence with the chosen artifact, then leave the rest at zero.
var silenceFrameStart = framesRead;
var silenceFrameCount = requestedFrames - framesRead;
ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact);
}
inUnderrunConcealment = true;
}
else if (inUnderrunConcealment)
{
// First full read after a gap. Fade the new audio in from zero so we don't
// instantly jump back to whatever the new audio's amplitude is.
ApplyFadeIn(inSpan, requestedFrames, artifact);
inUnderrunConcealment = false;
consecutiveEmptyReads = 0;
}
else
{
consecutiveEmptyReads = 0;
}
// Remember the last real sample for the next fade-out. Use the last frame of actual
// ring data, not anything we just synthesised. (Only meaningful if we read at least
// one real frame this call — i.e. framesRead > 0.)
if (framesRead > 0)
{
var lastIdx = (framesRead - 1) * MixChannels;
lastConcealSampleL = inSpan[lastIdx];
lastConcealSampleR = inSpan[lastIdx + 1];
}
}
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
/// hard-zero in place.</summary>
private void ApplyFadeOut(Span<float> inSpan, int startFrame, int silenceFrameCount, ConcealmentArtifact artifact)
{
if (artifact == ConcealmentArtifact.Click) return; // Hard zero; produces the original click.
var fadeLen = artifact == ConcealmentArtifact.CosineToneLow
? ConcealFadeFramesLow
: ConcealFadeFramesShort;
var fadeFrames = Math.Min(fadeLen, silenceFrameCount);
for (var f = 0; f < fadeFrames; f++)
{
// Common envelope: cosine ramp from 1.0 → 0.0 across the fade region.
var t = (f + 1) / (double)fadeFrames;
var g = (float)((Math.Cos(Math.PI * t) + 1.0) * 0.5);
var idx = (startFrame + f) * MixChannels;
switch (artifact)
{
case ConcealmentArtifact.NoiseBurst:
// White noise at last-sample peak amplitude. Random per channel — broader
// stereo image than mono noise, and avoids correlated content the brain
// can latch onto as a tone.
var peak = Math.Max(Math.Abs(lastConcealSampleL), Math.Abs(lastConcealSampleR));
inSpan[idx] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g;
inSpan[idx + 1] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g;
break;
default:
// Cosine-tone variants (short/low). Hold last sample, scaled by envelope.
inSpan[idx] = lastConcealSampleL * g;
inSpan[idx + 1] = lastConcealSampleR * g;
break;
}
}
}
/// <summary>Fades the resumed audio in from zero with the same cosine envelope used on
/// the way out. Click variant skips the fade — the goal of "Click" is to expose the
/// original raw zero-fill behaviour, including its discontinuity at audio resumption.</summary>
private static void ApplyFadeIn(Span<float> inSpan, int requestedFrames, ConcealmentArtifact artifact)
{
if (artifact == ConcealmentArtifact.Click) return;
var fadeLen = artifact == ConcealmentArtifact.CosineToneLow
? ConcealFadeFramesLow
: ConcealFadeFramesShort;
var fadeFrames = Math.Min(fadeLen, requestedFrames);
for (var f = 0; f < fadeFrames; f++)
{
var t = f / (double)fadeFrames;
var g = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
var idx = f * MixChannels;
inSpan[idx] *= g;
inSpan[idx + 1] *= g;
}
}
private static int MillisecondsToBytes(int milliseconds) =>
Math.Max(MixBytesPerFrame, milliseconds * MixBytesPerSecond / 1000);
}
+189
View File
@@ -0,0 +1,189 @@
using System.Net;
using System.Runtime.InteropServices;
using Concentus;
using RemSound.Core;
namespace RemSound.Receiver;
/// <summary>
/// Owns the per-sender decode pipeline. One sender = one StreamSession at a time. When a new
/// sender appears (different remote endpoint, or stream/codec change), the receiver swaps in a
/// new session — old buffered audio drains out of the playout buffer naturally during the
/// swap rather than being thrown away mid-playback.
///
/// All work runs on the network listener's thread. No locks; the only cross-thread interaction
/// is writing decoded float frames to the SPSC <see cref="AudioRingBuffer"/>.
/// </summary>
internal sealed class StreamSession : IDisposable
{
private readonly SessionPlayout sessionPlayout;
private readonly ReceiverDiagnostics diagnostics;
private readonly Action<int> onFramesQueued;
private readonly PcmFrameAssembler pcmAssembler = new();
private IOpusDecoder? opusDecoder;
// Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally
// handled by the (current - expected == 1U) comparison at gap detection.
private uint? expectedNextSequence;
/// <summary>Number of single-packet gaps recovered using inband FEC from the next packet.</summary>
public long OpusFecRecoveries { get; private set; }
/// <summary>Number of multi-packet gaps where FEC could not help (only logs once per occurrence).</summary>
public long OpusUnrecoveredGaps { get; private set; }
public IPEndPoint Endpoint { get; }
public ushort StreamId { get; }
public AudioFormatInfo Format { get; }
public AudioTransportCodec Codec => (AudioTransportCodec)Format.Codec;
/// <summary>For PCM streams: number of incoming packets the assembler rejected outright.</summary>
public long PcmFrameRejections => pcmAssembler.RejectionCount;
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
public StreamSession(
IPEndPoint endpoint,
ushort streamId,
AudioFormatInfo format,
SessionPlayout sessionPlayout,
ReceiverDiagnostics diagnostics,
Action<int> onFramesQueued)
{
Endpoint = endpoint;
StreamId = streamId;
Format = format;
this.sessionPlayout = sessionPlayout;
this.diagnostics = diagnostics;
this.onFramesQueued = onFramesQueued;
if (Codec == AudioTransportCodec.Opus)
{
opusDecoder = OpusCodecFactory.CreateDecoder(format.SampleRate, format.Channels, TextWriter.Null);
}
}
/// <summary>Returns true if this session matches the given format identity (codec/rate/channels/frame).</summary>
public bool MatchesFormat(IPEndPoint endpoint, ushort streamId, AudioFormatInfo format) =>
Endpoint.Equals(endpoint)
&& StreamId == streamId
&& Format.Codec == format.Codec
&& Format.SampleRate == format.SampleRate
&& Format.Channels == format.Channels
&& Format.FrameDurationMilliseconds == format.FrameDurationMilliseconds;
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
{
diagnostics.RecordPacketArrived();
return Codec switch
{
AudioTransportCodec.Pcm => HandlePcm(payload),
AudioTransportCodec.Opus => HandleOpus(sequence, payload),
_ => false,
};
}
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
// === PCM ===
private bool HandlePcm(ReadOnlySpan<byte> payload)
{
if (!RemPcmFrame.TryReadSubHeader(payload, out var frameId, out var partIndex, out var totalParts))
{
return false;
}
var partBytes = payload[RemPcmFrame.SubHeaderSize..];
if (!pcmAssembler.TryAssemble(partBytes, frameId, partIndex, totalParts, out var assembled))
{
return true; // pending or dropped due to mismatch — not an error condition
}
// assembled is signed int24 LE, stereo. Convert to float32 and queue.
var sampleCount = assembled.Length / 3;
var floatBytes = sampleCount * sizeof(float);
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
PcmPack.Int24LEToFloat(assembled, floatSpan);
sessionPlayout.Write(floatScratch);
onFramesQueued(sampleCount / Format.Channels);
return true;
}
// === Opus ===
private bool HandleOpus(uint sequence, ReadOnlySpan<byte> payload)
{
if (opusDecoder is null) return false;
var frameSize = Math.Max(1, Format.SampleRate * Math.Max(5, Format.FrameDurationMilliseconds) / 1000);
var totalShorts = frameSize * Format.Channels;
Span<short> shortScratch = totalShorts <= 4096 ? stackalloc short[totalShorts] : new short[totalShorts];
// Detect a single-packet gap. If the previous packet was N and this is N+2,
// we know N+1 was lost; this packet's payload contains FEC redundancy for
// it. Decode the FEC frame first (so audio plays in order), then the
// current frame. Wrap-around with uint subtraction is intentional.
bool useFecRecovery = false;
if (expectedNextSequence is uint expected)
{
uint gap = sequence - expected; // 0 = exactly expected, 1 = one missing, 2+ = multi-loss
if (gap == 1)
{
useFecRecovery = true;
}
else if (gap > 1 && gap < 1_000_000)
{
// Multi-packet loss — FEC can only recover one. Don't try.
OpusUnrecoveredGaps++;
}
// gap == 0 OR a wild jump (gap >= 1M, e.g. stream reset) → no recovery
}
if (useFecRecovery)
{
try
{
var fecDecoded = opusDecoder.Decode(payload, shortScratch, frameSize, true);
if (fecDecoded > 0)
{
EmitDecoded(shortScratch, fecDecoded);
OpusFecRecoveries++;
}
}
catch
{
// FEC recovery is best-effort; if it fails, fall through to the
// normal decode and accept a single click rather than crashing.
}
}
int decoded;
try
{
decoded = opusDecoder.Decode(payload, shortScratch, frameSize, false);
}
catch
{
return false;
}
if (decoded <= 0) return false;
EmitDecoded(shortScratch, decoded);
expectedNextSequence = sequence + 1U;
return true;
}
private void EmitDecoded(ReadOnlySpan<short> shortScratch, int sampleCountPerChannel)
{
var floatCount = sampleCountPerChannel * Format.Channels;
var floatBytes = floatCount * sizeof(float);
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
for (var i = 0; i < floatCount; i++) floatSpan[i] = shortScratch[i] / 32768f;
sessionPlayout.Write(floatScratch);
onFramesQueued(sampleCountPerChannel);
}
}