Initial commit: RemSound v1.0
This commit is contained in:
@@ -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:<channel-pair-index>"</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ~5–10 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.
|
||||
}
|
||||
@@ -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:<driver-name>|<channel-pair-index>"</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);
|
||||
}
|
||||
@@ -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 12–14 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user