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>
+15 -7
View File
@@ -63,6 +63,12 @@ public sealed class HeartbeatService : IDisposable
private CancellationTokenSource? cts;
private Task? sendTask;
private uint sequence;
// Reusable outbound packet buffer for the once-per-second ping fan-out. Pre-2026-05-23
// SendPings did `var bytes = packet.ToArray()` on every call (a 21-byte allocation +
// GC header). Trivial in absolute terms — ~3 small allocations/sec/peer — but the
// SendPings thread has only one writer so a single reused array is straightforward and
// makes the pattern explicit. Item 14 of RemSoundefficiency.md.
private readonly byte[] outboundPingBuffer = new byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
/// <summary>
/// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but
@@ -253,20 +259,22 @@ public sealed class HeartbeatService : IDisposable
foreach (var p in targets) p.FirstPingSentUtc ??= nowUtc;
}
// Build packet. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable
// in any future stream-aware filter; sequence increments locally per send.
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize];
// Build packet directly into the reusable outboundPingBuffer instead of stack-
// allocating + ToArray(). Same wire format, no per-call allocation. SendPings runs
// exclusively on the timer task — single writer — so no lock needed around the
// reuse. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable in any
// future stream-aware filter; sequence increments locally per send.
var seq = Interlocked.Increment(ref sequence);
var tickMs = monotonic.ElapsedMilliseconds;
RemPacket.WriteHeader(packet, RemPacketType.Heartbeat, 0xFFFF, seq);
RemPacket.WriteHeartbeatPayload(packet[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
var bytes = packet.ToArray();
var packetSpan = outboundPingBuffer.AsSpan();
RemPacket.WriteHeader(packetSpan, RemPacketType.Heartbeat, 0xFFFF, seq);
RemPacket.WriteHeartbeatPayload(packetSpan[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
foreach (var p in targets)
{
try
{
var ok = transport(bytes, bytes.Length, p.AudioEndpoint);
var ok = transport(outboundPingBuffer, outboundPingBuffer.Length, p.AudioEndpoint);
onDiagnostic?.Invoke($"send seq={seq} to={p.AudioEndpoint} {(ok ? "ok" : "FAILED")}");
}
catch (Exception ex)
+61 -12
View File
@@ -39,6 +39,15 @@ public sealed class PeerDiscoveryService : IDisposable
// reference once per tick. Volatile-write semantics via the assignment under the gate are
// sufficient because we only ever swap the reference, never mutate in place.
private IReadOnlyList<IPAddress> unicastTargets = [];
// Cached broadcast addresses. Item 16 of RemSoundefficiency.md — pre-2026-05-23 we
// recomputed these every 1.5 s by walking every network interface (NetworkInterface
// .GetAllNetworkInterfaces is a real Win32 P/Invoke), allocating a HashSet, and iterating
// unicast addresses. Network interfaces don't change on a 1.5 s cadence; cache the
// result and invalidate only when Windows raises the NetworkAddressChanged event.
// Reference-swap on update so the announce loop can read it without locking.
private volatile IPAddress[] cachedBroadcastAddresses = [];
private int broadcastCacheDirty = 1; // 1 = needs rebuild, 0 = current. Int for Interlocked.
private NetworkAddressChangedEventHandler? networkChangeHandler;
public event Action? PeersChanged;
@@ -70,6 +79,15 @@ public sealed class PeerDiscoveryService : IDisposable
announcer = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
// Subscribe to Windows network-change notifications so we know to rebuild the
// broadcast-address cache. Without this we'd either have to re-walk all interfaces
// every 1.5 s (the pre-2026-05-23 behaviour) or risk announcing on stale broadcast
// addresses after a network change. The handler just flips the dirty flag — the
// actual rebuild happens lazily the next time AnnounceLoop reads the cache.
networkChangeHandler = (_, _) => Interlocked.Exchange(ref broadcastCacheDirty, 1);
try { NetworkChange.NetworkAddressChanged += networkChangeHandler; }
catch { /* harmless — caching just falls back to per-tick rebuild on first miss */ }
listenTask = Task.Run(() => ListenLoop(cts.Token));
announceTask = Task.Run(() => AnnounceLoop(cts.Token));
}
@@ -105,6 +123,12 @@ public sealed class PeerDiscoveryService : IDisposable
public void Stop()
{
if (networkChangeHandler is not null)
{
try { NetworkChange.NetworkAddressChanged -= networkChangeHandler; }
catch { /* ignore — best-effort unsubscribe */ }
networkChangeHandler = null;
}
cts?.Cancel();
listener?.Dispose();
announcer?.Dispose();
@@ -225,23 +249,48 @@ public sealed class PeerDiscoveryService : IDisposable
}
}
private static IEnumerable<IPAddress> GetBroadcastAddresses()
/// <summary>Returns the cached broadcast-address array, rebuilding it only if the
/// dirty flag has been set (initial state, or by the NetworkAddressChanged event).
/// The original implementation walked every NIC on every announcement (~40 per minute);
/// caching turns that into a single walk per network change. Item 16 of
/// RemSoundefficiency.md. 2026-05-23.</summary>
private IPAddress[] GetBroadcastAddresses()
{
var addresses = new HashSet<IPAddress> { IPAddress.Broadcast };
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
// Fast path: cache is current.
if (Volatile.Read(ref broadcastCacheDirty) == 0)
{
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
return cachedBroadcastAddresses;
}
// Slow path: rebuild. Atomic CAS clears the dirty flag before the rebuild so a
// concurrent NetworkAddressChanged event sets it again rather than racing.
Interlocked.Exchange(ref broadcastCacheDirty, 0);
var addresses = new HashSet<IPAddress> { IPAddress.Broadcast };
try
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
var addr = unicast.Address.GetAddressBytes();
var mask = unicast.IPv4Mask.GetAddressBytes();
var bcast = new byte[4];
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]);
addresses.Add(new IPAddress(bcast));
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
{
if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
var addr = unicast.Address.GetAddressBytes();
var mask = unicast.IPv4Mask.GetAddressBytes();
var bcast = new byte[4];
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]);
addresses.Add(new IPAddress(bcast));
}
}
}
return addresses;
catch
{
// GetAllNetworkInterfaces can throw transiently on some configurations; the
// limited-broadcast 255.255.255.255 still reaches LAN peers on most setups, so
// fall back to just that rather than aborting discovery.
}
var snapshot = new IPAddress[addresses.Count];
addresses.CopyTo(snapshot);
cachedBroadcastAddresses = snapshot;
return snapshot;
}
private void PruneExpiredPeers()
+14 -52
View File
@@ -52,26 +52,14 @@ public enum RemoteControlKind : byte
SystemMuteToggle = 5,
}
[Flags]
public enum KeepAliveCapabilities : byte
{
None = 0,
CanSend = 1,
CanReceive = 2,
}
public enum KeepAliveKind : byte
{
Heartbeat = 1,
Ack = 2,
}
public readonly record struct KeepAliveInfo(
Guid SessionId,
KeepAliveKind Kind,
KeepAliveCapabilities Capabilities,
AudioTransportCodec Codec,
long UnixTimeMilliseconds);
// KeepAliveCapabilities / KeepAliveKind / KeepAliveInfo + the KeepAlivePayloadSize +
// WriteKeepAlivePayload / TryReadKeepAlive methods that lived here were removed 2026-05-23.
// They date from before HeartbeatService (which arrived 2026-05-06). After HeartbeatService
// went in, no code in RemSound ever wrote or read a KeepAlive packet again — they were dead
// code carried through 16 releases. RemPacketType.KeepAlive = 3 and the silent-drop dispatch
// in AudioReceiver are RETAINED on purpose so any pre-2026-05-06 build still in the wild
// has its packets quietly ignored rather than counted as malformed — but the unused machinery
// to construct/parse the payload is gone.
/// <summary>
/// Wire format for RemSound packets. Header is 12 bytes; body length is implied by the UDP datagram.
@@ -95,7 +83,8 @@ public static class RemPacket
/// before reading the Lane field; payloads shorter than that default Lane to
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
public const int FormatPayloadExtendedSize = 36;
public const int KeepAlivePayloadSize = 28;
// KeepAlivePayloadSize removed 2026-05-23 — no code reads or writes this payload any more
// (see top-of-file comment). RemPacketType.KeepAlive itself is retained for wire safety.
/// <summary>
/// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 8 bytes originator-monotonic
/// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent).
@@ -179,24 +168,8 @@ public static class RemPacket
return FormatPayloadExtendedSize;
}
public static int WriteKeepAlivePayload(Span<byte> destination, KeepAliveInfo info)
{
if (destination.Length < KeepAlivePayloadSize)
{
throw new ArgumentException("KeepAlive payload destination too small", nameof(destination));
}
destination[0] = (byte)info.Kind;
destination[1] = (byte)info.Codec;
destination[2] = (byte)info.Capabilities;
destination[3] = 0;
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], info.UnixTimeMilliseconds);
if (!info.SessionId.TryWriteBytes(destination.Slice(12, 16)))
{
return 0;
}
return KeepAlivePayloadSize;
}
// WriteKeepAlivePayload removed 2026-05-23 — dead since HeartbeatService landed
// 2026-05-06. See top-of-file comment.
public static bool TryReadHeader(ReadOnlySpan<byte> packet, out RemPacketType type, out ushort streamId, out uint sequence)
{
@@ -305,19 +278,8 @@ public static class RemPacket
return true;
}
public static bool TryReadKeepAlive(ReadOnlySpan<byte> payload, out KeepAliveInfo info)
{
info = default;
if (payload.Length < KeepAlivePayloadSize) return false;
if (!Enum.IsDefined((KeepAliveKind)payload[0])) return false;
info = new KeepAliveInfo(
new Guid(payload.Slice(12, 16)),
(KeepAliveKind)payload[0],
(KeepAliveCapabilities)payload[2],
Enum.IsDefined((AudioTransportCodec)payload[1]) ? (AudioTransportCodec)payload[1] : AudioTransportCodec.Pcm,
BinaryPrimitives.ReadInt64LittleEndian(payload[4..]));
return true;
}
// TryReadKeepAlive removed 2026-05-23 — dead since HeartbeatService landed 2026-05-06.
// See top-of-file comment.
}
/// <summary>
+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
+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)