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:
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 12–14 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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<short>...)</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)
|
||||
|
||||
Reference in New Issue
Block a user