Bump to v2.2.0: Opus native binding, efficiency tidy-up, diag self-meter

Single biggest change: added the Concentus.Native NuGet package. Concentus
2.0+ auto-detects native libopus at runtime and routes encode calls
through it; encoder state lives on the C side and is reused across calls
rather than `new`ing ~15 working buffers per call (Concentus issue #22,
open since 2018). Measured on the desktop test at 15:36:55 — Opus 10 ms
allocation rate dropped from 4,625 KB/s to 108 KB/s, a 97.7% reduction.
Process CPU dropped from 4.7% to 1.6% in the same config. Audio is bit-
for-bit identical (it's literally the same encoder, just better
packaged). `OpusEncoderState.cs` itself unchanged on the call site.

Diagnostic / measurement layer (gated on Enable-logs, zero cost when off):
* ProcessSelfMeter: CPU%, managed heap MB, working set MB, allocation
  rate per second, GC counts per generation
* Per-thread work-time counters: captureMs / sendMs / recvMs / renderMs
  expressed as milliseconds of CPU consumed by each audio thread per
  second
* Inter-packet arrival gap measured at the user-space UDP socket
  (rxNetGapMs) — pinpoints whether arrival jitter is in the network or
  our own dispatch path

Small efficiency wins (each one was small but cumulative):
* deviceRefreshTimer interval 1s -> 3s (item 4)
* WaitHandle array allocations eliminated in MixingEngine.MixLoop and
  MultiOutputPlayout.ProduceLoop (item 6)
* MultiOutputPlayout caches its output-buffer snapshot and only rebuilds
  on SetOutputDevices, instead of rebuilding every 10 ms (item 7)
* HeartbeatService reuses an outbound ping byte[] instead of allocating
  per send (item 14)
* PeerDiscoveryService caches broadcast addresses and invalidates on
  Windows' NetworkChange event instead of walking all NICs every 1.5 s
  (item 16)

Legacy / dead-code removal:
* KeepAlive packet's implementation (struct, enums, writer, reader, size
  constant) — all dead since HeartbeatService landed 2026-05-06. Kept
  the RemPacketType.KeepAlive enum value and silent-drop dispatch for
  wire compat with any pre-2026-05-06 build still in the wild (item 30)
* driftDropFramesTotal / driftRepeatFramesTotal fields and accessors —
  Phase-2 splice corrector relics, never incremented since Phase-4
  resampler design landed; backed five always-zero diag log columns
  (items 34 + 35)
* DriftAccumulator (always returned 0) — same shape, removed alongside
  the driftAcc= column (item 35)
* TakeMaxFanOutCacheBytes / Ms + fanCacheMs column — FanOutSource was
  retired in May (item 36)

Project documentation:
* RemSoundefficiency.md added as the canonical record of the efficiency
  analysis, every item's status, and the measured wins from this round
* Honest item-by-item review of the original 50-item list — several
  items I had sized optimistically in the original analysis turned out
  to be already-done (item 20), already-optimal (item 22), or below
  the meter floor (items 9, 15, 17, 25). Recorded so future passes
  don't re-investigate.

