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
+50 -7
View File
@@ -20,14 +20,57 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v2.1
RemSound v2.2
Automatic router setup for internet streaming, a small
notice before background updates install, a "lock this
profile" option for users who don't want close prompts,
and a fix for the "no sound after the laptop wakes up"
problem. No wire-format or audio-pipeline changes
v1.5 through v2.1 peers interoperate.
A maintenance release that makes RemSound use less of
your computer's CPU and memory, especially when sending
with the Opus codec. No new features to learn, no
settings have changed, and audio sounds exactly the same.
Wire format and audio pipeline are unchanged every
version from v1.5 to v2.2 still talks to every other
version cleanly.
What's lighter on your computer:
* Opus sending uses much less memory. RemSound's
Opus encoder used to put quite a lot of work on
Windows' memory manager about 4 megabytes per
second of "throwaway" memory churn while sending
Opus audio. v2.2 ships a native build of the same
encoder that does its work in a tighter, faster way.
The audio you hear is identical (it really is the
same encoder, just packaged better); the memory
churn drops by about 97 per cent. On laptops you
should see less background CPU when streaming Opus,
and longer sessions are less likely to see brief
pauses while Windows tidies up memory.
* Smaller all-round efficiency tidy-up. A handful of
small fixes RemSound checks the audio-device list
less often, reuses some small bits of memory it
used to make fresh each time, and skips some
paperwork on the receive side when there's nothing
to do. Each one is small on its own; together they
cut RemSound's everyday memory churn modestly.
* Removed some old leftover code that was retired
months ago but still lived on as zero-valued
columns in the diagnostic log. Same behaviour,
cleaner files.
For people who use the diagnostic logs:
* Several new columns. "cpu" shows how much of one
CPU core RemSound just used. "memMB" and "wsMB" are
its memory footprint. "allocKBps" is the per-second
memory-churn rate. "captureMs / sendMs / recvMs /
renderMs" show how busy each of the four audio
threads is. All of this only writes to the log when
Enable logs is ticked; with logs off it costs
nothing.
* "fanCacheMs", "driftDrop", "driftRep" and "driftAcc"
columns have been removed they were always zero
after the playback engine changed in May.
No bug fixes in v2.2 specifically everything carried
over from v2.1's UPnP, lock-profile, wake-from-sleep
and hibernate fixes is still in place.
What's new:
* Automatic router port opening (UPnP). RemSound can
+55 -27
View File
@@ -359,7 +359,15 @@ public sealed class MainForm : Form
// threads (which run on separate MMCSS-boosted threads). The listbox itself is only
// rebuilt when the (id, name) signature actually changes, so NVDA isn't pestered on every
// tick — only when a device truly came or went.
private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 1000 };
// 3 s interval (was 1 s pre-2026-05-23). Item 4 of RemSoundefficiency.md — when an ASIO
// driver is configured, each tick calls AsioDeviceProbe.ProbeDriverInfo which briefly
// opens the driver to enumerate channel names. That's measurable CPU (~1.6 % of one core
// in the test we ran) for a check that only matters when a USB audio device is hot-
// plugged. 3 s is the value the existing RefreshAudioDeviceLists docstring already
// claimed; the actual timer just hadn't been bumped to match. Hot-plug latency goes from
// up-to-1 s to up-to-3 s, which is fine for the device-list-refresh use case (nobody
// pulls a device and stares at the menu in the next second waiting for it to drop off).
private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 3000 };
// Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler
// wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts
// (NVDA users typically press a few keys in quick succession to scan through items),
@@ -386,8 +394,9 @@ public sealed class MainForm : Form
// subtracting from the current value gives "how many fired this second". Only read when
// DiagnosticsGate.Enabled (i.e. logs on); otherwise SnapshotLogIfDue early-outs before
// touching these.
private long prevDiagDriftDrops;
private long prevDiagDriftReps;
// prevDiagDriftDrops / prevDiagDriftReps removed 2026-05-23. Drift drop/repeat counters
// were dead since the Phase-4 fixed-ratio resampler design (always zero); diag columns
// are gone too.
private long prevDiagConceal;
private long prevDiagShortRead;
private long prevDiagTrimFires;
@@ -412,6 +421,10 @@ public sealed class MainForm : Form
private int prevDiagGc0Count;
private int prevDiagGc1Count;
private int prevDiagGc2Count;
// Per-process CPU% / memory / allocation / GC meter — drained once per second by the
// diag emitter. New 2026-05-22, item 1 + 3 of RemSoundefficiency.md. Carries no cost
// when logs are off because the diag emitter is itself gated.
private readonly ProcessSelfMeter processSelfMeter = new();
// Profile system (2026-05-02). The active profile (if any) was selected at app start and
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
@@ -4229,28 +4242,19 @@ public sealed class MainForm : Form
// >0 = real anomalous samples in RemSound's output. ~0 = clean output.
// sampleStepMax = raw peak step magnitude (false-positive prone on bright
// music; informational only).
var driftDrops = receiver.DriftDropFrames;
var driftReps = receiver.DriftRepeatFrames;
// Per-second deltas for the same counters — easier to read at a glance than
// ever-growing cumulative numbers. driftDropΔ + driftRepΔ tell us how fast
// the corrector is firing right now. concealΔ tells us how many real underruns
// fired this second (audible). shortReadΔ tracks the now-silent partial-read
// events for clock-phase diagnostics. Trim fires + delta gives us "is the
// click-trim safety net firing".
// driftDrops / driftReps / driftAccumulator readings removed 2026-05-23 along
// with their dead accessors. The Phase-4 fixed-ratio resampler design never
// increments those counters; the columns were always zero. filteredErrorFrames
// below is the still-useful "where the buffer is sitting on average" signal —
// computed every Read by the active LP filter.
var concealNow = receiver.ConcealmentFires;
var shortReadNow = receiver.ShortReadFires;
var driftDropDelta = driftDrops - prevDiagDriftDrops; prevDiagDriftDrops = driftDrops;
var driftRepDelta = driftReps - prevDiagDriftReps; prevDiagDriftReps = driftReps;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state (not deltas) — current LP-filtered drift error and accumulator
// value. Both let us see "where the corrector thinks the buffer is" between
// explicit drop/repeat events. filtErr negative = buffer running below target
// on average; positive = above. driftAcc near 0 = corrector idle; near ±1 =
// about to fire.
// Live state — current LP-filtered drift error. Negative = buffer running below
// target on average; positive = above.
var filteredErrorFrames = receiver.FilteredDriftErrorFrames;
var driftAccumulator = receiver.DriftAccumulator;
// 2026-05-11 added timing-split metrics:
// emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send)
// sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only)
@@ -4271,11 +4275,9 @@ public sealed class MainForm : Form
// thread, kernel batching, GC pause — rather than the sender stalling or
// RemSound's own decode/dispatch chain. 2026-05-21.
var rxNetGapMs = receiver.TakeMaxInterPacketGapMs();
// fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single
// active render lane should sit at ~0; non-zero says the FanOut is sitting on
// samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// fanCacheMs reading + column removed 2026-05-23. The FanOutSource was retired
// mid-May (each lane reads its own filtered PlayoutEngine source directly); the
// measurement always returned 0 and surfaced an unhelpful diag column.
// GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the
// previous tick gives the per-second collection count per generation. Gen-0
// collections are cheap (microseconds); Gen-1 takes longer; Gen-2 / LOH can
@@ -4288,6 +4290,21 @@ public sealed class MainForm : Form
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
// Process-wide self-meter (item 1 + 3 of RemSoundefficiency.md). Single
// snapshot covers CPU%, managed heap MB, working set MB, allocation rate.
var selfMeter = processSelfMeter.Take();
// Per-thread work-time (item 2 of RemSoundefficiency.md). Each is the
// milliseconds of CPU that thread (or thread group) consumed in the last
// second; in a clean steady-state session they should all be small. The
// four categories follow the request: capture, send, receive, render.
// captureMs covers ASIO + WASAPI capture bodies and the MixingEngine tick;
// sendMs is encode + sendto on the audio thread; recvMs is the network
// thread's packet handler; renderMs is the audio render thread's mix +
// limiter + pack work.
var captureMs = sender.TakeCaptureWorkMs();
var sendMs = sender.TakeSendWorkMs();
var recvMs = receiver.TakeReceiveWorkMs();
var renderMs = receiver.TakeRenderWorkMs();
// Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero =
@@ -4353,12 +4370,13 @@ public sealed class MainForm : Form
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} fanCacheMs={fanCacheMs} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"cpu={selfMeter.CpuPercentOneCore:0.0}% memMB={selfMeter.ManagedHeapMb:0.0} wsMB={selfMeter.WorkingSetMb:0.0} allocKBps={selfMeter.AllocatedKbPerSecond:0.0} " +
$"captureMs={captureMs:0.0} sendMs={sendMs:0.0} recvMs={recvMs:0.0} renderMs={renderMs:0.0} " +
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
$"filtErr={filteredErrorFrames:0.0}f " +
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
@@ -4412,6 +4430,14 @@ public sealed class MainForm : Form
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
// Process self-meter + per-thread work-time on the send-only side too.
// captureMs covers the WASAPI / ASIO callback bodies; sendMs is the encode
// + sendto work; recvMs / renderMs stay at 0 (no playback on this machine
// by definition for the send-only branch). See item 1, 2, 3 of
// RemSoundefficiency.md.
var selfMeter = processSelfMeter.Take();
var captureMs = sender.TakeCaptureWorkMs();
var sendMs = sender.TakeSendWorkMs();
logFile.Event(
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " +
@@ -4419,6 +4445,8 @@ public sealed class MainForm : Form
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"cpu={selfMeter.CpuPercentOneCore:0.0}% memMB={selfMeter.ManagedHeapMb:0.0} wsMB={selfMeter.WorkingSetMb:0.0} allocKBps={selfMeter.AllocatedKbPerSecond:0.0} " +
$"captureMs={captureMs:0.0} sendMs={sendMs:0.0} " +
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
}
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Diagnostics;
namespace RemSound.App;
/// <summary>
/// Process-wide CPU / memory / allocation / GC meter. Sampled once a second by the diag-
/// log emitter (gated behind <see cref="RemSound.Core.DiagnosticsGate"/>) so we get a
/// continuous baseline of "how heavy is RemSound right now?" alongside every audio-pipeline
/// stat we already track. Item 1 + 3 of <c>RemSoundefficiency.md</c> — the "build the
/// measurement layer first" finding.
///
/// All readings are deltas since the previous <see cref="Take"/> call, so consumers see
/// "this second's CPU" not "since process start". The first call returns zeros for the
/// delta-based fields (no previous sample to compare to) and the steady-state fields
/// already meaningful at that point (memory, working set).
///
/// Threading: <see cref="Take"/> is called from the App's status-tick handler on the UI
/// thread. Snapshot fields are mutated by that same single thread; no locks needed.
/// </summary>
internal sealed class ProcessSelfMeter
{
private TimeSpan prevTotalCpu;
private long prevAllocBytes;
private DateTime prevSampleUtc;
// Cached Process handle. Process.GetCurrentProcess() allocates a new object each call
// and the underlying handle is the same for the process lifetime — caching it saves an
// allocation per Take.
private readonly Process selfProcess = Process.GetCurrentProcess();
/// <summary>One-second meter reading.</summary>
/// <param name="CpuPercentOneCore">CPU used in the last sample interval as a percentage
/// of one CPU core (so a fully-loaded core reads 100, two cores read 200, etc.). Zero
/// on the first call (no previous sample). Includes time across all of the app's
/// threads — kernel + user.</param>
/// <param name="ManagedHeapMb">Managed heap occupancy in megabytes right now. The
/// .NET garbage collector's view of "stuff RemSound is holding"; doesn't include
/// unmanaged buffers held via NAudio / Concentus / etc.</param>
/// <param name="WorkingSetMb">Working set in megabytes — what Task Manager shows for
/// the process. Includes managed heap, unmanaged buffers, and pages currently resident.</param>
/// <param name="AllocatedKbPerSecond">Bytes allocated to the managed heap in this
/// interval, divided by 1024 and normalised to per-second. A steady-state RemSound
/// should run in the single-digit-kilobytes-per-second range; sustained megabytes is
/// a leak somewhere in the hot path.</param>
/// <param name="ElapsedMs">Wall-clock milliseconds since the previous sample, so the
/// caller can sanity-check the delta calculation. Roughly 1000 in steady state.</param>
public readonly record struct Snapshot(
double CpuPercentOneCore,
double ManagedHeapMb,
double WorkingSetMb,
double AllocatedKbPerSecond,
double ElapsedMs);
public Snapshot Take()
{
var now = DateTime.UtcNow;
// TotalProcessorTime is "user + kernel time across every thread", refreshed lazily.
// Refresh() asks the OS for the current value; without it the property is sticky
// from the first access. Done explicitly so the math below is meaningful.
selfProcess.Refresh();
var totalCpu = selfProcess.TotalProcessorTime;
var workingSet = selfProcess.WorkingSet64;
// GC.GetTotalAllocatedBytes(precise: true) is the official .NET counter for
// "total bytes allocated across all threads since process start". precise: true
// forces a fast cross-thread sync; the cost is a thread-list walk (cheap). We
// need precise=true because the audio threads allocate too and we want their
// contribution included.
var totalAllocBytes = GC.GetTotalAllocatedBytes(precise: true);
// GetTotalMemory(false) doesn't trigger a collection; we just want the current
// size of the heap as the GC knows it.
var managedHeapBytes = GC.GetTotalMemory(false);
double cpuPercent = 0;
double allocKbps = 0;
double elapsedMs = 0;
if (prevSampleUtc != default)
{
elapsedMs = (now - prevSampleUtc).TotalMilliseconds;
if (elapsedMs > 0)
{
var cpuDeltaMs = (totalCpu - prevTotalCpu).TotalMilliseconds;
cpuPercent = cpuDeltaMs / elapsedMs * 100.0;
var allocDelta = totalAllocBytes - prevAllocBytes;
allocKbps = allocDelta / 1024.0 * (1000.0 / elapsedMs);
}
}
prevTotalCpu = totalCpu;
prevAllocBytes = totalAllocBytes;
prevSampleUtc = now;
return new Snapshot(
CpuPercentOneCore: cpuPercent,
ManagedHeapMb: managedHeapBytes / (1024.0 * 1024.0),
WorkingSetMb: workingSet / (1024.0 * 1024.0),
AllocatedKbPerSecond: allocKbps,
ElapsedMs: elapsedMs);
}
}
+17 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>2.1.0</Version>
<Version>2.2.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -38,6 +38,22 @@
manual port forwarding. Cross-protocol — picks whichever the router speaks. Used
under the AppConfig.UpnpEnabled toggle, off by default. -->
<PackageReference Include="Mono.Nat" Version="3.0.4" />
<!-- Native libopus binaries that Concentus 2.0+ auto-detects at runtime and routes
encode/decode calls through. Without this package Concentus uses its pure-managed
C# fallback, which `new`s ~15 working buffers per encode call (issue #22 on the
Concentus repo, open since 2018) and produces ~4.5 MB/s of GC pressure per
encoding lane at 10 ms frames. Switching to native via this package keeps the
encoder state allocated once (C-side) and reuses it across calls. Same encoder
settings, bit-for-bit identical audio output. Installed at the top-level project
(per the package's install guidance) so `dotnet publish` correctly trims native
binaries for irrelevant RIDs from the release output. 2026-05-23. -->
<PackageReference Include="Concentus.Native" Version="1.5.2" />
<!-- Explicit pin for the transitive Concentus.Native.NetCore. The parent package
declares a minimum version of 1.5.1, but 1.5.1 was never published to nuget.org —
only 1.5.2 was. NuGet still resolves correctly (it picks 1.5.2) but emits NU1603
as a warning, which our TreatWarningsAsErrors policy promotes to an error.
Pinning explicitly skips that warning and is self-documenting. -->
<PackageReference Include="Concentus.Native.NetCore" Version="1.5.2" />
</ItemGroup>
<ItemGroup>