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
+24 -22
View File
@@ -248,20 +248,25 @@ public sealed class AudioReceiver : IDisposable
/// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary>
public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
/// render lane is holding samples (slow consumer holding back compaction, or the fast
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly
/// inflating latency on either lane.</summary>
public int TakeMaxFanOutCacheMs()
{
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec.
const int MixBytesPerSecond = 48000 * 2 * 4;
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0;
return bytes * 1000 / MixBytesPerSecond;
}
/// <summary>Cumulative milliseconds the network receive thread spent inside packet-
/// handler work since the last call (drain-on-read pattern). Diag log emits this as
/// recvMs per second — a direct read of how busy the network thread is. Item 2 of
/// RemSoundefficiency.md. Resets on read.</summary>
public double TakeReceiveWorkMs() =>
listener.TakeCumulativeOnPacketTicks() * 1000.0 / Stopwatch.Frequency;
/// <summary>Cumulative milliseconds the audio render threads spent inside
/// <see cref="PlayoutEngine.Read"/> / <see cref="PlayoutEngine.ReadForRoute"/>
/// (per-session mix + volume + limiter + pack-to-bytes) since the last call. Diag log
/// emits this as renderMs per second. Resets on read. 2026-05-22.</summary>
public double TakeRenderWorkMs() =>
playoutEngine.TakeCumulativeRenderTicks() * 1000.0 / Stopwatch.Frequency;
// TakeMaxFanOutCacheMs removed 2026-05-23. Originally measured the FanOutSource cache age
// between WASAPI and ASIO consumers in BothIndependent mode. The FanOut architecture was
// removed in May when each lane got its own filtered PlayoutEngine source — there is no
// shared cache to measure any more, so the method always returned 0. Removed alongside
// CompositeRenderBackend.TakeMaxFanOutCacheBytes and the fanCacheMs= diag column.
public string OutputDeviceName => multiOutput.ActiveDeviceSummary;
public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
@@ -412,11 +417,9 @@ public sealed class AudioReceiver : IDisposable
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
// DriftDropFrames / DriftRepeatFrames accessors removed 2026-05-23. They aggregated
// Phase-2 splice-corrector counters that the Phase-4 fixed-ratio resampler design never
// increments. Always-zero. Surfaced two unhelpful diag-log columns that are now gone.
/// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible
/// underrun events that trigger noise-burst concealment + fade-in. Separated from
/// <see cref="Underruns"/> (which conflates full and partial short reads) so the diag
@@ -429,9 +432,8 @@ public sealed class AudioReceiver : IDisposable
/// <summary>Live LP-filtered drift error of the primary active session (stereo frames,
/// signed). Negative = buffer running below target on average; positive = above.</summary>
public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames;
/// <summary>Live drift integrator accumulator of the primary session. Crosses ±1 to fire
/// a drop / repeat correction.</summary>
public double DriftAccumulator => playoutEngine.PrimaryDriftAccumulator;
// DriftAccumulator removed 2026-05-23. Phase-4 fixed-ratio resampler never sets an
// integrator value; always returned 0. Removed alongside the driftAcc= diag column.
/// <summary>Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.</summary>
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
@@ -89,12 +89,11 @@ internal sealed class CompositeRenderBackend : IRenderBackend
public bool IsRunning => started;
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads
/// per-lane sources directly with no intermediate cache. Kept on the surface so the
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero
/// rather than disappearing. Can be removed once we're confident the per-lane wiring
/// is the right shape long-term.</summary>
public int TakeMaxFanOutCacheBytes() => 0;
// TakeMaxFanOutCacheBytes removed 2026-05-23. The FanOutSource architecture was retired
// in mid-May when each lane got its own filtered PlayoutEngine source — there's no shared
// cache to measure any more, so the method always returned 0. The receiver-side
// pass-through (AudioReceiver.TakeMaxFanOutCacheMs) and the fanCacheMs= diag column were
// removed alongside it.
public string ActiveDeviceSummary
{
+34 -17
View File
@@ -38,6 +38,14 @@ internal sealed class MultiOutputPlayout : IRenderBackend
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
private readonly byte[] frameScratch = new byte[FrameBytes];
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
// Snapshot of the current output buffers, rebuilt only when SetOutputDevices changes the
// device set (rare — typically once per user action, minutes apart). The producer loop
// reads this with a single volatile load per tick instead of taking the gate and
// rebuilding `outputs.Values.Select(o => o.Buffer).ToArray()` on every 10 ms tick.
// Item 7 of RemSoundefficiency.md — eliminates ~100 array allocations per second on the
// receive side whenever any output device is ticked. Empty array is a singleton via
// Array.Empty<T>(), so the default value costs nothing.
private volatile BufferedWaveProvider[] outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
private CancellationTokenSource? cts;
private Task? produceTask;
@@ -95,6 +103,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
foreach (var o in outputs.Values) DisposeOutput(o);
outputs.Clear();
// Reset the snapshot the producer loop reads so any subsequent Start sees the
// empty state cleanly (not a stale snapshot from the previous session). Empty
// array is a cached singleton, no allocation.
outputBufferSnapshot = Array.Empty<BufferedWaveProvider>();
}
}
@@ -152,6 +164,14 @@ internal sealed class MultiOutputPlayout : IRenderBackend
try { device?.Dispose(); } catch { /* ignore */ }
}
}
// Refresh the snapshot the producer loop reads. Under the gate, so the producer
// sees a consistent view; once published via the volatile field, the loop reads
// it without taking the gate every tick. Empty case uses the cached singleton
// so it's allocation-free. Item 7 of RemSoundefficiency.md.
outputBufferSnapshot = outputs.Count == 0
? Array.Empty<BufferedWaveProvider>()
: outputs.Values.Select(o => o.Buffer).ToArray();
}
}
@@ -178,7 +198,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
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 — see matching change in
// MixingEngine.MixLoop for the rationale. WaitOne is allocation-free
// and semantically equivalent to WaitAny on a 1-element array.
if (ct.WaitHandle.WaitOne(sleepMs)) break;
continue;
}
@@ -188,22 +211,16 @@ internal sealed class MultiOutputPlayout : IRenderBackend
}
nextTickStopwatch += ticksPerFrame;
// Snapshot the buffers under the gate so we don't iterate a mid-mutation dict.
// Also skip the source.Read entirely when no outputs are ticked: in
// BothIndependent mode the source is a FanOutSource view shared with the ASIO
// lane, and pulling here when WASAPI has nothing ticked makes the FanOut
// consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the
// ASIO lane permanently reading from a cache 10 ms behind the source. That
// showed up in test logs as fanCacheMs sustained at 1214 ms with bufAvg=0,
// and audibly as an extra 10 ms baked into the ASIO lane's perceived latency.
// The gate-then-read order matters; the previous order (read first, then
// check outputs.Count) was the bug.
BufferedWaveProvider[] targets;
lock (gate)
{
if (outputs.Count == 0) continue;
targets = outputs.Values.Select(o => o.Buffer).ToArray();
}
// Read the pre-built snapshot. Volatile load — no lock, no allocation per
// tick. SetOutputDevices rebuilds the snapshot under the gate whenever the
// device set changes (rare event), so reads here see a consistent view.
// Skip the source.Read entirely when no outputs are ticked: in BothIndependent
// mode the source is shared between WASAPI and ASIO, and pulling here when
// WASAPI has nothing ticked would consume PlayoutEngine audio ahead of the
// ASIO consumer. Pre-2026-05-23 this whole block ran under `lock (gate)` and
// rebuilt the array on every tick — fixed as item 7 of RemSoundefficiency.md.
var targets = outputBufferSnapshot;
if (targets.Length == 0) continue;
var produced = source.Read(frameScratch, 0, FrameBytes);
if (produced <= 0) continue;
+14
View File
@@ -43,6 +43,15 @@ internal sealed class NetworkListener : IDisposable
public int TakeMaxInterPacketGapMs() =>
(int)(Interlocked.Exchange(ref maxInterPacketGapTicks, 0) * 1000 / Stopwatch.Frequency);
// CUMULATIVE on-packet work-time counter. Sister to maxOnPacketTicks (per-call max)
// — this is "total time the receive thread spent inside the packet handler since the
// last Take". The diag log samples this once a second and reports milliseconds-of-
// CPU-per-second for the receive thread, which is the per-thread CPU% reading from
// item 2 of RemSoundefficiency.md. Cumulative-sum + atomic-take pattern; no lock.
// 2026-05-22.
private long cumulativeOnPacketTicks;
public long TakeCumulativeOnPacketTicks() => Interlocked.Exchange(ref cumulativeOnPacketTicks, 0);
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
{
this.onPacket = onPacket;
@@ -89,6 +98,7 @@ internal sealed class NetworkListener : IDisposable
// spurious huge gap.
Interlocked.Exchange(ref lastReceiveTicks, 0);
Interlocked.Exchange(ref maxInterPacketGapTicks, 0);
Interlocked.Exchange(ref cumulativeOnPacketTicks, 0);
}
public void Dispose() => Stop();
@@ -142,6 +152,10 @@ internal sealed class NetworkListener : IDisposable
long current;
do { current = Volatile.Read(ref maxOnPacketTicks); }
while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current);
// And the cumulative counter — every call's elapsed adds in. Lets the
// diag log show "the receive thread spent X ms working this second"
// (item 2 of the efficiency analysis).
Interlocked.Add(ref cumulativeOnPacketTicks, elapsed);
}
else
{
+47 -34
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net;
using NAudio.Wave;
using RemSound.Core;
@@ -82,6 +83,15 @@ internal sealed class PlayoutEngine : IWaveProvider
private volatile bool asioLaneActive = true;
private volatile bool muted;
private volatile float volume = 1f;
// Cumulative render-thread work-time counter. Every Read / ReadForRoute call adds its
// elapsed Stopwatch ticks here; the diag log samples once a second to report renderMs
// — milliseconds of CPU the render thread(s) consumed in the last second. Per-thread
// CPU usage from item 2 of RemSoundefficiency.md. Gated implicitly by the diag log's
// own DiagnosticsGate check (the math is cheap enough that we don't gate the
// Stopwatch reads themselves — the alternative is a per-call branch every render
// callback, which costs more than the read does).
private long cumulativeRenderTicks;
public long TakeCumulativeRenderTicks() => Interlocked.Exchange(ref cumulativeRenderTicks, 0);
// 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI.
// Now mostly a safety-knob for the click-trim catastrophic path; in normal operation the
// Phase-2 drift corrector (in SessionPlayout) keeps the buffer near target so the trim
@@ -414,27 +424,9 @@ internal sealed class PlayoutEngine : IWaveProvider
}
}
/// <summary>Cumulative count of single-frame drops the Phase-2 drift corrector has applied.</summary>
public long AggregateDriftDropFrames
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal;
return total;
}
}
/// <summary>Cumulative count of single-frame repeats the Phase-2 drift corrector has applied.</summary>
public long AggregateDriftRepeatFrames
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal;
return total;
}
}
// AggregateDriftDropFrames + AggregateDriftRepeatFrames removed 2026-05-23 alongside the
// backing per-session fields. They surfaced two always-zero diag-log columns; both columns
// and accessors are gone.
/// <summary>Cumulative count of full-empty reads (framesRead == 0) across all sessions.
/// These are the audible underrun events that trigger noise-burst concealment + fade-in
@@ -480,17 +472,9 @@ internal sealed class PlayoutEngine : IWaveProvider
}
}
/// <summary>Live state — the drift integrator accumulator of the first active session.
/// Crosses ±1 to fire a single-frame drop / repeat. Useful for "is the corrector about
/// to fire?" diagnosis.</summary>
public double PrimaryDriftAccumulator
{
get
{
var snap = sessionsSnapshot;
return snap.Length > 0 ? snap[0].DriftAccumulator : 0.0;
}
}
// PrimaryDriftAccumulator removed 2026-05-23 alongside SessionPlayout.DriftAccumulator
// (which always returned 0 under the Phase-4 resampler design) and the driftAcc= diag
// log column.
/// <summary>Worst single-sample step seen out of the ring buffer since the last call.
/// Compared against the sender's pre-encode probe and the session's post-resampler
@@ -595,8 +579,20 @@ internal sealed class PlayoutEngine : IWaveProvider
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
/// long-standing cross-backend send/receive flow.
/// </summary>
public int Read(byte[] buffer, int offset, int count) =>
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
public int Read(byte[] buffer, int offset, int count)
{
// Per-thread CPU instrumentation. Gated on DiagnosticsGate so the Stopwatch
// reads cost nothing when logs are off; cumulativeRenderTicks is what the diag
// log samples for the renderMs column.
if (!RemSound.Core.DiagnosticsGate.Enabled)
{
return ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
}
var start = Stopwatch.GetTimestamp();
var produced = ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
Interlocked.Add(ref cumulativeRenderTicks, Stopwatch.GetTimestamp() - start);
return produced;
}
/// <summary>
/// Shared per-route render pull. Iterates the session snapshot, summing only those
@@ -608,6 +604,23 @@ internal sealed class PlayoutEngine : IWaveProvider
/// per-tick stats columns are still the user-visible source of truth.
/// </summary>
internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
{
// Per-thread CPU instrumentation — same shape as Read above. Gate on DiagnosticsGate
// so when logs are off this is a free pass-through.
long workStart = 0;
var diag = RemSound.Core.DiagnosticsGate.Enabled;
if (diag) workStart = Stopwatch.GetTimestamp();
try
{
return ReadForRouteInner(buffer, offset, count, route, mixBuf, sessionBuf, recordDiagnostics);
}
finally
{
if (diag) Interlocked.Add(ref cumulativeRenderTicks, Stopwatch.GetTimestamp() - workStart);
}
}
private int ReadForRouteInner(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
{
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
+11 -19
View File
@@ -163,14 +163,11 @@ internal sealed class SessionPlayout : IDisposable
// we don't realloc on the hot path.
private float[] resamplerInputScratch = new float[2048];
// Retained for backward compatibility with the diagnostic surface — the diag log line
// still emits driftDrop / driftRep counters and the DriftAccumulator / FilteredError
// accessors. In the Phase-4 design these are all just informational metrics that stay
// at zero / track the same buffer-vs-target offset, but old log parsers don't break.
// Explicit zero init so the compiler doesn't flag them as never-assigned when the
// Phase-4 design no longer increments them anywhere.
private long driftDropFramesTotal = 0;
private long driftRepeatFramesTotal = 0;
// driftDropFramesTotal + driftRepeatFramesTotal fields removed 2026-05-23. They were
// Phase-2/3 splice-corrector counters that the Phase-4 fixed-ratio resampler design
// never incremented; they sat at zero and fed dead diag-log columns that have also been
// removed. The current corrector's "where is the buffer" signal is filteredErrorFrames
// (below) — that one IS still active and IS still surfaced via FilteredDriftErrorFrames.
// Live state for the diag log — the current buffer-level offset from target, low-pass
// filtered. Lets the diag line continue to surface "where the buffer is sitting".
// Updated each Read; no longer drives any correction logic itself.
@@ -204,12 +201,9 @@ internal sealed class SessionPlayout : IDisposable
private const double DriftFilterTimeConstantSec = 2.0;
// Number of stereo frames each side of a splice point that get blended when a drop or
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
// Public accessors for the diag log. Drop / repeat counters are retained for the diag
// surface (the Phase-4 resampler doesn't increment them, so they stay flat at the
// last value from any pre-Phase-4 fallback path — informationally that's "the splice
// path didn't fire", which is what we want to see now).
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
// DriftDropFramesTotal / DriftRepeatFramesTotal accessors removed 2026-05-23 alongside
// their backing fields — they only ever surfaced two always-zero columns in the diag log,
// and the columns have been removed too.
/// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the
/// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running
/// 200 ppm faster than receiver; 0.9998 = 200 ppm slower.</summary>
@@ -245,11 +239,9 @@ internal sealed class SessionPlayout : IDisposable
/// running above target on average (sender clock faster); negative = buffer below
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
public double FilteredDriftErrorFrames => filteredErrorFrames;
/// <summary>Legacy diag accessor — the Phase-2 / Phase-3 integrator accumulator is no
/// longer used in the Phase-4 resampler design. Always returns 0. Kept on the surface
/// so MainForm's existing diag log line still compiles; can be removed once the diag
/// columns are pruned.</summary>
public double DriftAccumulator => 0.0;
// DriftAccumulator accessor removed 2026-05-23. The Phase-4 fixed-ratio resampler design
// never sets an integrator accumulator value; the property always returned 0. Removed
// along with the driftAcc= diag column.
public IPEndPoint Endpoint { get; }
/// <summary>The stream ID this session was opened for. Sessions are keyed by