Wire format and audio pipeline unchanged from v1.5 onward — v1.5 through
v2.2 peers interoperate.
This commit is contained in:
Ednunp
2026-05-23 15:56:03 +01:00
parent 79b28b6c02
commit 6d6d6897e4
22 changed files with 847 additions and 232 deletions
+18 -1
View File
@@ -68,6 +68,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// is via Interlocked which provides its own memory barriers (no need for volatile).
private long lastCallbackTimestamp;
private int maxCallbackGapMs;
// Cumulative ticks the ASIO capture callback spent doing per-callback work. The diag
// log samples this once a second; per-thread CPU instrumentation from item 2 of
// RemSoundefficiency.md. Gated by DiagnosticsGate.Enabled so logs-off costs nothing.
// 2026-05-22.
private long cumulativeCaptureTicks;
public AsioCaptureBackend(string driverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
{
@@ -90,6 +95,7 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public long TakeCumulativeCaptureTicks() => Interlocked.Exchange(ref cumulativeCaptureTicks, 0);
public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
@@ -238,9 +244,12 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// 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 diag = RemSound.Core.DiagnosticsGate.Enabled;
long workStart = 0;
if (diag)
{
var now = Stopwatch.GetTimestamp();
workStart = now;
var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now);
if (prev != 0)
{
@@ -312,6 +321,14 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
}
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, stereoFloats));
// Capture-thread CPU instrumentation (item 2 of RemSoundefficiency.md). Records
// the time the WHOLE callback spent — including the synchronous downstream
// OnMixedSamples invocation, because that runs on this same thread and counts
// toward "the capture thread's per-second CPU load". Send-side encode work is
// ALSO tallied separately via AudioSender.cumulativeEmitTicks for a more detailed
// breakdown; capture vs send columns let us see "is the bottleneck the buffer
// copy + mix loop, or is it encode + sendto".
if (diag) Interlocked.Add(ref cumulativeCaptureTicks, Stopwatch.GetTimestamp() - workStart);
}
/// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
+20
View File
@@ -110,11 +110,17 @@ public sealed class AudioSender : IDisposable
// Both are reset on each Take() so the SNAP gets per-second peaks.
private long maxEmitTicks;
private long maxSendCallTicks;
// Cumulative counters mirroring the max ones above. The diag log samples these once
// a second to report "milliseconds-of-CPU-per-second" for the send-side audio thread —
// i.e. per-thread CPU usage from item 2 of RemSoundefficiency.md. Drain-on-read so the
// value reads naturally as "this last second's load". 2026-05-22.
private long cumulativeEmitTicks;
internal void RecordEmitTicks(long ticks)
{
long current;
do { current = Volatile.Read(ref maxEmitTicks); }
while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current);
Interlocked.Add(ref cumulativeEmitTicks, ticks);
}
internal void RecordSendCallTicks(long ticks)
{
@@ -124,6 +130,20 @@ public sealed class AudioSender : IDisposable
}
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
/// <summary>Cumulative milliseconds the send-side audio thread spent inside
/// <see cref="SenderLane.OnMixedSamples"/> (encode + sendto + per-packet bookkeeping)
/// since the last call. Resets on read. Diag log emits this as sendMs per second
/// — direct measurement of "how busy is the send thread". 2026-05-22.</summary>
public double TakeSendWorkMs() =>
Interlocked.Exchange(ref cumulativeEmitTicks, 0) * 1000.0 / Stopwatch.Frequency;
/// <summary>Cumulative milliseconds the capture-side threads spent doing per-callback
/// work (ASIO buffer copy + mix loop; WASAPI capture body; MixingEngine.MixLoop per
/// tick) since the last call. Resets on read. Diag log emits this as captureMs per
/// second. Sister metric to <see cref="TakeSendWorkMs"/> — the two together split
/// "what is the sender side spending its CPU on". 2026-05-22.</summary>
public double TakeCaptureWorkMs() =>
engine.TakeCumulativeCaptureTicks() * 1000.0 / Stopwatch.Frequency;
// Pre-encode discontinuity probe — per-lane (each <see cref="SenderLane"/> owns its own).
// The aggregate accessor returns the max across both lanes since the last read; per-lane
@@ -145,6 +145,17 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
return w > a ? w : a;
}
/// <summary>Sum of cumulative capture-callback ticks across both inner backends since
/// the last call. The diag log uses this for captureMs — the per-thread CPU footprint
/// of all capture-side work (item 2 of RemSoundefficiency.md). Drains BOTH so neither
/// accumulates forever; in BothIndependent the user wants both lanes' load combined.</summary>
public long TakeCumulativeCaptureTicks()
{
var w = wasapi?.TakeCumulativeCaptureTicks() ?? 0L;
var a = asio?.TakeCumulativeCaptureTicks() ?? 0L;
return w + a;
}
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
lock (gate)
+8
View File
@@ -68,6 +68,14 @@ internal interface ICaptureBackend : IDisposable
/// Resets on read. Backends that can't sensibly expose raw samples return 0.</summary>
float TakeMaxRawCaptureStepWithinBuffer();
/// <summary>Cumulative Stopwatch ticks the backend's capture callbacks spent doing
/// per-callback work (buffer copy, mix, clamp — everything BEFORE the encode handoff)
/// since the last call. Diag log samples this once a second to report captureMs
/// per second — i.e. how busy the capture thread is. Resets on read. Backends that
/// don't track this return 0. Per-thread CPU instrumentation from item 2 of
/// RemSoundefficiency.md. 2026-05-22.</summary>
long TakeCumulativeCaptureTicks();
void Start(IReadOnlyList<CaptureSourceSpec> specs);
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
+26 -2
View File
@@ -120,6 +120,13 @@ internal sealed class MixingEngine : ICaptureBackend
public float TakeMaxRawCaptureStep() => 0f;
public float TakeMaxRawCaptureStepCrossBuffer() => 0f;
public float TakeMaxRawCaptureStepWithinBuffer() => 0f;
public long TakeCumulativeCaptureTicks() => Interlocked.Exchange(ref cumulativeMixLoopTicks, 0);
// Cumulative ticks the mix-loop task spent doing per-tick work (everything between
// wake-up and the next sleep). Reported as captureMs on the diag log so the user sees
// the WASAPI mix-engine's CPU footprint when it's the active capture path.
// 2026-05-22 (item 2 of RemSoundefficiency.md).
private long cumulativeMixLoopTicks;
/// <summary>
/// Starts the mix loop with the given initial source set. If already running, the existing
@@ -316,7 +323,13 @@ internal sealed class MixingEngine : ICaptureBackend
if (nextTickStopwatch > now)
{
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50);
if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break;
// Item 6 of RemSoundefficiency.md: use WaitHandle.WaitOne directly
// instead of WaitAny(new[] { ct.WaitHandle }, ...). Identical semantics
// (returns true on signal / false on timeout — i.e. the same as WaitAny
// returning index 0 for our single-element case), but no per-call array
// allocation. At this loop's ~100 Hz cadence the old line was producing
// ~100 small array allocations per second; the new one produces none.
if (ct.WaitHandle.WaitOne(sleepMs)) break;
continue;
}
@@ -330,8 +343,18 @@ internal sealed class MixingEngine : ICaptureBackend
var localMixer = mixer;
if (localMixer is null) continue;
// Per-thread CPU instrumentation. Capture-the-work-tick at the start of
// the active body so we can report this loop's CPU footprint via the
// captureMs column on the diag log.
var diag = RemSound.Core.DiagnosticsGate.Enabled;
var workStart = diag ? Stopwatch.GetTimestamp() : 0L;
var read = localMixer.Read(mixScratch, 0, MixSamplesPerTick);
if (read <= 0) continue;
if (read <= 0)
{
if (diag) Interlocked.Add(ref cumulativeMixLoopTicks, Stopwatch.GetTimestamp() - workStart);
continue;
}
// Hard-clamp mixed sum to [-1, 1] to prevent encoder clipping when multiple loud
// sources sum past unity. Counts clipped samples for diagnostics.
@@ -346,6 +369,7 @@ internal sealed class MixingEngine : ICaptureBackend
Interlocked.Increment(ref mixTickCount);
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, read));
if (diag) Interlocked.Add(ref cumulativeMixLoopTicks, Stopwatch.GetTimestamp() - workStart);
}
catch (OperationCanceledException)
{
+13 -9
View File
@@ -7,6 +7,14 @@ namespace RemSound.Sender;
/// 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.
///
/// 2026-05-23 — switched from the <c>Encode(ReadOnlySpan&lt;short&gt;...)</c> overload to the
/// float overload after the first allocation-rate measurement (Part C, item 51 of
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round trip
/// inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and lets us
/// drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does its own
/// out-of-range clipping per its XML docs. Same encoder configuration, same bitrate, same
/// frame size, same audio output bit-for-bit.
/// </summary>
internal sealed class OpusEncoderState : IDisposable
{
@@ -14,7 +22,6 @@ internal sealed class OpusEncoderState : IDisposable
private const int PacketBufferBytes = 4000;
private readonly IOpusEncoder encoder;
private readonly short[] pcm16Scratch;
private readonly byte[] packetScratch = new byte[PacketBufferBytes];
public int FrameMilliseconds { get; }
@@ -27,7 +34,6 @@ internal sealed class OpusEncoderState : IDisposable
// 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;
@@ -55,13 +61,11 @@ internal sealed class OpusEncoderState : IDisposable
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);
// Direct float→Opus path. Concentus' float-input Encode overload normalises and clips
// out-of-range samples internally (per its XML doc) — so the Math.Clamp loop we used
// to run on every sample before calling the int16 overload is no longer needed. That
// also lets us delete the pcm16Scratch field entirely.
return encoder.Encode(stereoFloats, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length);
}
public ReadOnlySpan<byte> LastEncoded(int length) => packetScratch.AsSpan(0, length);
@@ -111,6 +111,12 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public long TakeCumulativeCaptureTicks() => Interlocked.Exchange(ref cumulativeCaptureTicks, 0);
// Per-thread CPU instrumentation. Cumulative ticks the WASAPI capture callback spent
// in per-callback work; the diag log samples this once a second to report captureMs.
// See item 2 of RemSoundefficiency.md. 2026-05-22.
private long cumulativeCaptureTicks;
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{
@@ -247,6 +253,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
if (e.BytesRecorded <= 0) return;
var diag = RemSound.Core.DiagnosticsGate.Enabled;
var workStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
try
{
// 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start).
@@ -356,6 +364,16 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
lastError = ex.Message;
onDiagnostic?.Invoke($"push-wasapi: callback error: {ex.GetType().Name}: {ex.Message}");
}
finally
{
// Capture-thread CPU instrumentation. See AsioCaptureBackend for matching
// pattern. Wrapped in `finally` so the count is honest even when the body
// throws (the catch above is the normal path).
if (diag)
{
Interlocked.Add(ref cumulativeCaptureTicks, System.Diagnostics.Stopwatch.GetTimestamp() - workStart);
}
}
}
private void OnRecordingStopped(object? sender, StoppedEventArgs e)