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>
|
||||
|
||||
Reference in New Issue
Block a user