Initial commit: RemSound v1.0

This commit is contained in:
Ednunp
2026-05-13 15:15:27 +01:00
commit 17259438c6
80 changed files with 17892 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
using System.Diagnostics;
using NAudio.Wave;
using NAudio.Wave.Asio;
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// ASIO capture backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver and
/// produces 48 kHz stereo float frames in the same shape <see cref="MixingEngine"/> does, so
/// <see cref="AudioSender"/> doesn't care which backend is active.
///
/// Spec identity: each <see cref="CaptureSourceSpec"/> for ASIO uses a synthetic
/// <c>DeviceId</c> of the form <c>"asio:&lt;channel-pair-index&gt;"</c>. Channel pair 0 = ASIO
/// channels 0+1, pair 1 = channels 2+3, etc. The driver is implicit (a single driver per
/// session, configured through the Connectivity &amp; transport dialog).
///
/// Limitations vs the WASAPI backend (deliberate to keep this manageable):
/// • Driver is locked at <see cref="Start"/> time. Switching drivers means Stop + new instance.
/// • We always open the AsioOut with the driver's full input channel count, regardless of
/// which pairs the user selected. The unused channels are pulled but discarded. This
/// trades a tiny amount of buffer memory for a big stability win: adding or removing a
/// channel pair never requires reopening the driver, which means we don't fight a
/// concurrent receiver-side AsioOut on single-client drivers (Komplete Audio etc.).
/// • Sample rate is fixed at 48 kHz; if the driver doesn't support that, capture fails to
/// start (the diagnostic line says so). All modern pro audio interfaces support 48 kHz.
/// • Hardware loopback channels (e.g. EVO 8's Loop-back 1/2) are just regular ASIO inputs
/// from our perspective; they live in the same channel space and are picked the same way.
/// </summary>
internal sealed class AsioCaptureBackend : ICaptureBackend
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
// Volatile-published callback. The ASIO audio thread reads this every callback to
// decide where to deliver samples; AudioSender swaps it on mode changes so the same
// open driver can keep running while routing changes between Mixed / AsioLane / no-op.
// Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier).
private volatile Action<ReadOnlyMemory<float>> onMixedSamples;
private readonly Action<string>? onDiagnostic;
private readonly string driverName;
public string DriverName => driverName;
private readonly object gate = new();
private AsioOut? asio;
private List<int> activeChannelPairIndices = [];
private int recordChannelCount;
private float[] mixScratch = new float[1024];
private float[] interleavedScratch = new float[1024];
private long callbackCount;
private long bytesCaptured;
private long clippedSampleCount;
private string? lastError;
private string? captureFormat;
private readonly Stopwatch uptime = new();
// Per-callback gap tracking. The ASIO callback should fire on a strict period (= buffer
// size in samples / sample rate). When the .NET runtime, GC, USB driver, or Windows
// scheduler stalls the audio thread, that period stretches and the audio stream gets a
// discontinuity — which the receiver can't detect because it just sees a packet arrive
// late. We measure the elapsed time between consecutive callbacks here, track the worst
// since the last read, and let the sender's diag logger surface it. Plain int; access
// is via Interlocked which provides its own memory barriers (no need for volatile).
private long lastCallbackTimestamp;
private int maxCallbackGapMs;
public AsioCaptureBackend(string driverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
{
this.driverName = driverName;
this.onMixedSamples = onMixedSamples;
this.onDiagnostic = onDiagnostic;
}
/// <summary>
/// Swap the callback that captured audio is delivered to. Used by AudioSender to keep
/// one persistent AsioCaptureBackend instance alive across audio-mode changes — the
/// driver stays open, the callback gets rewired to the lane appropriate for the new
/// mode (Mixed in AsioOnly, AsioLane in BothIndependent, or a no-op while the
/// composite is being rebuilt). Volatile write, so the audio thread picks the new
/// callback up on its very next ASIO buffer.
/// </summary>
public void SetCallback(Action<ReadOnlyMemory<float>> callback) =>
onMixedSamples = callback;
public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
public string? FirstCaptureFormatDescription => captureFormat;
public string? FirstCaptureLastError => lastError;
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
public IReadOnlyList<string> ActiveSourceNames
{
get
{
lock (gate)
{
return activeChannelPairIndices
.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}")
.ToList();
}
}
}
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
if (IsRunning) StopInternal();
if (specs.Count == 0) return;
activeChannelPairIndices = ParseChannelPairIndices(specs);
if (activeChannelPairIndices.Count == 0)
{
onDiagnostic?.Invoke("asio capture: no valid channel pair indices in spec list");
return;
}
try
{
asio = new AsioOut(driverName);
// Always open with the driver's full input channel count. Pulling channels we
// don't immediately need is essentially free — the driver fills them anyway —
// and it removes the need to ever reopen the AsioOut when the user toggles a
// higher-numbered channel pair. Reopening is what previously caused 15-second
// freezes when both sender and receiver held the same single-client driver
// (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
recordChannelCount = asio.DriverInputChannelCount;
if (recordChannelCount <= 0)
{
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels");
StopInternal();
return;
}
asio.InputChannelOffset = 0;
// Sanity-check that the requested pairs are within the driver's channel range.
// We open the full count anyway, but if a saved spec references a pair above
// the driver's range, the OnAudioAvailable mixer would silently emit zero —
// surface that as a diagnostic so it's not mysterious.
var maxPairIndex = activeChannelPairIndices.Max();
var highestNeededChannel = (maxPairIndex + 1) * 2;
if (highestNeededChannel > recordChannelCount)
{
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
// Continue anyway — out-of-range pairs just contribute silence to the mix.
}
asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
asio.AudioAvailable += OnAudioAvailable;
captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
asio.Play();
uptime.Restart();
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");
}
catch (Exception ex)
{
lastError = ex.Message;
onDiagnostic?.Invoke($"asio capture start failed: {ex.GetType().Name}: {ex.Message}");
StopInternal();
}
}
}
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
if (!IsRunning)
{
Start(specs);
return;
}
var newPairs = ParseChannelPairIndices(specs);
// No reopen needed regardless of which pairs change. We always opened the driver
// with its full input channel count at Start, so adding or removing a pair is just
// a matter of which input channels the OnAudioAvailable mixer reads from. Even
// when the new pair set is empty we DO NOT close the driver here — Audient's
// ASIO driver (and several others) doesn't tolerate a close+reopen within a few
// seconds, which is exactly the pattern the user produces by unticking the last
// ASIO source and then ticking another one. Keeping the driver open with zero
// active pairs makes the callback fire harmlessly (zeros) and the next pair
// addition takes effect on the very next callback. The driver only truly closes
// on Stop() or Dispose(), which fire on sender disabled or app exit.
activeChannelPairIndices = newPairs;
onDiagnostic?.Invoke($"asio capture: pairs updated to [{string.Join(",", activeChannelPairIndices)}] (no driver restart)");
}
}
public void Stop()
{
lock (gate) StopInternal();
}
private void StopInternal()
{
if (asio is not null)
{
try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
try { asio.Stop(); } catch { /* ignore */ }
try { asio.Dispose(); } catch { /* ignore */ }
asio = null;
}
uptime.Stop();
activeChannelPairIndices = [];
recordChannelCount = 0;
}
public void Dispose() => Stop();
private static List<int> ParseChannelPairIndices(IReadOnlyList<CaptureSourceSpec> specs)
{
var result = new List<int>();
foreach (var spec in specs)
{
if (AsioDeviceId.TryParse(spec.DeviceId, out var pair))
{
result.Add(pair);
}
}
result.Sort();
return result.Distinct().ToList();
}
public int TakeMaxCallbackGapMs() => Interlocked.Exchange(ref maxCallbackGapMs, 0);
private void OnAudioAvailable(object? sender, AsioAudioAvailableEventArgs e)
{
Interlocked.Increment(ref callbackCount);
// Capture-callback gap timing. First callback seeds the timestamp without recording a
// gap (we have nothing to compare to). Subsequent callbacks compute the elapsed ms
// since the previous one and CAS-update the max. Skipped entirely when diagnostics
// are off — saves the Stopwatch reads, exchange and CAS loop on every ASIO callback.
if (RemSound.Core.DiagnosticsGate.Enabled)
{
var now = Stopwatch.GetTimestamp();
var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now);
if (prev != 0)
{
var gapMs = (int)((now - prev) * 1000 / Stopwatch.Frequency);
int current;
do
{
current = Volatile.Read(ref maxCallbackGapMs);
if (gapMs <= current) break;
} while (Interlocked.CompareExchange(ref maxCallbackGapMs, gapMs, current) != current);
}
}
// Pull all interleaved float samples for the recorded channels into a reusable buffer.
var samplesNeeded = e.SamplesPerBuffer * e.InputBuffers.Length;
if (interleavedScratch.Length < samplesNeeded) interleavedScratch = new float[samplesNeeded];
var written = e.GetAsInterleavedSamples(interleavedScratch);
Interlocked.Add(ref bytesCaptured, written * sizeof(float));
var interleaved = interleavedScratch;
// Frame count = total samples / channel count.
var frames = written / Math.Max(1, recordChannelCount);
var stereoFloats = frames * MixChannels;
if (mixScratch.Length < stereoFloats) mixScratch = new float[stereoFloats];
Array.Clear(mixScratch, 0, stereoFloats);
// Mix selected channel pairs into the stereo output. Each pair contributes its L/R to
// the mix bus.
List<int> pairs;
lock (gate) pairs = activeChannelPairIndices;
if (pairs.Count == 0) return;
for (var f = 0; f < frames; f++)
{
var srcBase = f * recordChannelCount;
var dstBase = f * MixChannels;
float l = 0f, r = 0f;
foreach (var pair in pairs)
{
var lCh = pair * 2;
var rCh = pair * 2 + 1;
if (lCh < recordChannelCount) l += interleaved[srcBase + lCh];
if (rCh < recordChannelCount) r += interleaved[srcBase + rCh];
}
// Soft-limit-ish clamp at the encoder boundary; matches MixingEngine.
if (l > 1f) { l = 1f; Interlocked.Increment(ref clippedSampleCount); }
else if (l < -1f) { l = -1f; Interlocked.Increment(ref clippedSampleCount); }
if (r > 1f) { r = 1f; Interlocked.Increment(ref clippedSampleCount); }
else if (r < -1f) { r = -1f; Interlocked.Increment(ref clippedSampleCount); }
mixScratch[dstBase] = l;
mixScratch[dstBase + 1] = r;
}
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, stereoFloats));
}
/// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
/// can't find any. Exposed for the App's driver picker UI.</summary>
public static IReadOnlyList<string> EnumerateDriverNames()
{
try { return AsioOut.GetDriverNames().ToList(); }
catch { return []; }
}
/// <summary>
/// Briefly opens the named ASIO driver to query its channel counts, then disposes. Single
/// driver instance held for ~50 ms while the COM object reads its channel info — does not
/// claim the device for streaming. Returns (in,out) = (-1,-1) on any failure (driver not
/// installed, busy with another app, etc.). Used by the App to populate channel-pair lists
/// in ASIO mode without holding the driver open between user actions.
/// </summary>
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
{
try
{
using var asio = new AsioOut(driverName);
return (asio.DriverInputChannelCount, asio.DriverOutputChannelCount);
}
catch
{
return (-1, -1);
}
}
}
+110
View File
@@ -0,0 +1,110 @@
using Microsoft.Win32;
using NAudio.Wave;
namespace RemSound.Sender;
/// <summary>
/// Public static helpers for the App layer to enumerate ASIO drivers and probe their channel
/// counts and channel names without needing access to the internal
/// <see cref="AsioCaptureBackend"/> / <see cref="AsioRenderBackend"/> implementation classes.
/// These are read-only queries: opening the driver briefly to read its info, then closing —
/// does NOT claim the device for streaming.
/// </summary>
public static class AsioDeviceProbe
{
/// <summary>
/// Names of all installed ASIO drivers. Tries several enumeration paths and merges results,
/// because:
/// • NAudio's built-in <c>AsioOut.GetDriverNames()</c> reads <c>HKLM\SOFTWARE\ASIO</c> in
/// the registry view that matches the calling process. A 64-bit RemSound only sees the
/// 64-bit hive; some ASIO drivers register only into the 32-bit <c>Wow6432Node</c> hive.
/// • A few drivers register under HKCU instead of HKLM.
/// We scan both views and both hives, merge results (case-insensitive de-dup on the
/// registry key name and the human-friendly Description), and return the descriptions.
/// </summary>
public static IReadOnlyList<string> EnumerateDriverNames()
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var n in AsioOut.GetDriverNames()) names.Add(n);
}
catch { /* ignore — fall through to manual scan */ }
// Manual scan covers cases NAudio's built-in helper misses.
AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64, names);
AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32, names);
AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64, names);
AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32, names);
return names.ToList();
}
private static void AddFromRegistry(RegistryHive hive, RegistryView view, HashSet<string> names)
{
try
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var asioKey = baseKey.OpenSubKey(@"SOFTWARE\ASIO");
if (asioKey is null) return;
foreach (var subKeyName in asioKey.GetSubKeyNames())
{
using var sub = asioKey.OpenSubKey(subKeyName);
if (sub is null) continue;
// Most drivers store a friendly "Description" value; if absent, the subkey name
// itself is what NAudio uses.
var description = sub.GetValue("Description") as string;
names.Add(string.IsNullOrWhiteSpace(description) ? subKeyName : description);
}
}
catch { /* ignore — that hive/view combo unavailable, fine */ }
}
/// <summary>
/// Probes the named ASIO driver for full info (channel counts + per-channel names). Briefly
/// opens the driver, reads metadata, disposes. Returns a result with empty arrays + 1
/// counts on any failure.
/// </summary>
public static AsioDriverProbeResult ProbeDriverInfo(string driverName)
{
try
{
using var asio = new AsioOut(driverName);
var inCount = asio.DriverInputChannelCount;
var outCount = asio.DriverOutputChannelCount;
var inNames = new List<string>(Math.Max(0, inCount));
for (var i = 0; i < inCount; i++)
{
try { inNames.Add(asio.AsioInputChannelName(i)); }
catch { inNames.Add($"Input {i + 1}"); }
}
var outNames = new List<string>(Math.Max(0, outCount));
for (var i = 0; i < outCount; i++)
{
try { outNames.Add(asio.AsioOutputChannelName(i)); }
catch { outNames.Add($"Output {i + 1}"); }
}
return new AsioDriverProbeResult(inCount, outCount, inNames, outNames);
}
catch
{
return new AsioDriverProbeResult(-1, -1, [], []);
}
}
/// <summary>
/// Backwards-compatibility shim around <see cref="ProbeDriverInfo"/> for callers that only
/// need channel counts.
/// </summary>
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
{
var info = ProbeDriverInfo(driverName);
return (info.InputChannelCount, info.OutputChannelCount);
}
}
public sealed record AsioDriverProbeResult(
int InputChannelCount,
int OutputChannelCount,
IReadOnlyList<string> InputChannelNames,
IReadOnlyList<string> OutputChannelNames);
+595
View File
@@ -0,0 +1,595 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using RemSound.Core;
namespace RemSound.Sender;
// PcmPack is in RemSound.Core (used by both Sender and Receiver).
/// <summary>
/// Captures from one or more Windows audio devices via WASAPI (loopback for output devices,
/// direct capture for input devices), mixes them into a single 48 kHz stereo float stream
/// through <see cref="MixingEngine"/>, encodes (PCM 24-bit or Opus), and sends to a configurable
/// set of UDP receivers.
///
/// The mixing engine owns the capture lifecycle and the per-source silence keepalive (needed on
/// USB audio interfaces whose loopback callbacks otherwise stall when no app is rendering — see
/// naudio/NAudio#1110). AudioSender just wires the mixer's mixed-sample callback into the
/// existing PCM/Opus encode + UDP path.
///
/// Threading model: the mixer's tick task delivers 10 ms frames here on its own thread; this
/// class accumulates into PCM 5 ms or Opus 10/20 ms frames and dispatches over UDP. No
/// cross-thread synchronization other than reading a few volatile flags (codec, mute,
/// receiver list).
/// </summary>
public sealed class AudioSender : IDisposable
{
// PCM frame size is configurable via SendRate. Standard = 5 ms (240 samples = 1440 bytes,
// single UDP packet under MaxAudioPayloadBytes=1454). Tight = 2.5 ms (120 samples = 720
// bytes, also single packet). Tight mode adds nothing structurally — same packet shape,
// just half-size — so the receive-side multipart assembler stays a no-op.
private const int MixChannels = 2;
private const int OpusBitrateLan = 192_000;
private const int PcmStandardSamplesPerChannel = 240; // 5 ms
private const int PcmTightSamplesPerChannel = 120; // 2.5 ms
// Mutable PCM frame parameters — updated by SetSendRate. Keep them volatile because the
// hot-path read happens on the audio thread while writes come from the UI thread.
private volatile int pcmFrameSamplesPerChannel = PcmStandardSamplesPerChannel;
internal int PcmFrameStereoSamples => pcmFrameSamplesPerChannel * MixChannels;
internal int PcmFrameSamplesPerChannel => pcmFrameSamplesPerChannel;
private readonly object configGate = new();
private ICaptureBackend engine;
private IReadOnlyList<CaptureSourceSpec> pendingSources = [];
private readonly UdpClient udp;
// Two lanes. defaultLane carries every output in the three classic modes (Mixed route).
// In BothIndependent mode defaultLane carries WASAPI-only audio (route WasapiLane) and
// asioLane carries ASIO-only audio (route AsioLane), each producing its own UDP stream
// tagged with the matching Lane byte so the receiver routes them to per-lane
// IWaveProvider surfaces. We always construct both lanes — the asio lane sits idle
// (no capture child wired to it) in classic modes and the memory cost is trivial.
private readonly SenderLane defaultLane;
private readonly SenderLane asioLane;
// Persistent AsioCaptureBackend that survives audio-mode changes. The composite borrows
// a reference to it; mode rebuilds rewire its callback (via SetCallback) rather than
// tearing it down and reopening the driver. This avoids Audient (and similar single-
// client drivers) hanging the audio thread for ~5 s on rapid close+reopen — which had
// been crashing the laptop on every "switch between Both and AsioOnly" attempt.
// Lazily created when first needed, disposed when transitioning to WasapiOnly OR when
// the user picks a different ASIO driver entirely. The Action<...> stub is a deliberate
// placeholder that gets immediately swapped via SetCallback in EnsurePersistentAsio.
private AsioCaptureBackend? persistentAsio;
private string? persistentAsioDriverName;
/// <summary>Optional diagnostic sink. Set by callers (typically the App) before Start to receive
/// human-readable status strings ("capture started…", "capture stopped with error…", etc.).</summary>
public Action<string>? Diagnostic
{
get => diagnostic;
set => diagnostic = value;
}
private Action<string>? diagnostic;
// Per-stream state (streamId, audioSequence, frame accumulator, Opus encoder, PCM frame id,
// format-resend timer) now lives on each SenderLane. This file kept its monolithic shape
// through Phase 1/2 — the BothIndependent refactor required splitting "stuff that belongs
// to one outbound stream" from "shared infrastructure". The accumulator/outbound scratch/
// streamId/sequence counters are all per-lane; the UDP socket, codec config, mute flag,
// engine and stats stay here. See <see cref="SenderLane"/> for the per-stream hot path.
private readonly Stopwatch uptime = new();
private volatile AudioTransportCodec codec = AudioTransportCodec.Pcm;
private volatile int opusFrameMs = 10; // only meaningful when codec == Opus
private volatile bool muted;
private IPEndPoint[] receivers = [];
private long packetsSent;
private long bytesSent;
// Internal accessor so SenderLane can read tight-latency without exposing the field
// publicly. Codec, OpusFrameMilliseconds and IsMuted are already exposed publicly below
// and re-used directly by the lane.
internal bool IsTightLatencyEnabled => tightLatencyEnabled;
// Hot-path timing instrumentation. Both lanes update these on every emit; the SNAP
// timer reads + resets them once per second. Used to split observed inter-packet jitter
// between "our code is slow" vs "the kernel is slow" vs "the network is slow".
// maxEmitTicks = Stopwatch ticks for the WIDEST observation of SenderLane's
// OnMixedSamples (encode + scratch + SendToAll). If this is in
// the multi-ms range, our encode pipeline is the bottleneck.
// maxSendCallTicks = Stopwatch ticks for the WIDEST single udp.Client.SendTo call.
// If this is in the multi-ms range, the kernel TX buffer / NIC
// driver / send-socket contention is the bottleneck.
// Both are reset on each Take() so the SNAP gets per-second peaks.
private long maxEmitTicks;
private long maxSendCallTicks;
internal void RecordEmitTicks(long ticks)
{
long current;
do { current = Volatile.Read(ref maxEmitTicks); }
while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current);
}
internal void RecordSendCallTicks(long ticks)
{
long current;
do { current = Volatile.Read(ref maxSendCallTicks); }
while (ticks > current && Interlocked.CompareExchange(ref maxSendCallTicks, ticks, current) != current);
}
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
// === inbound dispatch (relay-mode) ===
// The send socket is normally write-only, but in relay-mode the same socket is what
// catches return packets — the relay forwards traffic into our NAT pinhole, which lives on
// this socket's ephemeral port. An optional inbound-packet callback lets the App route
// those packets into the receiver pipeline (audio) or the heartbeat service.
// Existing LAN peer-to-peer behaviour is unchanged: nothing inbound arrives at this socket
// from a LAN peer because LAN peers send to the receiver's well-known port directly.
private CancellationTokenSource? inboundCts;
private Thread? inboundThread;
private long inboundPackets;
/// <summary>
/// Optional callback invoked for each UDP datagram that arrives at this sender's socket.
/// Buffer is owned by the receive thread — copy what you keep. Length is the byte count
/// (the buffer may be larger). Remote is the sender of the packet (typically a relay).
/// Set this before <see cref="StartReceiving"/> is called.
/// </summary>
public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; }
public AudioSender()
{
udp = new UdpClient(AddressFamily.InterNetwork);
udp.Client.SendBufferSize = 256 * 1024;
udp.Client.ReceiveBufferSize = 256 * 1024;
// Explicit bind to port 0 (OS picks an ephemeral). Two reasons:
// 1. ReceiveFrom on an unbound UDP socket throws SocketException (WSAEINVAL) on
// Windows — the receive thread we start below would then CPU-spin in its
// catch/continue loop. Binding up front makes ReceiveFrom block normally for
// data instead.
// 2. Same NAT pinhole is shared between send and receive — relay mode requires
// this; LAN peer-to-peer is unaffected (we still send from this port, peer just
// sends to its own well-known port as before).
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
// WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null.
currentAudioMode = AudioMode.WasapiOnly;
currentAsioDriverName = null;
engine = new CompositeCaptureBackend(currentAudioMode, currentAsioDriverName, defaultLane.OnMixedSamples, asioLane.OnMixedSamples, persistentAsio, msg => diagnostic?.Invoke(msg), useTightLatencyWasapi: false);
}
// Held so SetTightLatency can rebuild the composite with the same mode/driver.
private AudioMode currentAudioMode;
private string? currentAsioDriverName;
/// <summary>
/// Sets the audio backend mode and (when ASIO is involved) the driver name. The composite is
/// rebuilt to match. Two reachable pipeline shapes today:
/// * WasapiOnly: MixingEngine direct, no ASIO code in the path. Lowest latency for users
/// without ASIO.
/// * BothIndependent: WASAPI MixingEngine + persistent AsioCaptureBackend running side by
/// side, each on its own SenderLane (own streamId, own UDP stream). No mix loop, no tee.
/// Each lane keeps its native latency.
/// Legacy <c>AudioMode.AsioOnly</c> and <c>AudioMode.Both</c> are tolerated (the composite
/// coerces them) but no UI path produces them any more. If running, previously-pending
/// sources are re-applied automatically.
/// </summary>
public void SetAudioMode(AudioMode mode, string? asioDriverName)
{
lock (configGate)
{
currentAudioMode = mode;
currentAsioDriverName = asioDriverName;
// Lane route assignment. WasapiOnly: only defaultLane is active, carrying Mixed.
// BothIndependent: defaultLane carries the WASAPI lane, asioLane carries the ASIO
// lane. SetRoute rotates each lane's streamId so the receiver opens a fresh
// session under the new Lane tag — old session drains naturally on its 4-second
// prune. Legacy AsioOnly / Both can't be produced by the UI any more; if they
// arrive (in-flight callers, future call sites) we treat them as BothIndependent
// for routing purposes so the streams still carry distinct Lane tags.
if (mode != AudioMode.WasapiOnly)
{
defaultLane.SetRoute(RenderRoute.WasapiLane);
asioLane.SetRoute(RenderRoute.AsioLane);
}
else
{
defaultLane.SetRoute(RenderRoute.Mixed);
asioLane.SetRoute(RenderRoute.Mixed); // idle; no callbacks will fire on it
}
EnsurePersistentAsioLocked();
RebuildEngineLocked();
}
}
/// <summary>
/// Make sure <see cref="persistentAsio"/> matches the current mode + driver. Created
/// fresh when first transitioning into an ASIO-using mode; reused across subsequent
/// mode changes that keep the same driver; disposed when transitioning to WasapiOnly
/// (no ASIO) or when the user picks a different driver. The persistent instance is
/// loaned to the composite via the constructor; the composite borrows but doesn't
/// dispose, so the underlying ASIO driver handle stays open across engine rebuilds.
/// Caller must hold <see cref="configGate"/>. The callback is also rewired here based
/// on which lane should receive ASIO audio in the new mode.
/// </summary>
private void EnsurePersistentAsioLocked()
{
var willUseAsio = currentAudioMode != AudioMode.WasapiOnly
&& !string.IsNullOrEmpty(currentAsioDriverName);
if (!willUseAsio)
{
// Mode no longer uses ASIO. Dispose the persistent instance so the driver
// releases (other apps may want it).
if (persistentAsio is not null)
{
try { persistentAsio.Dispose(); } catch { /* ignore */ }
persistentAsio = null;
persistentAsioDriverName = null;
}
return;
}
// Need ASIO. Reuse if the driver matches; rebuild otherwise (rare — only when the
// user picks a different driver in the dropdown).
if (persistentAsio is null || persistentAsioDriverName != currentAsioDriverName)
{
if (persistentAsio is not null)
{
try { persistentAsio.Dispose(); } catch { /* ignore */ }
}
persistentAsio = new AsioCaptureBackend(
currentAsioDriverName!,
_ => { /* placeholder, replaced by SetCallback below */ },
msg => diagnostic?.Invoke($"asio: {msg}"));
persistentAsioDriverName = currentAsioDriverName;
}
// Wire the callback to the right lane for the current mode. WasapiOnly never reaches
// here (willUseAsio is false above). BothIndependent is the only ASIO-using mode the
// UI can produce, and it routes ASIO into the dedicated AsioLane. Legacy AsioOnly is
// tolerated by sending into defaultLane (which carries RenderRoute.Mixed in non-
// BothIndependent setups).
persistentAsio.SetCallback(
currentAudioMode == AudioMode.BothIndependent
? asioLane.OnMixedSamples
: defaultLane.OnMixedSamples);
}
/// <summary>
/// (Re)create the composite backend with the current audio-mode + asio-driver-name +
/// tight-latency-WASAPI flag. Caller must hold <c>configGate</c>. Preserves the running
/// state — if the engine was running before, restart it with the same source list.
/// The persistent ASIO instance is passed in by reference so the composite borrows
/// rather than creates+disposes it; that's what keeps the driver open across rebuilds.
/// </summary>
private void RebuildEngineLocked()
{
var wasRunning = engine.IsRunning;
try { engine.Stop(); } catch { /* ignore */ }
try { engine.Dispose(); } catch { /* ignore */ }
engine = new CompositeCaptureBackend(
currentAudioMode,
currentAsioDriverName,
defaultLane.OnMixedSamples,
asioLane.OnMixedSamples,
persistentAsio,
msg => diagnostic?.Invoke(msg),
useTightLatencyWasapi: tightLatencyEnabled);
if (wasRunning && pendingSources.Count > 0)
{
engine.Start(pendingSources);
}
}
public bool IsAsioBackend => engine is CompositeCaptureBackend;
/// <summary>Updates the PCM frame size based on the user's "Send rate" choice. For Opus,
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameMs parameter (the App
/// halves it when SendRate is Tight). On a frame-size change, resets the accumulator and
/// stream id so the receiver opens a fresh session at the new format.</summary>
public void SetSendRate(SendRate rate)
{
lock (configGate)
{
var newSamples = rate == SendRate.Tight ? PcmTightSamplesPerChannel : PcmStandardSamplesPerChannel;
if (newSamples == pcmFrameSamplesPerChannel) return;
pcmFrameSamplesPerChannel = newSamples;
// Both lanes need to rotate streamId + reset accumulator on a frame-size change.
// The asio lane is idle in classic modes (no producer feeding it) so the reset is
// harmless there; in BothIndependent both lanes are active and both must roll.
defaultLane.OnPcmFrameSizeChanged();
asioLane.OnPcmFrameSizeChanged();
}
}
/// <summary>Tight-latency mode toggle. Affects two things:
/// * ASIO-only PCM: every incoming ASIO buffer is emitted directly as a single packet
/// instead of being accumulated to the PCM frame size — saves ~frame_size_ms/2 of
/// average send-side latency. ProcessPcm reads <c>tightLatencyEnabled</c> directly.
/// * WasapiOnly with single source: rebuilds the capture backend as
/// <see cref="PushModeWasapiBackend"/> instead of <see cref="MixingEngine"/>. The WASAPI
/// capture event drives the encode/UDP-send pipeline directly, eliminating the ~6 ms
/// of Stopwatch+WaitHandle scheduler jitter that <see cref="MixingEngine"/>'s mix tick
/// adds. Especially important at high device sample rates (96 kHz EVO8 etc.) where the
/// in-tick resampler stage compounds the jitter. <see cref="CompositeCaptureBackend"/>
/// decides whether push-mode actually applies based on source count and mode.
/// No effect on Opus accumulation (Opus needs fixed frame sizes) or AsioOnly's WASAPI
/// (there's no WASAPI source). Sender-side only as of Phase 3 (2026-05-06): the
/// receiver no longer has a resampler to bypass.</summary>
public void SetTightLatency(bool enabled)
{
lock (configGate)
{
if (tightLatencyEnabled == enabled) return;
tightLatencyEnabled = enabled;
RebuildEngineLocked();
}
}
private volatile bool tightLatencyEnabled;
public bool IsRunning => engine.IsRunning;
public long CaptureCallbacks => engine.TotalCaptureCallbacks;
public long CaptureBytes => engine.TotalCaptureBytes;
/// <summary>Largest gap between capture callbacks since the last call. Resets on read.
/// Use this in periodic diagnostics — if it spikes well above the audio-buffer period
/// (e.g. 19 ms when the period should be ≤ 5 ms), the audio capture thread is being
/// stalled by GC, USB, or scheduler issues, which produces audible discontinuities the
/// receiver can't detect (because no packets are lost — they just contain audio with
/// holes in it).</summary>
public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs();
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
public string? LastCaptureError => engine.FirstCaptureLastError;
public long ClippedSampleCount => engine.ClippedSampleCount;
public AudioTransportCodec Codec => codec;
public int OpusFrameMilliseconds => opusFrameMs;
/// <summary>
/// Atomically set the codec and (for Opus) the frame size. Resets stream identity and the
/// frame accumulator so the receiver sees the new format from the next packet onward. Both
/// parameters are taken together because changing only one would briefly send malformed
/// frames at the encoder boundary.
/// </summary>
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameMs = 10)
{
var clampedFrameMs = Math.Clamp(newOpusFrameMs, 5, 60);
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameMs == clampedFrameMs))
{
return;
}
lock (configGate)
{
codec = newCodec;
opusFrameMs = clampedFrameMs;
// Rebuild both lanes' encoders + rotate their streamIds. Same idle-lane rationale
// as SetSendRate — harmless when the asio lane has no producer; necessary when it
// does (BothIndependent).
defaultLane.OnCodecChanged(newCodec, clampedFrameMs);
asioLane.OnCodecChanged(newCodec, clampedFrameMs);
}
}
public bool IsMuted { get => muted; set => muted = value; }
public long PacketsSent => Interlocked.Read(ref packetsSent);
public long BytesSent => Interlocked.Read(ref bytesSent);
public TimeSpan Uptime => uptime.Elapsed;
/// <summary>
/// Friendly summary of currently-active sources for diagnostic columns. Returns
/// "(none)" when nothing is configured, "(N sources)" when there are 4+ — the snapshot log
/// column is fixed-width-ish and a long join becomes unreadable past 3 sources.
/// </summary>
public string CaptureDeviceName
{
get
{
var names = engine.ActiveSourceNames;
if (names.Count == 0) return "(none)";
if (names.Count <= 3) return string.Join(", ", names);
return $"({names.Count} sources)";
}
}
/// <summary>Set the destinations to which packets are sent. Live-updateable.</summary>
public void SetReceivers(IEnumerable<IPEndPoint> endpoints)
{
var list = endpoints.ToArray();
Volatile.Write(ref receivers, list);
}
/// <summary>
/// Sets the list of capture sources to mix. Each spec identifies a WASAPI device + whether
/// it's a loopback (output device, system audio) or direct input (mic, line-in). Order does
/// not matter — sources are summed equally.
/// </summary>
public void Configure(IReadOnlyList<CaptureSourceSpec> sources)
{
pendingSources = sources;
if (engine.IsRunning)
{
// Live add/remove via NAudio's MixingSampleProvider — mix loop never pauses,
// streamId stays the same, receiver doesn't see a new stream session, no underrun.
engine.UpdateSources(sources);
}
}
public void Start()
{
if (engine.IsRunning) return;
StartEngineWithCurrentSources();
}
private void StartEngineWithCurrentSources()
{
if (pendingSources.Count == 0)
{
diagnostic?.Invoke("sender: start requested but no sources configured");
return;
}
defaultLane.ResetForStart();
asioLane.ResetForStart();
Interlocked.Exchange(ref packetsSent, 0);
Interlocked.Exchange(ref bytesSent, 0);
uptime.Restart();
engine.Start(pendingSources);
}
public void Stop()
{
engine.Stop();
uptime.Stop();
}
/// <summary>
/// Start a background thread reading inbound packets from this sender's socket and
/// dispatching them to <see cref="OnInboundPacket"/>. Idempotent — safe to call repeatedly.
/// Used in relay mode so heartbeat replies and audio coming back through the relay
/// (which arrive at the sender's NAT pinhole, not the receiver's well-known port) get
/// routed into the right pipelines. No-op for pure LAN peer-to-peer setups.
/// </summary>
public void StartReceiving()
{
lock (configGate)
{
if (inboundThread is { IsAlive: true }) return;
inboundCts = new CancellationTokenSource();
var token = inboundCts.Token;
inboundThread = new Thread(() => InboundReceiveLoop(token))
{
IsBackground = true,
Name = "RemSound.SenderReceive",
};
inboundThread.Start();
}
}
private void InboundReceiveLoop(CancellationToken token)
{
var buffer = new byte[2048];
EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0);
while (!token.IsCancellationRequested)
{
int received;
try
{
received = udp.Client.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;
Interlocked.Increment(ref inboundPackets);
try
{
OnInboundPacket?.Invoke(buffer, received, remote);
}
catch (Exception ex)
{
diagnostic?.Invoke($"sender inbound dispatch threw: {ex.GetType().Name}: {ex.Message}");
}
}
}
/// <summary>
/// Send an arbitrary datagram on this sender's UDP socket. Used by the heartbeat service
/// in relay mode so its packets share the same NAT pinhole as audio. Returns false if the
/// send failed.
/// </summary>
public bool SendVia(byte[] data, int length, IPEndPoint destination)
{
try
{
udp.Send(data, length, destination);
return true;
}
catch (SocketException) { return false; }
catch (ObjectDisposedException) { return false; }
}
/// <summary>Cumulative inbound packets received on this sender's socket. Mostly zero
/// outside relay mode.</summary>
public long InboundPackets => Interlocked.Read(ref inboundPackets);
public void Dispose()
{
Stop();
try { inboundCts?.Cancel(); } catch { /* ignore */ }
try { inboundThread?.Join(500); } catch { /* ignore */ }
engine.Dispose();
// Dispose the persistent ASIO LAST, after the engine that was borrowing it. The
// composite's Dispose doesn't touch the persistent instance (it borrowed it); we
// own it here and close the driver as part of app shutdown.
try { persistentAsio?.Dispose(); } catch { /* ignore */ }
persistentAsio = null;
udp.Dispose();
}
// === wire path (shared across all lanes) ===
/// <summary>
/// Emit a fully-constructed packet to every configured receiver. Per-lane code in
/// <see cref="SenderLane"/> builds the header + payload (in its stack/pre-allocated
/// outboundScratch) and calls this; we forward the span straight into the socket's
/// span-aware Send overload so the audio thread never allocates anything in the hot
/// path. The pre-2026-05-11 implementation did packet.ToArray() per send, which on
/// ASIO tight-latency throughput (~750 packets/sec per lane × two lanes in
/// BothIndependent) was a steady ~2 MB/sec of small-byte-array Gen 0 allocations and
/// drove visible packet-emission jitter via GC pauses. The span overload eliminates
/// that entire allocation stream.
///
/// Single point of outbound socket use means both lanes share the same NAT pinhole
/// and stats. The send-buffer-full or kernel-mutex contention between two threads
/// sending on the same UDP socket is microseconds in practice and not the source of
/// the ms-scale jitter we observe; the per-packet allocation was.
///
/// UDP failures per-receiver are swallowed by design — UDP is unreliable and one
/// peer dropping shouldn't disturb the others.
/// </summary>
internal void SendToAll(ReadOnlySpan<byte> packet)
{
var targets = Volatile.Read(ref receivers);
if (targets.Length == 0) return;
// Use Socket.SendTo with the span overload — UdpClient's span-Send signature is
// .NET 6+. Going via Client (the underlying Socket) avoids one wrapper layer too.
var packetLen = packet.Length;
// Measure the kernel-side time of just the SendTo call when diagnostics are enabled.
// If this number spikes, the bottleneck is the TX path (kernel buffer pressure, NIC,
// single-socket cross-thread contention) rather than our encode pipeline. Hoisted
// out of the per-target loop so a multi-peer broadcast pays one branch instead of N.
var diag = RemSound.Core.DiagnosticsGate.Enabled;
foreach (var target in targets)
{
try
{
if (diag)
{
var sendStart = Stopwatch.GetTimestamp();
udp.Client.SendTo(packet, target);
RecordSendCallTicks(Stopwatch.GetTimestamp() - sendStart);
}
else
{
udp.Client.SendTo(packet, target);
}
Interlocked.Increment(ref packetsSent);
Interlocked.Add(ref bytesSent, packetLen);
}
catch (SocketException)
{
// Single-packet failures are a non-event; UDP is unreliable by design.
}
catch (ObjectDisposedException)
{
return;
}
}
}
}
+193
View File
@@ -0,0 +1,193 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// One capture source feeding the mixer. Wraps a single <see cref="WasapiCapture"/> (loopback or
/// direct input) and produces 48 kHz stereo float samples through an NAudio sample-provider chain.
///
/// Pipeline:
/// WasapiCapture (event-sync, 10 ms buffer)
/// → BufferedWaveProvider (250 ms ring; ReadFully=true pads with silence on underflow,
/// DiscardOnBufferOverflow=true drops oldest on overflow)
/// → ToSampleProvider (bytes → floats)
/// → WdlResamplingSampleProvider (any rate → 48 kHz)
/// → StereoMixDown (any channel layout → stereo)
///
/// The <see cref="Provider"/> exposes that final 48 kHz stereo float stream so the mixing engine
/// can plug it into NAudio's <see cref="MixingSampleProvider"/>.
///
/// Threading: NAudio's capture event runs on its own dedicated thread. We push samples into a
/// thread-safe BufferedWaveProvider; the mixer's pull thread reads from the sample-provider
/// chain. Standard NAudio idiom — well-tested and avoids hand-rolling SPSC ring buffers.
///
/// Per-source clock drift across independent audio devices IS unavoidable
/// (https://rogueamoeba.com/support/knowledgebase/?showArticle=Loopback-AggregateDeviceHandling)
/// but the 250 ms ring + automatic discard-on-overflow tolerates it for realistic session
/// lengths. A proper drift-correcting micro-resample is a future addition.
/// </summary>
internal sealed class CaptureSource : IDisposable
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int CaptureBufferMs = 10;
private const int RingBufferMs = 250;
private readonly WasapiCapture capture;
private readonly BufferedWaveProvider buffer;
private readonly Action<string>? onDiagnostic;
private long callbackCount;
private long bytesCaptured;
private string? lastError;
public string Name { get; }
public CaptureKind Kind { get; }
public string DeviceId { get; }
public ISampleProvider Provider { get; }
public string CaptureFormatDescription { get; }
public long CallbackCount => Interlocked.Read(ref callbackCount);
public long BytesCaptured => Interlocked.Read(ref bytesCaptured);
public string? LastError => lastError;
public int BufferedMilliseconds =>
(int)(buffer.BufferedDuration.TotalMilliseconds);
public CaptureSource(MMDevice device, CaptureKind kind, string displayName, Action<string>? onDiagnostic = null)
{
Name = displayName;
Kind = kind;
DeviceId = device.ID;
this.onDiagnostic = onDiagnostic;
capture = kind == CaptureKind.Loopback
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs);
var captureFormat = capture.WaveFormat;
CaptureFormatDescription =
$"{captureFormat.SampleRate} Hz, {captureFormat.Channels} ch, {captureFormat.BitsPerSample}-bit "
+ (captureFormat.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : captureFormat.Encoding.ToString());
buffer = new BufferedWaveProvider(captureFormat)
{
ReadFully = true,
DiscardOnBufferOverflow = true,
BufferDuration = TimeSpan.FromMilliseconds(RingBufferMs),
};
ISampleProvider sp = buffer.ToSampleProvider();
if (sp.WaveFormat.SampleRate != MixSampleRate)
{
sp = new WdlResamplingSampleProvider(sp, MixSampleRate);
}
if (sp.WaveFormat.Channels != MixChannels)
{
sp = new StereoMixDownSampleProvider(sp);
}
Provider = sp;
capture.DataAvailable += OnDataAvailable;
capture.RecordingStopped += OnRecordingStopped;
}
public void Start()
{
try
{
capture.StartRecording();
onDiagnostic?.Invoke($"capture started \"{Name}\" ({Kind}) at {CaptureFormatDescription}");
}
catch (Exception ex)
{
lastError = ex.Message;
onDiagnostic?.Invoke($"capture start failed for \"{Name}\": {ex.GetType().Name}: {ex.Message}");
throw;
}
}
public void Stop()
{
try { capture.StopRecording(); } catch { /* ignore */ }
}
public void Dispose()
{
Stop();
capture.DataAvailable -= OnDataAvailable;
capture.RecordingStopped -= OnRecordingStopped;
capture.Dispose();
}
private void OnDataAvailable(object? sender, WaveInEventArgs e)
{
Interlocked.Increment(ref callbackCount);
Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
if (e.BytesRecorded <= 0) return;
try
{
buffer.AddSamples(e.Buffer, 0, e.BytesRecorded);
}
catch (Exception ex)
{
lastError = ex.Message;
onDiagnostic?.Invoke($"capture buffer error for \"{Name}\": {ex.GetType().Name}: {ex.Message}");
}
}
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
{
if (e.Exception is not null)
{
lastError = e.Exception.Message;
onDiagnostic?.Invoke($"capture stopped with error for \"{Name}\": {e.Exception.GetType().Name}: {e.Exception.Message}");
}
}
/// <summary>
/// Down-mixes any channel layout to stereo. Mono is duplicated to L=R; stereo passes through;
/// multi-channel (5.1, 7.1, etc.) takes the front L/R channels (a basic "front-pair" pick,
/// not a full ITU down-mix matrix). Same approach as the legacy RSound build.
/// </summary>
private sealed class StereoMixDownSampleProvider : ISampleProvider
{
private readonly ISampleProvider source;
private float[] sourceBuffer = new float[4096];
public StereoMixDownSampleProvider(ISampleProvider source)
{
this.source = source;
WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(source.WaveFormat.SampleRate, 2);
}
public WaveFormat WaveFormat { get; }
public int Read(float[] buffer, int offset, int count)
{
var frames = count / 2;
var sourceChannels = source.WaveFormat.Channels;
var sourceFloats = frames * sourceChannels;
if (sourceBuffer.Length < sourceFloats) sourceBuffer = new float[sourceFloats];
var read = source.Read(sourceBuffer, 0, sourceFloats) / Math.Max(sourceChannels, 1);
var written = 0;
for (var i = 0; i < read; i++)
{
if (sourceChannels == 1)
{
var s = sourceBuffer[i];
buffer[offset + written++] = s;
buffer[offset + written++] = s;
}
else
{
buffer[offset + written++] = sourceBuffer[i * sourceChannels];
buffer[offset + written++] = sourceBuffer[i * sourceChannels + 1];
}
}
if (written < count) Array.Clear(buffer, offset + written, count - written);
return count;
}
}
}
@@ -0,0 +1,255 @@
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// Capture backend that runs a WASAPI <see cref="MixingEngine"/> and the persistent
/// <see cref="AsioCaptureBackend"/> owned by <see cref="AudioSender"/> in parallel, as two
/// independent lanes — each producing its own PCM stream for its own <see cref="SenderLane"/>.
///
/// Two pipeline shapes are reachable today:
/// <list type="bullet">
/// <item>WasapiOnly: WASAPI child only, no ASIO in the path. Used when no ASIO driver is
/// selected (or none is installed). Lowest latency for WASAPI-only setups.</item>
/// <item>BothIndependent: WASAPI child + persistent ASIO child running side by side. Each
/// delivers samples to its own callback; there is no mix loop, no shared buffer, no
/// tee. ASIO keeps its native sub-5 ms pipeline; WASAPI keeps its WASAPI-event rate.
/// The legacy <c>AudioMode.Both</c> tee-style mode and <c>AudioMode.AsioOnly</c> are
/// no longer reachable from the UI; their enum values remain in
/// <see cref="AudioMode"/> for back-compat but produce nothing here.</item>
/// </list>
/// </summary>
internal sealed class CompositeCaptureBackend : ICaptureBackend
{
// WASAPI lane callback. In WasapiOnly this is the only callback in use; in BothIndependent
// it is specifically the WASAPI lane (the ASIO lane has its own callback below).
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
// ASIO lane callback. Only meaningful in BothIndependent (passed but unused in WasapiOnly,
// where the persistent ASIO instance is disposed by AudioSender).
private readonly Action<ReadOnlyMemory<float>>? onAsioLaneSamples;
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
// WASAPI child. Normally a MixingEngine (timer-driven, supports N sources); swapped to
// PushModeWasapiBackend in Start() when useTightLatencyWasapi is true AND there is exactly
// one WASAPI source. Push-mode lets the WASAPI capture event drive the encoder/UDP-send
// pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler jitter that's
// otherwise visible in the receiver as maxGapMs spikes. Multi-source push mode isn't
// supported (rendezvous-of-N-callback-streams problem) — multi-source falls back to
// MixingEngine.
private ICaptureBackend? wasapi;
// ASIO child. BORROWED — AudioSender owns the persistent instance and keeps the driver
// open across audio-mode rebuilds (so Audient and similar drivers don't get a rapid
// close+reopen, which they hate). The composite uses this reference but does NOT
// dispose it; AudioSender disposes on app shutdown or driver change.
private readonly AsioCaptureBackend? asio;
private readonly string? asioDriverName;
private readonly AudioMode mode;
private readonly bool useTightLatencyWasapi;
private List<CaptureSourceSpec> wasapiSpecs = [];
private List<CaptureSourceSpec> asioSpecs = [];
private bool started;
public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<ReadOnlyMemory<float>>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action<string>? onDiagnostic = null, bool useTightLatencyWasapi = false)
{
this.onMixedSamples = onMixedSamples;
this.onAsioLaneSamples = onAsioLaneSamples;
this.onDiagnostic = onDiagnostic;
this.asioDriverName = asioDriverName;
this.mode = mode;
this.useTightLatencyWasapi = useTightLatencyWasapi;
// Legacy enum values (AsioOnly, Both) are no longer produced by the UI but might
// arrive here from in-flight callers. Coerce them into reachable modes: a non-WASAPI
// request without a driver demotes to WasapiOnly; a non-WASAPI request with a driver
// is treated as BothIndependent (the only ASIO-using mode now).
if (mode != AudioMode.WasapiOnly)
{
if (string.IsNullOrEmpty(asioDriverName) || injectedAsio is null)
{
this.mode = mode = AudioMode.WasapiOnly;
}
else if (mode != AudioMode.BothIndependent)
{
this.mode = mode = AudioMode.BothIndependent;
}
}
// Always build the WASAPI lane (it is the WasapiOnly callback path, and the WASAPI
// lane in BothIndependent). Push-mode swap, if applicable, happens in Start().
wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
// Borrow the persistent ASIO instance only in BothIndependent. AudioSender already
// pointed its callback at the right lane via SetCallback before constructing us.
if (mode == AudioMode.BothIndependent)
{
asio = injectedAsio;
}
}
public bool IsRunning => started;
public long TotalCaptureCallbacks => (wasapi?.TotalCaptureCallbacks ?? 0) + (asio?.TotalCaptureCallbacks ?? 0);
public long TotalCaptureBytes => (wasapi?.TotalCaptureBytes ?? 0) + (asio?.TotalCaptureBytes ?? 0);
public string? FirstCaptureFormatDescription => asio?.FirstCaptureFormatDescription ?? wasapi?.FirstCaptureFormatDescription;
public string? FirstCaptureLastError => asio?.FirstCaptureLastError ?? wasapi?.FirstCaptureLastError;
// ClippedSampleCount lived on the (now-removed) classic-Both mix loop; the per-lane
// BothIndependent pipeline has no shared mix bus to clip. Kept as 0 so any UI binding
// that still reads it doesn't NRE.
public long ClippedSampleCount => 0;
/// <summary>Worst callback-gap across both inner backends. We have to take from BOTH (so
/// each inner's counter resets), then return the larger — otherwise the unread inner
/// would just keep accumulating its max forever.</summary>
public int TakeMaxCallbackGapMs()
{
var w = wasapi?.TakeMaxCallbackGapMs() ?? 0;
var a = asio?.TakeMaxCallbackGapMs() ?? 0;
return Math.Max(w, a);
}
public IReadOnlyList<string> ActiveSourceNames
{
get
{
var combined = new List<string>();
if (wasapi is not null) combined.AddRange(wasapi.ActiveSourceNames);
if (asio is not null) combined.AddRange(asio.ActiveSourceNames);
return combined;
}
}
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
if (started) StopInternal();
(wasapiSpecs, asioSpecs) = SplitSpecs(specs);
// Push-mode WASAPI selection. Lets the WASAPI capture event drive the encoder/UDP
// send pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler
// jitter. Conditions: tight-latency requested, and exactly one WASAPI source
// (multi-source needs the rendezvous logic in MixingEngine). Applies equally in
// WasapiOnly and BothIndependent — in either, the WASAPI lane is single-source
// when the user has ticked one input.
var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1;
var currentIsPush = wasapi is PushModeWasapiBackend;
if (wantPushMode != currentIsPush)
{
try { wasapi?.Dispose(); } catch { /* ignore */ }
if (wantPushMode)
{
wasapi = new PushModeWasapiBackend(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
onDiagnostic?.Invoke("wasapi backend: switched to push-mode (audio-clock-locked, single-source)");
}
else
{
wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
onDiagnostic?.Invoke("wasapi backend: switched to mix-engine (timer-driven, multi-source capable)");
}
}
wasapi!.Start(wasapiSpecs);
// ASIO child is BORROWED from AudioSender. If the driver is already open from a
// previous engine instance we want UpdateSources (which won't close it) rather
// than Start (which would Stop+Open and trigger the close+reopen hang on Audient).
// The callback was already wired to the correct lane by EnsurePersistentAsioLocked.
if (asio is not null)
{
if (asio.IsRunning) asio.UpdateSources(asioSpecs);
else asio.Start(asioSpecs);
}
started = true;
onDiagnostic?.Invoke($"composite capture started: wasapi={wasapiSpecs.Count} sources, asio={asioSpecs.Count} sources, mode={ModeLabel()}{(wantPushMode ? " [wasapi push]" : "")}");
}
}
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
if (!started)
{
Start(specs);
return;
}
var (newWasapi, newAsio) = SplitSpecs(specs);
// If push-mode applicability changes (single WASAPI source toggled on/off), the
// backend has to swap. PushModeWasapiBackend supports only one source. Full
// restart is acceptable here — changing source count mid-session is rare.
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
var isPush = wasapi is PushModeWasapiBackend;
if (wouldBePush != isPush)
{
onDiagnostic?.Invoke($"wasapi backend: source count changed ({wasapiSpecs.Count}→{newWasapi.Count}), restarting to switch backend");
StopInternal();
Start(specs);
return;
}
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
{
wasapi.UpdateSources(newWasapi);
wasapiSpecs = newWasapi;
}
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
{
asio.UpdateSources(newAsio);
asioSpecs = newAsio;
}
}
}
private string ModeLabel() => mode switch
{
AudioMode.WasapiOnly => "fast (WASAPI direct)",
AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)",
_ => mode.ToString(),
};
public void Stop()
{
lock (gate) StopInternal();
}
private void StopInternal()
{
if (!started) return;
try { wasapi?.Stop(); } catch { /* ignore */ }
// ASIO child is NEVER stopped here — it's the persistent instance owned by AudioSender
// and kept alive across engine rebuilds. Stopping it would force a close+reopen that
// Audient (and similar drivers) hang on for ~5 s. AudioSender disposes it on app
// shutdown or driver change.
started = false;
}
public void Dispose()
{
Stop();
try { wasapi?.Dispose(); } catch { /* ignore */ }
// ASIO child not disposed — see StopInternal above.
}
private static (List<CaptureSourceSpec> wasapi, List<CaptureSourceSpec> asio) SplitSpecs(IReadOnlyList<CaptureSourceSpec> specs)
{
var wasapi = new List<CaptureSourceSpec>();
var asio = new List<CaptureSourceSpec>();
foreach (var spec in specs)
{
if (AsioDeviceId.TryParse(spec.DeviceId, out _)) asio.Add(spec);
else wasapi.Add(spec);
}
return (wasapi, asio);
}
private static bool SpecsEqual(IReadOnlyList<CaptureSourceSpec> a, IReadOnlyList<CaptureSourceSpec> b)
{
if (a.Count != b.Count) return false;
for (var i = 0; i < a.Count; i++)
{
if (a[i].DeviceId != b[i].DeviceId || a[i].Kind != b[i].Kind) return false;
}
return true;
}
}
+55
View File
@@ -0,0 +1,55 @@
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// Abstraction over the capture-side audio backend so <see cref="AudioSender"/> can be wired
/// to either a WASAPI implementation (today's <see cref="MixingEngine"/>) or an ASIO
/// implementation (<see cref="AsioCaptureBackend"/>) without caring which is in use.
///
/// Both backends produce 48 kHz stereo float frames via the constructor-supplied
/// <c>onMixedSamples</c> callback and accept the same <see cref="CaptureSourceSpec"/> identity
/// model. ASIO specs use a synthetic <see cref="CaptureSourceSpec.DeviceId"/> of the form
/// <c>"asio:&lt;driver-name&gt;|&lt;channel-pair-index&gt;"</c>; WASAPI specs use the
/// MMDevice ID.
/// </summary>
internal interface ICaptureBackend : IDisposable
{
bool IsRunning { get; }
/// <summary>Total capture callback count across all active sources (WASAPI) or the ASIO
/// driver's input-callback count.</summary>
long TotalCaptureCallbacks { get; }
long TotalCaptureBytes { get; }
/// <summary>Brief format description of the first source (e.g. "96000 Hz, 2 ch, 32-bit
/// float"). Used in diagnostic logs.</summary>
string? FirstCaptureFormatDescription { get; }
string? FirstCaptureLastError { get; }
/// <summary>Cumulative count of samples that hit the soft-limiter / hard-clamp at the
/// encoder boundary. Helpful to know if the source mix is hot enough to need attenuation.</summary>
long ClippedSampleCount { get; }
/// <summary>Friendly names of currently-active sources for diagnostic columns.</summary>
IReadOnlyList<string> ActiveSourceNames { get; }
/// <summary>Largest observed gap (in milliseconds) between consecutive capture callbacks
/// since the last call. Resets to zero on read. Exposed so the periodic sender diagnostic
/// can surface "the audio capture path stalled for 19 ms" — which on a smoothly-running
/// backend should be ≈ buffer-period, but spikes up when GC, USB, or scheduler hiccups
/// pause the capture thread. A receiver-side gap > 510 ms with otherwise clean network
/// is almost always traceable to this value spiking on the sender. Backends that don't
/// support per-callback timing (e.g. trivial test backends) may return 0.</summary>
int TakeMaxCallbackGapMs();
void Start(IReadOnlyList<CaptureSourceSpec> specs);
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
/// only the sources that actually changed. Behaviour parity expected from both backends.</summary>
void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs);
void Stop();
}
@@ -0,0 +1,24 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
namespace RemSound.Sender;
/// <summary>
/// WasapiLoopbackCapture variant that uses event-sync (interrupt-driven) callbacks with a
/// short audio buffer. NAudio's default <see cref="WasapiLoopbackCapture"/> polls every
/// half-buffer (~50 ms with the default 100 ms buffer), which delivers audio in noticeable
/// bursts and blows up the receiver's playout buffer headroom requirement.
///
/// With event sync + 10 ms buffer, callbacks fire at the device period (~10 ms typical),
/// each carrying ~10 ms of audio. Far smoother. This is what the older RSound build used,
/// minus the layers of subsequent abstraction.
/// </summary>
internal sealed class LowLatencyWasapiLoopbackCapture : WasapiCapture
{
public LowLatencyWasapiLoopbackCapture(MMDevice device, int audioBufferMilliseconds = 10)
: base(device, useEventSync: true, audioBufferMillisecondsLength: Math.Clamp(audioBufferMilliseconds, 5, 200))
{
}
protected override AudioClientStreamFlags GetAudioClientStreamFlags() => AudioClientStreamFlags.Loopback;
}
+358
View File
@@ -0,0 +1,358 @@
using System.Diagnostics;
using NAudio.CoreAudioApi;
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// Owns N <see cref="CaptureSource"/> objects + an NAudio <see cref="MixingSampleProvider"/> +
/// a 10 ms mix tick. Each tick pulls one frame's worth of mixed 48 kHz stereo float samples
/// from the mix bus and hands it to <see cref="OnMixedSamples"/>, which the caller wires into
/// the encoder/UDP path.
///
/// Architecture rationale (validated by external research, see notes in CaptureSource.cs):
/// • Separate WASAPI captures, each into a buffered ring, all converted to a common 48 kHz
/// stereo float format, then summed via NAudio's MixingSampleProvider — the canonical
/// pattern (https://www.markheath.net/post/mixing-and-looping-with-naudio).
/// • Loopback captures only fire callbacks when something is rendering on the device. Without
/// a continuous render stream, the mic capture (which always fires) and the loopback (which
/// intermittently fires) desync and the mix gets gaps — see naudio/NAudio#1110. The sender
/// starts a <see cref="SilentRenderKeepAlive"/> on every loopback source's device to keep
/// callbacks firing continuously.
/// • Per-source clock drift between independent audio devices is unavoidable across long
/// sessions. The 250 ms ring + DiscardOnBufferOverflow tolerates it for realistic
/// conversation lengths. A proper drift-correcting micro-resample is a future addition.
///
/// Source-list changes are LIVE: <see cref="UpdateSources"/> diffs the desired set against the
/// active set and only adds/removes the sources that actually changed, using NAudio's
/// AddMixerInput / RemoveMixerInput. The mix loop never pauses; the encoder's streamId stays
/// the same; the receiver doesn't re-init its playout. This is what stops a checkbox toggle
/// from causing a 60 ms gap + receiver underrun + auto-tune freakout.
///
/// The mix-tick loop runs on its own task with Stopwatch-based scheduling for jitter-tolerant
/// 10 ms timing — better than System.Threading.Timer or Sleep-based loops.
/// </summary>
internal sealed class MixingEngine : ICaptureBackend
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int MixTickMs = 10;
private const int MixSamplesPerTick = MixSampleRate * MixChannels * MixTickMs / 1000; // 960 floats
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
private readonly List<ActiveSource> active = [];
private MixingSampleProvider? mixer;
private float[] mixScratch = new float[MixSamplesPerTick];
private CancellationTokenSource? cts;
private Task? mixTask;
private long clippedSampleCount;
private long mixTickCount;
public MixingEngine(Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
{
this.onMixedSamples = onMixedSamples;
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => mixTask is { IsCompleted: false };
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
public long MixTickCount => Interlocked.Read(ref mixTickCount);
public long TotalCaptureCallbacks
{
get
{
lock (gate)
{
long total = 0;
foreach (var a in active) total += a.Source.CallbackCount;
return total;
}
}
}
public long TotalCaptureBytes
{
get
{
lock (gate)
{
long total = 0;
foreach (var a in active) total += a.Source.BytesCaptured;
return total;
}
}
}
/// <summary>MixingEngine doesn't track per-callback timing — its mix tick is timer-driven
/// rather than callback-driven, so the metric isn't directly meaningful here. Returning 0
/// is fine: the sender's diag log treats "0 means n/a or no spike". Tight Latency mode in
/// WASAPI uses <see cref="PushModeWasapiBackend"/> instead, which is callback-driven.</summary>
public int TakeMaxCallbackGapMs() => 0;
public string? FirstCaptureFormatDescription
{
get { lock (gate) return active.Count == 0 ? null : active[0].Source.CaptureFormatDescription; }
}
public string? FirstCaptureLastError
{
get { lock (gate) return active.Count == 0 ? null : active[0].Source.LastError; }
}
public IReadOnlyList<string> ActiveSourceNames
{
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
}
/// <summary>
/// Starts the mix loop with the given initial source set. If already running, the existing
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
/// sources without interrupting the loop.
/// </summary>
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
if (IsRunning) StopInternal();
if (specs.Count == 0) return;
var mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
mixer = new MixingSampleProvider(mixFormat) { ReadFully = true };
foreach (var spec in specs)
{
var entry = OpenSource(spec);
if (entry is null) continue;
mixer.AddMixerInput(entry.Source.Provider);
active.Add(entry);
}
if (active.Count == 0)
{
onDiagnostic?.Invoke("mixer: no sources opened — staying stopped");
mixer = null;
return;
}
foreach (var a in active)
{
try { a.Source.Start(); }
catch (Exception ex)
{
onDiagnostic?.Invoke($"mixer: source \"{a.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}");
}
}
Interlocked.Exchange(ref clippedSampleCount, 0);
Interlocked.Exchange(ref mixTickCount, 0);
cts = new CancellationTokenSource();
mixTask = Task.Run(() => MixLoop(cts.Token));
onDiagnostic?.Invoke($"mixer started with {active.Count} source(s): [{string.Join(", ", active.Select(a => $"\"{a.Source.Name}\" ({a.Source.Kind})"))}]");
}
}
/// <summary>
/// Live add/remove of sources without stopping the mix loop. Diffs the desired specs
/// against the currently active set: removes those no longer wanted (RemoveMixerInput +
/// dispose), adds those newly wanted (open + AddMixerInput + start). The mix loop continues
/// reading uninterrupted from whatever is currently in the mixer.
/// </summary>
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
{
// If the engine was started with no sources (specs.Count==0 returns early in
// Start, so mixTask is never created), a later UpdateSources adding sources used
// to silently no-op. That broke the BothIndependent flow where a user starts in
// AsioOnly→BothIndependent with no WASAPI ticks, then later ticks a WASAPI source
// — the lane would never come alive. Mirror AsioCaptureBackend's pattern: when
// not running and the new spec set is non-empty, just delegate to Start. The
// existing empty-specs case (still not running, still no sources to add) stays a
// no-op as before. 2026-05-11.
if (!IsRunning || mixer is null)
{
if (specs.Count > 0)
{
Start(specs);
}
return;
}
var desiredKeys = specs.Select(s => SourceKey(s.DeviceId, s.Kind)).ToHashSet();
// Remove sources no longer wanted.
for (var i = active.Count - 1; i >= 0; i--)
{
var a = active[i];
if (desiredKeys.Contains(SourceKey(a.Source.DeviceId, a.Source.Kind))) continue;
try { mixer.RemoveMixerInput(a.Source.Provider); } catch { /* ignore */ }
DisposeEntry(a);
active.RemoveAt(i);
onDiagnostic?.Invoke($"mixer: removed source \"{a.Source.Name}\" ({a.Source.Kind})");
}
// Add new sources.
var existingKeys = active.Select(a => SourceKey(a.Source.DeviceId, a.Source.Kind)).ToHashSet();
foreach (var spec in specs)
{
if (existingKeys.Contains(SourceKey(spec.DeviceId, spec.Kind))) continue;
var entry = OpenSource(spec);
if (entry is null) continue;
mixer.AddMixerInput(entry.Source.Provider);
active.Add(entry);
try
{
entry.Source.Start();
onDiagnostic?.Invoke($"mixer: added source \"{entry.Source.Name}\" ({entry.Source.Kind})");
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"mixer: source \"{entry.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}");
}
}
}
}
public void Stop()
{
lock (gate) StopInternal();
}
private void StopInternal()
{
try { cts?.Cancel(); } catch { /* ignore */ }
try { mixTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ }
cts?.Dispose();
cts = null;
mixTask = null;
foreach (var a in active) DisposeEntry(a);
active.Clear();
mixer = null;
}
public void Dispose() => Stop();
/// <summary>
/// Opens a single source from a spec: enumerates the device, creates the capture, attaches a
/// silence keepalive for loopback sources. Does NOT register with the mixer or start capture
/// — caller does that. Returns null on any failure (device gone, format negotiation, etc.)
/// after disposing partial state.
/// </summary>
private ActiveSource? OpenSource(CaptureSourceSpec spec)
{
MMDevice? device = null;
try
{
using var enumerator = new MMDeviceEnumerator();
device = enumerator.GetDevice(spec.DeviceId);
var src = new CaptureSource(device, spec.Kind, spec.Name, onDiagnostic);
SilentRenderKeepAlive? ka = null;
if (spec.Kind == CaptureKind.Loopback)
{
try
{
ka = new SilentRenderKeepAlive(device, onDiagnostic);
ka.Start();
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"mixer: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
ka = null; // capture still works without it; just less robust on USB devices
}
}
return new ActiveSource { Source = src, KeepAlive = ka, Device = device };
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"mixer: failed to open source \"{spec.Name}\" ({spec.Kind}): {ex.GetType().Name}: {ex.Message}");
try { device?.Dispose(); } catch { /* ignore */ }
return null;
}
}
/// <summary>Disposes a source bundle in the right order: keepalive first (it shares the device
/// with the capture; tearing down the device first leaves the keepalive's WasapiOut talking to
/// a freed COM handle), then capture, then device.</summary>
private static void DisposeEntry(ActiveSource a)
{
try { a.KeepAlive?.Dispose(); } catch { /* ignore */ }
try { a.Source.Dispose(); } catch { /* ignore */ }
try { a.Device.Dispose(); } catch { /* ignore */ }
}
private static string SourceKey(string deviceId, CaptureKind kind) => $"{deviceId}|{kind}";
private async Task MixLoop(CancellationToken ct)
{
// Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible.
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
var ticksPerFrame = Stopwatch.Frequency * MixTickMs / 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 we fell catastrophically behind (>4 frames), resync rather than spinning.
if (now - nextTickStopwatch > ticksPerFrame * 4)
{
nextTickStopwatch = now;
}
nextTickStopwatch += ticksPerFrame;
var localMixer = mixer;
if (localMixer is null) continue;
var read = localMixer.Read(mixScratch, 0, MixSamplesPerTick);
if (read <= 0) continue;
// Hard-clamp mixed sum to [-1, 1] to prevent encoder clipping when multiple loud
// sources sum past unity. Counts clipped samples for diagnostics.
long clipped = 0;
for (var i = 0; i < read; i++)
{
var v = mixScratch[i];
if (v > 1f) { mixScratch[i] = 1f; clipped++; }
else if (v < -1f) { mixScratch[i] = -1f; clipped++; }
}
if (clipped > 0) Interlocked.Add(ref clippedSampleCount, clipped);
Interlocked.Increment(ref mixTickCount);
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, read));
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"mix loop error: {ex.GetType().Name}: {ex.Message}");
await Task.Delay(50, ct).ConfigureAwait(false);
}
}
}
private sealed class ActiveSource
{
public required CaptureSource Source { get; init; }
public required MMDevice Device { get; init; }
public SilentRenderKeepAlive? KeepAlive { get; init; }
}
}
+70
View File
@@ -0,0 +1,70 @@
using Concentus;
using Concentus.Enums;
namespace RemSound.Sender;
/// <summary>
/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio.
/// Frame size is selectable at construction (10 ms or 20 ms). Receiver auto-handles whatever
/// frame size the sender announces in the format packet — no coordination required.
/// </summary>
internal sealed class OpusEncoderState : IDisposable
{
public const int Channels = 2;
private const int PacketBufferBytes = 4000;
private readonly IOpusEncoder encoder;
private readonly short[] pcm16Scratch;
private readonly byte[] packetScratch = new byte[PacketBufferBytes];
public int FrameMilliseconds { get; }
public int FrameSizePerChannel { get; }
public OpusEncoderState(int frameMilliseconds, int bitrate)
{
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames. 10 ms = lowest practical latency,
// 20 ms = same bitrate but more robust to packet loss (each lost packet is half the audio
// share). We expose 10 and 20 as the user-selectable choices.
FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60);
FrameSizePerChannel = 48000 * FrameMilliseconds / 1000;
pcm16Scratch = new short[FrameSizePerChannel * Channels];
encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null);
encoder.Bitrate = bitrate;
encoder.Complexity = 10;
encoder.UseVBR = true;
// Inband forward error correction. Each encoded packet carries a low-bitrate
// copy of the PREVIOUS packet's audio. The receiver only uses it when it
// detects a single-packet gap, so on a clean line FEC costs almost nothing
// (the encoder gets a few extra bytes of headroom from VBR). On a lossy
// link it lets the receiver fill a single missing packet without waiting
// — recovery without buffering.
encoder.UseInbandFEC = true;
// Tells the encoder how aggressively to bias FEC redundancy. 10% is a
// sensible value for an internet link via Tailscale: enough redundancy to
// recover most one-packet drops, not so much that we sacrifice quality on
// a clean network. Concentus accepts 0..100.
encoder.PacketLossPercent = 10;
}
/// <summary>Encode one frame at the configured frame size. Returns bytes written.</summary>
public int Encode(ReadOnlySpan<float> stereoFloats)
{
if (stereoFloats.Length != FrameSizePerChannel * Channels)
{
throw new ArgumentException($"Expected {FrameSizePerChannel * Channels} samples, got {stereoFloats.Length}", nameof(stereoFloats));
}
for (var i = 0; i < stereoFloats.Length; i++)
{
var clamped = Math.Clamp(stereoFloats[i], -1f, 1f);
pcm16Scratch[i] = (short)(clamped * 32767f);
}
return encoder.Encode(pcm16Scratch, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length);
}
public ReadOnlySpan<byte> LastEncoded(int length) => packetScratch.AsSpan(0, length);
public void Dispose() { /* IOpusEncoder is finalized by GC, no Dispose */ }
}
@@ -0,0 +1,342 @@
using System.Runtime.InteropServices;
using NAudio.CoreAudioApi;
using NAudio.Dsp;
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// Single-source WASAPI capture backend with PUSH-DRIVEN timing — the WASAPI capture event
/// callback is the encode/send trigger, so the audio pipeline runs on the audio device's
/// hardware clock instead of the OS scheduler's Stopwatch+WaitHandle clock.
///
/// Why this exists: <see cref="MixingEngine"/> uses a Stopwatch-driven 10 ms mix tick that
/// pulls audio through a sample-provider chain. That tick is woken by
/// <see cref="WaitHandle.WaitAny"/>, which on Windows has ~6 ms of inherent jitter even with
/// MMCSS Pro Audio thread priority — visible as <c>maxGapMs=16-20 ms</c> in the receiver
/// diagnostics. At 48 kHz device rate that jitter is absorbed by buffer cushion; at 96 kHz the
/// extra in-tick resampling stage compounds it and the receiver's buffer ends up sitting
/// ~13 ms lower (closer to the underrun edge), producing audible clicks at tight target
/// latency.
///
/// Push mode eliminates the mix tick entirely. The WASAPI callback already fires at the
/// device's hardware-clocked period (sub-millisecond precision), and we run the
/// resample / stereo-mixdown / soft-clamp / hand-off-to-encoder pipeline directly on the
/// callback thread. Same architectural shape as <see cref="AsioCaptureBackend"/> already has.
///
/// Constraints (deliberate scope reduction so we ship something testable):
/// • Single source only. <see cref="Start"/> with multiple specs throws — caller is
/// expected to fall back to <see cref="MixingEngine"/> for multi-source. Mixing N
/// independent WASAPI capture callbacks needs a rendezvous point that doesn't exist
/// in this design.
/// • Float-format capture only. Modern WASAPI loopback / shared-mode delivers
/// <see cref="WaveFormatEncoding.IeeeFloat"/> 32-bit stereo on every device we've seen.
/// Direct-input devices that report int16 will fall through to a diagnostic and the
/// callback returns silence; caller can fall back to <see cref="MixingEngine"/> in that
/// case (which uses NAudio's <c>ToSampleProvider</c> conversion path that handles all
/// formats).
/// • Resampling is performed inline using <see cref="WdlResampler"/> (sinc filter). Same
/// resampler the existing pull path uses — kept identical to keep audio quality
/// comparable.
///
/// Threading: NAudio's WASAPI callback runs on its own thread, which becomes the audio
/// thread for our purposes. <see cref="onMixedSamples"/> is invoked synchronously from
/// inside that callback, so the encoder/UDP-send work happens on the capture thread. PCM
/// pack and Opus encode are both fast enough not to overrun the next callback period
/// (typically &lt; 200 µs of work per 10 ms callback on modern hardware).
/// </summary>
internal sealed class PushModeWasapiBackend : ICaptureBackend
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int CaptureBufferMs = 10;
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
private readonly Action<string>? onDiagnostic;
private readonly object gate = new();
private WasapiCapture? capture;
private SilentRenderKeepAlive? keepAlive;
private CaptureSourceSpec? activeSpec;
private string? captureFormatDescription;
private string? lastError;
private long callbackCount;
private long bytesCaptured;
private long clippedSampleCount;
// Resampling state — only allocated when source rate != MixSampleRate.
private WdlResampler? resampler;
private int sourceSampleRate;
private int sourceChannels;
// Reusable scratch buffers. Sized lazily inside the callback.
private float[] sourceFloatScratch = new float[8192];
private float[] resampledScratch = new float[8192];
private float[] stereoScratch = new float[4096];
public PushModeWasapiBackend(Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
{
this.onMixedSamples = onMixedSamples;
this.onDiagnostic = onDiagnostic;
}
public bool IsRunning => capture is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
public string? FirstCaptureFormatDescription => captureFormatDescription;
public string? FirstCaptureLastError => lastError;
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
public IReadOnlyList<string> ActiveSourceNames =>
activeSpec is { } s ? new[] { s.Name } : Array.Empty<string>();
/// <summary>Push-mode WASAPI is callback-driven and could meaningfully track callback gaps,
/// but for now we don't — adding the timing only matters once we're hunting an audible
/// jitter issue on the WASAPI tight-latency path. Returns 0 (= no spike). Compare with
/// <see cref="AsioCaptureBackend.TakeMaxCallbackGapMs"/> which does track it because that's
/// where Ed has been hunting jitter.</summary>
public int TakeMaxCallbackGapMs() => 0;
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
if (specs.Count == 0)
{
onDiagnostic?.Invoke("push-wasapi: start called with no specs — staying stopped");
return;
}
if (specs.Count > 1)
{
// Surface this loudly. The caller should have routed multi-source to MixingEngine.
throw new InvalidOperationException(
$"PushModeWasapiBackend supports only one source, got {specs.Count}. Caller must fall back to MixingEngine for multi-source.");
}
lock (gate)
{
if (IsRunning) StopInternal();
var spec = specs[0];
try
{
using var enumerator = new MMDeviceEnumerator();
var device = enumerator.GetDevice(spec.DeviceId);
capture = spec.Kind == CaptureKind.Loopback
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs);
var fmt = capture.WaveFormat;
sourceChannels = fmt.Channels;
sourceSampleRate = fmt.SampleRate;
captureFormatDescription = $"{fmt.SampleRate} Hz, {fmt.Channels} ch, {fmt.BitsPerSample}-bit "
+ (fmt.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : fmt.Encoding.ToString());
if (fmt.Encoding != WaveFormatEncoding.IeeeFloat)
{
onDiagnostic?.Invoke(
$"push-wasapi: source \"{spec.Name}\" reports non-float capture format ({fmt.Encoding}); push mode requires IeeeFloat");
lastError = $"unsupported source encoding: {fmt.Encoding}";
StopInternal();
return;
}
if (fmt.SampleRate != MixSampleRate)
{
resampler = new WdlResampler();
// Same configuration the existing CaptureSource pull path uses — sinc filter,
// 64-tap, 32 sub-phase. Quality matches the pull path so any audible
// difference vs MixingEngine is timing-driven, not filter-quality-driven.
resampler.SetMode(true, 2, true, 64, 32);
resampler.SetFilterParms();
resampler.SetFeedMode(false); // pull mode internally; we drive the pull from our callback
resampler.SetRates(sourceSampleRate, MixSampleRate);
}
else
{
resampler = null;
}
if (spec.Kind == CaptureKind.Loopback)
{
// WASAPI loopback only fires callbacks while something else is rendering on
// the device. Same trick MixingEngine uses (see naudio/NAudio#1110).
try
{
keepAlive = new SilentRenderKeepAlive(device, onDiagnostic);
keepAlive.Start();
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"push-wasapi: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
keepAlive = null;
}
}
activeSpec = spec;
capture.DataAvailable += OnDataAvailable;
capture.RecordingStopped += OnRecordingStopped;
capture.StartRecording();
onDiagnostic?.Invoke($"push-wasapi started \"{spec.Name}\" ({spec.Kind}) at {captureFormatDescription}");
}
catch (Exception ex)
{
lastError = ex.Message;
onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
StopInternal();
throw;
}
}
}
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
{
// Single-source backend; live add/remove like MixingEngine V2 isn't applicable.
// If the spec list shape is unchanged, no-op. Otherwise restart.
var noChange = activeSpec is { } s
&& specs.Count == 1
&& specs[0].DeviceId == s.DeviceId
&& specs[0].Kind == s.Kind;
if (noChange) return;
lock (gate) StopInternal();
if (specs.Count > 0) Start(specs);
}
public void Stop()
{
lock (gate) StopInternal();
}
private void StopInternal()
{
if (capture is not null)
{
try { capture.DataAvailable -= OnDataAvailable; } catch { /* ignore */ }
try { capture.RecordingStopped -= OnRecordingStopped; } catch { /* ignore */ }
try { capture.StopRecording(); } catch { /* ignore */ }
try { capture.Dispose(); } catch { /* ignore */ }
capture = null;
}
if (keepAlive is not null)
{
try { keepAlive.Dispose(); } catch { /* ignore */ }
keepAlive = null;
}
resampler = null;
activeSpec = null;
}
public void Dispose() => Stop();
private void OnDataAvailable(object? sender, WaveInEventArgs e)
{
Interlocked.Increment(ref callbackCount);
Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
if (e.BytesRecorded <= 0) return;
try
{
// 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start).
var sourceFloatCount = e.BytesRecorded / sizeof(float);
if (sourceFloatScratch.Length < sourceFloatCount)
sourceFloatScratch = new float[sourceFloatCount];
// MemoryMarshal.Cast avoids a copy where layout permits, but e.Buffer is byte[] and we
// need the floats indexable so we copy into our scratch. Copy is cheap: 7680 bytes for
// 10 ms at 96 kHz stereo float.
Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded);
var sourceFrames = sourceFloatCount / sourceChannels;
// 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the
// pull from our callback. Approximate output frames = input * outRate / inRate.
float[] working;
int workingFrames;
int workingChannels;
if (resampler is null)
{
working = sourceFloatScratch;
workingFrames = sourceFrames;
workingChannels = sourceChannels;
}
else
{
// Compute a generous upper bound on output frames (add a small pad for the
// resampler's lookahead). The resampler is fed exactly what it needs and tells us
// how many output frames it actually produced; any input we couldn't feed in this
// iteration is held in its internal state for next callback.
var outBound = (int)Math.Ceiling(sourceFrames * (double)MixSampleRate / sourceSampleRate) + 16;
if (resampledScratch.Length < outBound * sourceChannels)
resampledScratch = new float[outBound * sourceChannels];
var inFramesNeeded = resampler.ResamplePrepare(outBound, sourceChannels, out var inBuf, out var inOff);
var copyFrames = Math.Min(sourceFrames, inFramesNeeded);
if (copyFrames > 0)
{
Array.Copy(sourceFloatScratch, 0, inBuf, inOff, copyFrames * sourceChannels);
}
var produced = resampler.ResampleOut(resampledScratch, 0, copyFrames, outBound, sourceChannels);
working = resampledScratch;
workingFrames = produced;
workingChannels = sourceChannels;
}
if (workingFrames <= 0) return;
// 3. Stereo mixdown. Mono → duplicate; stereo → passthrough; multi-channel → take
// front L/R (matches StereoMixDownSampleProvider in CaptureSource).
float[] stereo;
if (workingChannels == 2)
{
stereo = working;
}
else
{
if (stereoScratch.Length < workingFrames * MixChannels)
stereoScratch = new float[workingFrames * MixChannels];
if (workingChannels == 1)
{
for (var i = 0; i < workingFrames; i++)
{
stereoScratch[i * 2] = working[i];
stereoScratch[i * 2 + 1] = working[i];
}
}
else
{
for (var i = 0; i < workingFrames; i++)
{
stereoScratch[i * 2] = working[i * workingChannels];
stereoScratch[i * 2 + 1] = working[i * workingChannels + 1];
}
}
stereo = stereoScratch;
}
// 4. Soft clamp at the encoder boundary (matches MixingEngine / AsioCaptureBackend).
var stereoFloatCount = workingFrames * MixChannels;
for (var i = 0; i < stereoFloatCount; i++)
{
var v = stereo[i];
if (v > 1f) { stereo[i] = 1f; Interlocked.Increment(ref clippedSampleCount); }
else if (v < -1f) { stereo[i] = -1f; Interlocked.Increment(ref clippedSampleCount); }
}
// 5. Hand off to the encoder/UDP-send pipeline. Synchronous on the capture thread.
onMixedSamples(new ReadOnlyMemory<float>(stereo, 0, stereoFloatCount));
}
catch (Exception ex)
{
lastError = ex.Message;
onDiagnostic?.Invoke($"push-wasapi: callback error: {ex.GetType().Name}: {ex.Message}");
}
}
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
{
if (e.Exception is not null)
{
lastError = e.Exception.Message;
onDiagnostic?.Invoke($"push-wasapi: capture stopped with error: {e.Exception.GetType().Name}: {e.Exception.Message}");
}
}
}
@@ -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.Sender</RootNamespace>
<AssemblyName>RemSound.Sender</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>
+305
View File
@@ -0,0 +1,305 @@
using RemSound.Core;
namespace RemSound.Sender;
/// <summary>
/// One outbound audio stream's worth of state. Each lane owns its own streamId, audio
/// sequence counter, frame accumulator, Opus encoder, format-resend timer and PCM frame id.
/// AudioSender holds one or more of these — in the three classic modes (WasapiOnly,
/// AsioOnly, Both) there is exactly one lane and behaviour is identical to the pre-refactor
/// monolithic AudioSender. The BothIndependent mode (Stage 4) instantiates two: a WASAPI
/// lane fed by the WASAPI capture child and an ASIO lane fed by the ASIO capture child, each
/// producing its own UDP stream on its own streamId, multiplexed by the receiver's
/// (endpoint, streamId) keying.
///
/// Threading: the hot-path methods (<see cref="OnMixedSamples"/> and below) are called from
/// the capture engine's callback thread. Each lane has exactly one such thread feeding it.
/// Cross-thread state read from AudioSender (codec, mute, opusFrameMs, etc.) goes through
/// volatile fields on the owner. Configuration mutations (<see cref="ConfigureCodec"/>,
/// <see cref="OnPcmFrameSizeChanged"/>) come from the UI thread; they take the same
/// configGate that AudioSender does to serialise streamId rotation against in-flight
/// accumulator writes — see AudioSender for the gate.
/// </summary>
internal sealed class SenderLane
{
private const int MixSampleRate = 48000;
private const int MixChannels = 2;
private const int MaxFrameStereoSamples = MixSampleRate * 20 / 1000 * MixChannels; // 1920, Opus 20 ms
private const int FormatResendIntervalMs = 250;
private readonly AudioSender owner;
private readonly int opusBitrate;
// Hot-path scratch. Sized to the largest possible single frame (Opus 20 ms = 1920 stereo
// samples). PCM 5 ms uses only the first 480, Opus 10 ms only the first 960. Reusing one
// buffer means no realloc on codec change. outboundScratch is per-lane so two lanes don't
// step on each other's packet construction.
private readonly float[] frameAccumulator = new float[MaxFrameStereoSamples];
private int frameAccumulatorWritten;
private readonly byte[] outboundScratch = new byte[2048];
// Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus
// FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic
// format-announce packet; receiver doesn't sequence-check format packets but having a
// separate counter keeps the audio FEC clean (see AudioSender.audioSequence comment for
// the original reasoning).
private uint audioSequence;
private uint pcmFrameId;
private uint formatSequence;
private ushort streamId;
private DateTime lastFormatPacketUtc = DateTime.MinValue;
private OpusEncoderState opusEncoder;
private int opusFrameStereoSamples;
// Which render route this lane announces in its format packets. The receiver reads the
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
// backends in BothIndependent mode; the legacy Mixed surface in every classic mode).
// Default Mixed = classic-mode behaviour, indistinguishable from a pre-2026-05-11 sender.
// BothIndependent assigns WasapiLane / AsioLane to the two SenderLanes at mode-change
// time via SetRoute.
private volatile RenderRoute route = RenderRoute.Mixed;
public RenderRoute Route => route;
public ushort StreamId => streamId;
public SenderLane(AudioSender owner, int initialOpusFrameMs, int opusBitrate)
{
this.owner = owner;
this.opusBitrate = opusBitrate;
opusEncoder = new OpusEncoderState(initialOpusFrameMs, opusBitrate);
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
streamId = NewStreamId();
}
private static ushort NewStreamId() => (ushort)Random.Shared.Next(1, ushort.MaxValue);
/// <summary>
/// Set this lane's render route. Called by AudioSender when audio-mode changes — e.g.
/// switching into BothIndependent flips the default lane from Mixed to WasapiLane and
/// activates the asio lane as AsioLane. Rotates streamId and forces an immediate format
/// re-announce so the receiver opens a fresh session with the new Lane tag rather than
/// continuing to route the existing session under the old tag.
/// </summary>
public void SetRoute(RenderRoute newRoute)
{
if (route == newRoute) return;
route = newRoute;
streamId = NewStreamId();
lastFormatPacketUtc = DateTime.MinValue;
frameAccumulatorWritten = 0;
}
/// <summary>Reset per-lane counters and pick a new streamId. Called from
/// <see cref="AudioSender.Start"/> so the receiver sees a fresh session on each start.</summary>
public void ResetForStart()
{
streamId = NewStreamId();
audioSequence = 0;
pcmFrameId = 0;
formatSequence = 0;
frameAccumulatorWritten = 0;
lastFormatPacketUtc = DateTime.MinValue;
}
/// <summary>
/// Codec just changed. Rotates streamId (the receiver opens a fresh session at the new
/// format), rebuilds the Opus encoder if Opus is in play, and zeroes the accumulator so
/// any half-filled frame from the previous format doesn't leak into the new one.
/// </summary>
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameMs)
{
if (newCodec == AudioTransportCodec.Opus)
{
opusEncoder = new OpusEncoderState(opusFrameMs, opusBitrate);
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
}
streamId = NewStreamId();
lastFormatPacketUtc = DateTime.MinValue;
frameAccumulatorWritten = 0;
}
/// <summary>PCM frame size just changed. Rotates streamId so the receiver sees a fresh
/// session at the new packet cadence and resets the accumulator. No encoder rebuild —
/// Opus is unaffected by the PCM send-rate setting.</summary>
public void OnPcmFrameSizeChanged()
{
streamId = NewStreamId();
lastFormatPacketUtc = DateTime.MinValue;
frameAccumulatorWritten = 0;
}
// === hot path ===
public void OnMixedSamples(ReadOnlyMemory<float> stereoFloats)
{
var span = stereoFloats.Span;
if (span.IsEmpty) return;
// Whole-callback timing — captures encode plus kernel send for the SNAP's emitMs
// column. Skipped entirely when diagnostics are off so the audio thread doesn't pay
// two Stopwatch reads + a CAS loop per callback for a number nobody is going to log.
var diag = RemSound.Core.DiagnosticsGate.Enabled;
var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
EnsureFormatPacketSent();
switch (owner.Codec)
{
case AudioTransportCodec.Pcm:
ProcessPcm(span);
break;
case AudioTransportCodec.Opus:
ProcessOpus(span);
break;
}
if (diag) owner.RecordEmitTicks(System.Diagnostics.Stopwatch.GetTimestamp() - emitStart);
}
private void ProcessPcm(ReadOnlySpan<float> samples)
{
// Tight-latency mode: emit each delivered sample buffer as its own packet instead of
// accumulating to the PCM frame size. Saves up to (frame_size_ms / 2) of average
// accumulator delay. Variable packet size per call. Cap at 240 stereo-frames (5 ms =
// 1440 bytes) to stay under MaxAudioPayloadBytes=1454; in normal ASIO buffer sizes
// (64/128) this cap is never hit.
if (owner.IsTightLatencyEnabled)
{
const int MaxStereoSamplesPerPacket = 240 * MixChannels;
var pos = 0;
while (pos < samples.Length)
{
var chunk = Math.Min(MaxStereoSamplesPerPacket, samples.Length - pos);
EmitPcmFrame(samples.Slice(pos, chunk));
pos += chunk;
}
return;
}
var pcmFrameStereoSamples = owner.PcmFrameStereoSamples;
var idx = 0;
while (idx < samples.Length)
{
var spaceLeftForPcmFrame = pcmFrameStereoSamples - frameAccumulatorWritten;
var copy = Math.Min(spaceLeftForPcmFrame, samples.Length - idx);
samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten));
frameAccumulatorWritten += copy;
idx += copy;
if (frameAccumulatorWritten == pcmFrameStereoSamples)
{
EmitPcmFrame(frameAccumulator.AsSpan(0, pcmFrameStereoSamples));
frameAccumulatorWritten = 0;
}
}
}
private void ProcessOpus(ReadOnlySpan<float> samples)
{
var frameSamples = opusFrameStereoSamples;
var idx = 0;
while (idx < samples.Length)
{
var spaceLeft = frameSamples - frameAccumulatorWritten;
var copy = Math.Min(spaceLeft, samples.Length - idx);
samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten));
frameAccumulatorWritten += copy;
idx += copy;
if (frameAccumulatorWritten == frameSamples)
{
EmitOpusFrame(frameAccumulator.AsSpan(0, frameSamples));
frameAccumulatorWritten = 0;
}
}
}
private void EmitPcmFrame(ReadOnlySpan<float> stereoFloats)
{
var bytesOnWire = stereoFloats.Length * 3;
Span<byte> int24 = stackalloc byte[bytesOnWire];
if (owner.IsMuted)
{
int24.Clear();
}
else
{
PcmPack.FloatToInt24LE(stereoFloats, int24);
}
pcmFrameId++;
SendPcmPart(pcmFrameId, partIndex: 0, totalParts: 1, int24);
}
private void EmitOpusFrame(ReadOnlySpan<float> stereoFloats)
{
ReadOnlySpan<byte> opusBytes;
if (owner.IsMuted)
{
Span<float> silence = stackalloc float[opusFrameStereoSamples];
silence.Clear();
var muteLen = opusEncoder.Encode(silence);
opusBytes = opusEncoder.LastEncoded(muteLen);
}
else
{
var len = opusEncoder.Encode(stereoFloats);
if (len <= 0) return;
opusBytes = opusEncoder.LastEncoded(len);
}
SendAudio(opusBytes);
}
// === wire path ===
private void EnsureFormatPacketSent()
{
if (DateTime.UtcNow - lastFormatPacketUtc < TimeSpan.FromMilliseconds(FormatResendIntervalMs)) return;
lastFormatPacketUtc = DateTime.UtcNow;
// PCM FrameDurationMilliseconds: receiver only uses this for buffer sizing and
// diagnostics, not for decode. Round 2.5 ms up to ≥1 to keep the wire field integer.
var pcmFrameMs = owner.PcmFrameSamplesPerChannel * 1000 / MixSampleRate;
if (pcmFrameMs < 1) pcmFrameMs = 1;
var codec = owner.Codec;
var opusFrameMs = owner.OpusFrameMilliseconds;
// Pass this lane's current Route as the Lane field. In classic-mode senders this is
// Mixed and the receiver routes the session to its legacy mix bus; in BothIndependent
// senders this is WasapiLane or AsioLane and the receiver routes to the matching
// per-route IWaveProvider surface.
var format = codec == AudioTransportCodec.Opus
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameMs, route)
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, pcmFrameMs, route);
// Allocate the extended (36-byte) format payload — see RemPacket.FormatPayloadExtendedSize
// for the backward-compat contract. Old receivers parse the first 32 bytes and ignore
// the rest; new receivers read the Lane byte to decide which render route this stream
// belongs to. The Lane value carried here comes from the AudioFormatInfo constructed
// above, which currently always sets Mixed for the default lane; Stage 4 will set
// WasapiLane / AsioLane on the second lane in BothIndependent mode.
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadExtendedSize];
RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence);
RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format);
owner.SendToAll(packet);
}
private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan<byte> partBytes)
{
var headerSize = RemPacket.HeaderSize;
var subHeaderSize = RemPcmFrame.SubHeaderSize;
var totalLen = headerSize + subHeaderSize + partBytes.Length;
var dst = outboundScratch.AsSpan(0, totalLen);
RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence);
RemPcmFrame.WriteSubHeader(dst.Slice(headerSize, subHeaderSize), frameId, partIndex, totalParts);
partBytes.CopyTo(dst[(headerSize + subHeaderSize)..]);
owner.SendToAll(dst);
}
private void SendAudio(ReadOnlySpan<byte> opusBytes)
{
var totalLen = RemPacket.HeaderSize + opusBytes.Length;
var dst = outboundScratch.AsSpan(0, totalLen);
RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence);
opusBytes.CopyTo(dst[RemPacket.HeaderSize..]);
owner.SendToAll(dst);
}
}
@@ -0,0 +1,61 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
namespace RemSound.Sender;
/// <summary>
/// Pins a continuous silent render stream on a WASAPI device so the device stays "warm".
/// Some USB audio interfaces (Audient EVO8, RME, Focusrite, etc.) only fire WASAPI loopback
/// callbacks when something is actively rendering to the device — when the render endpoint
/// goes idle, the loopback path stops delivering frames until an application starts rendering
/// again. By pinning a zero-volume silent render stream on the same device we capture from,
/// loopback callbacks keep firing regardless of whether other apps are playing audio.
///
/// Same idea the legacy "silence.exe" used; folded into the sender so it's automatic, sized
/// to the device's actual mix format (no resampler stage), and ties to capture lifetime.
/// We do not own the MMDevice — AudioSender does — so we never dispose it.
/// </summary>
internal sealed class SilentRenderKeepAlive : IDisposable
{
private readonly WasapiOut output;
private readonly Action<string>? onDiagnostic;
public SilentRenderKeepAlive(MMDevice device, Action<string>? onDiagnostic = null)
{
this.onDiagnostic = onDiagnostic;
// Shared mode with a 50 ms buffer. Latency doesn't matter for silence; longer buffers
// mean fewer wakeups per second. Event sync still gives us efficient blocking turnover.
output = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 50);
output.Init(new SilenceProvider(output.OutputWaveFormat));
}
public void Start()
{
try
{
output.Play();
onDiagnostic?.Invoke($"silence keepalive started ({output.OutputWaveFormat.SampleRate} Hz, {output.OutputWaveFormat.Channels} ch)");
}
catch (Exception ex)
{
onDiagnostic?.Invoke($"silence keepalive start failed: {ex.GetType().Name}: {ex.Message}");
}
}
public void Dispose()
{
try { output.Stop(); } catch { /* ignore */ }
try { output.Dispose(); } catch { /* ignore */ }
}
private sealed class SilenceProvider(WaveFormat format) : IWaveProvider
{
public WaveFormat WaveFormat { get; } = format;
public int Read(byte[] buffer, int offset, int count)
{
Array.Clear(buffer, offset, count);
return count;
}
}
}