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