Bump to v2.2.0: Opus native binding, efficiency tidy-up, diag self-meter

Single biggest change: added the Concentus.Native NuGet package. Concentus
2.0+ auto-detects native libopus at runtime and routes encode calls
through it; encoder state lives on the C side and is reused across calls
rather than `new`ing ~15 working buffers per call (Concentus issue #22,
open since 2018). Measured on the desktop test at 15:36:55 — Opus 10 ms
allocation rate dropped from 4,625 KB/s to 108 KB/s, a 97.7% reduction.
Process CPU dropped from 4.7% to 1.6% in the same config. Audio is bit-
for-bit identical (it's literally the same encoder, just better
packaged). `OpusEncoderState.cs` itself unchanged on the call site.

Diagnostic / measurement layer (gated on Enable-logs, zero cost when off):
* ProcessSelfMeter: CPU%, managed heap MB, working set MB, allocation
  rate per second, GC counts per generation
* Per-thread work-time counters: captureMs / sendMs / recvMs / renderMs
  expressed as milliseconds of CPU consumed by each audio thread per
  second
* Inter-packet arrival gap measured at the user-space UDP socket
  (rxNetGapMs) — pinpoints whether arrival jitter is in the network or
  our own dispatch path

Small efficiency wins (each one was small but cumulative):
* deviceRefreshTimer interval 1s -> 3s (item 4)
* WaitHandle array allocations eliminated in MixingEngine.MixLoop and
  MultiOutputPlayout.ProduceLoop (item 6)
* MultiOutputPlayout caches its output-buffer snapshot and only rebuilds
  on SetOutputDevices, instead of rebuilding every 10 ms (item 7)
* HeartbeatService reuses an outbound ping byte[] instead of allocating
  per send (item 14)
* PeerDiscoveryService caches broadcast addresses and invalidates on
  Windows' NetworkChange event instead of walking all NICs every 1.5 s
  (item 16)

Legacy / dead-code removal:
* KeepAlive packet's implementation (struct, enums, writer, reader, size
  constant) — all dead since HeartbeatService landed 2026-05-06. Kept
  the RemPacketType.KeepAlive enum value and silent-drop dispatch for
  wire compat with any pre-2026-05-06 build still in the wild (item 30)
* driftDropFramesTotal / driftRepeatFramesTotal fields and accessors —
  Phase-2 splice corrector relics, never incremented since Phase-4
  resampler design landed; backed five always-zero diag log columns
  (items 34 + 35)
* DriftAccumulator (always returned 0) — same shape, removed alongside
  the driftAcc= column (item 35)
* TakeMaxFanOutCacheBytes / Ms + fanCacheMs column — FanOutSource was
  retired in May (item 36)

Project documentation:
* RemSoundefficiency.md added as the canonical record of the efficiency
  analysis, every item's status, and the measured wins from this round
* Honest item-by-item review of the original 50-item list — several
  items I had sized optimistically in the original analysis turned out
  to be already-done (item 20), already-optimal (item 22), or below
  the meter floor (items 9, 15, 17, 25). Recorded so future passes
  don't re-investigate.

Wire format and audio pipeline unchanged from v1.5 onward — v1.5 through
v2.2 peers interoperate.
This commit is contained in:
Ednunp
2026-05-23 15:56:03 +01:00
parent 79b28b6c02
commit 6d6d6897e4
22 changed files with 847 additions and 232 deletions
+20 -16
View File
@@ -1,38 +1,42 @@
# RemSound v2.1 # RemSound v2.2
Automatic router setup for internet streaming, a new "lock this profile" option to keep your default profile from prompting on close, a small notice before background updates install, and fixes for the "no sound after sleep / hibernate" problem. Wire format and audio pipeline are unchanged from v1.5 onward — all versions from v1.5 to v2.1 interoperate. A maintenance release that makes RemSound use less of your computer's CPU and memory, especially when sending audio with the Opus codec. No new features to learn, no settings have changed, audio sounds exactly the same. Wire format and audio pipeline are unchanged from v1.5 onward — every version from v1.5 to v2.2 still talks to every other version cleanly.
## What's new ## What's lighter on your computer
* **Automatic router port opening (UPnP).** RemSound can now ask your router to open the audio port for incoming peer connections, so you don't have to set up port forwarding by hand. Off by default — tick **"Automatically open my router for incoming connections (UPnP)"** in Preferences (Ctrl+P) to turn it on. A live status line right below the tick tells you what happened: found your router and opened the port (with your external address), found your router but the port couldn't be opened, no router found that supports the feature, or the router opened the port but you're behind a carrier-grade NAT (common on mobile broadband — peers won't reach you directly, use Tailscale or the relay instead). Works with UPnP, NAT-PMP and PCP — whichever your router speaks. * **Opus sending now uses much less memory.** RemSound's Opus encoder used to do quite a lot of one-off memory work on every audio frame — about 4 MB per second of "throwaway" memory churn while sending Opus audio. v2.2 ships a native build of the same encoder that does the same work in a tighter way. The audio you hear is identical (it really is the same encoder, just packaged better); the memory churn drops by about 97 %. On laptops you should see less background CPU when streaming Opus, and long sessions are less likely to see brief pauses while Windows tidies up memory.
* **Lock profile (read-only).** New tickable item in the File menu (Alt+F, L). When ticked, anything you change while RemSound is running stays in this session and is forgotten on close — your saved profile is left untouched, and there is **no save-changes prompt on exit**. Useful when you have a default profile you tweak constantly but don't want to commit those tweaks, and essential for unattended shutdowns where a save prompt could deadlock the close (screen reader gone, remote session dropped, machine hibernating). The lock state is saved on the profile itself, so it sticks across launches. Save As on a locked profile produces an unlocked copy. The window title and the startup profile picker both show "(read-only)" so you can tell at a glance. * **Smaller all-round efficiency tidy-up.** A handful of small fixes — RemSound checks the audio-device list a bit 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 shave a few percent off RemSound's everyday CPU footprint and reduce memory churn modestly.
* **Check for updates on startup.** New checkbox in Preferences, **on by default**. Shortly after RemSound launches it has a quiet look for a new release. Combined with "Silently install updates", this is "leave RemSound to keep itself up to date and never think about it". * **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 anyone who reads the diagnostic logs.
* **Brief notice before a silent update installs.** When RemSound finds an update at startup and is set to install silently, it now shows a small window with the version it's about to install and an 8-second countdown. Press Enter (or wait) to install now, "Skip this version" to leave the update for another day, or "Postpone" to try again at the next check. Without this notice, the app would silently close on you a few seconds after launch and you'd have no idea why. ## For people who use the diagnostic logs
* **"Cue sounds" in Preferences is now labelled "Audio cue sounds"** for clarity. * **New columns added** (only emit when Enable logs is ticked, so cost nothing when off):
* `cpu=X.X%` — how much of one CPU core RemSound is using right now.
* `memMB=X.X` and `wsMB=X.X` — RemSound's memory footprint (managed heap and working set).
* `allocKBps=X.X` — how fast RemSound is asking Windows for new bits of memory right now. A low number is what we want.
* `captureMs / sendMs / recvMs / renderMs` — milliseconds of CPU each of RemSound's four audio threads spent doing work in the last second.
## Bug fixes * **Some old columns removed.** `fanCacheMs`, `driftDrop`, `driftDropΔ`, `driftRep`, `driftRepΔ` and `driftAcc` are gone — they were always zero after the playback engine was changed in May.
* **No sound after the computer wakes from sleep.** On many setups (especially USB audio interfaces), waking the computer left RemSound's audio engine in a state where it looked like it was running but no sound actually came out — you'd have to quit and reopen RemSound. RemSound now notices when the system has woken up, waits a moment for USB devices to settle, and rebuilds its audio engine automatically. A brief "Reconnecting to audio driver, please wait..." window appears during the rebuild so you can see it's happening. ## Nothing else has changed
* **Receiver audio silent after waking from hibernate.** A follow-up to the wake-from-sleep fix above: on hibernate (rather than ordinary sleep), the ASIO receive output's tick selection could be silently wiped during hibernation entry, leaving the receiver running silent on resume even though everything looked normal in the logs. Fixed by recognising the transient driver-disappeared state at hibernation entry and preserving the user's tick until the driver comes back. No bug fixes in v2.2 specifically. Everything in v2.1 — the UPnP automatic router-opening, the lock-profile (read-only) tick, the silent-install notice, the wake-from-sleep audio fix, the hibernate fix — is still in place and works exactly the same.
## Install ## Install
1. Download `RemSound-v2.1.zip` from this release. 1. Download `RemSound-v2.2.zip` from this release.
2. Close RemSound. 2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings. (For a fresh install, just extract it anywhere you have write permission and run `RemSound.exe`.) 3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings.
4. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. Press F1 for the user manual. 4. Run `RemSound.exe`. Press F1 for the user manual.
Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch. Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch.
## Upgrading ## Upgrading
**v1.9, v2.0:** Help → Check for updates works — it will fetch and install v2.1 automatically. If you've ticked the new "Check for updates on startup" and "Silently install updates", v2.1 will install itself shortly after launch with a brief notice. **v1.9, v2.0, v2.1:** Help → Check for updates works — it will fetch and install v2.2 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v2.2 will install itself shortly after launch with a brief notice.
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v2.1 but not apply it. Install v2.1 by hand using the steps above — just this once. From the build you install onward, updates are automatic. **v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v2.2 but not apply it. Install v2.2 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
If you installed RemSound inside a synced folder (Dropbox etc.) and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps. If you installed RemSound inside a synced folder (Dropbox etc.) and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps.
+267
View File
@@ -0,0 +1,267 @@
# RemSound efficiency — deep analysis (2026-05-22)
A full pass through every source file looking for ways to use less CPU / less memory, and for legacy code or things that can be made to run more efficiently. Written as a working document to refer back to as we make changes one at a time.
---
## Part 0 — What we need first: a way to *measure* improvements
There's nothing in there today that tells us how much CPU or memory we use. Without that, any change is a guess. So before anything else, **we need ways to see what RemSound is actually doing right now** so each change can be proved out.
1. **Add a "process self-meter" to the diagnostic log.** Once a second, the app would record how much CPU it just used (as a percentage of one core), how much memory it's holding, and how many garbage-collection events the runtime did in the last 1, 10, and 60 seconds. We already gather one-second diagnostics — this is just five more numbers on the same row. Gated behind the existing Enable-logs checkbox so it costs nothing when off. Output to the diag log so a long session shows the trend.
2. **Add a "per-thread time" counter for the four audio threads** (capture, render, mix, network). Each one records the milliseconds of CPU it actually consumed each second. Today we measure how long the *audio buffer* sits idle between callbacks (a related but different number); this measures how long the *thread itself* was busy. Tells us "the receive thread is using 3 percent of one core" vs "the receive thread is using 18 percent of one core" — directly answers "did this change actually help".
3. **Add an allocation counter.** A built-in .NET counter shows total bytes allocated per second. Logged once a second alongside the rest. A clean steady-state RemSound should be allocating in the kilobytes-per-second range; if it's in megabytes, something inside the hot path is leaking allocations.
These three additions give us a baseline. Every other suggestion in this document can then be tested: did CPU drop, did memory steady-state drop, did GC events get rarer.
---
## Part A — Ways to use less CPU and memory
Grouped from biggest expected win to smallest, with the riskier ones flagged.
### A1. Things you can do that are likely BIG wins
4. **Stop re-checking the audio devices every second on the user-interface thread.** Right now, every single second the app asks Windows for the full list of speakers and microphones, and (when an ASIO driver is selected) it opens the ASIO driver to read its channel names. That's a lot of work for a check that only matters when a device is plugged in or unplugged. Comment in the code even says "3 second intervals" but the timer is actually set to 1 second. Slowing it to 3 or 5 seconds, OR switching to Windows' built-in "device changed" notification (which fires only when something actually changes), would save measurable CPU. The ASIO probe is the heavy bit — it briefly touches the audio driver each time.
5. **Stop trimming the audio mixing loop every 10 milliseconds when nothing is being captured by Windows.** The mixing-engine loop runs at 100 times a second whether or not there's audio to mix. When no input devices are ticked there's literally nothing for it to do — it could go to sleep until it's needed again. Same for the multi-output player loop on the receive side.
6. **Use the right tool for waiting in the two main background loops.** The mixing-engine loop and the multi-output-playout loop both use a pattern that allocates a small object every single wait (~100 times per second per loop). Switching to a "wait token" approach that's allocated once and reused would remove about 200 small allocations per second across the two loops. Each on its own is tiny; over an hour it's still tens of thousands of pointless allocations that the garbage collector eventually has to clean up. Same thing inside `MixingEngine.MixLoop` and `MultiOutputPlayout.ProduceLoop`.
7. **Stop rebuilding the output-target list inside the playout loop every 10 milliseconds.** When the audio is being fanned out to several output devices, the loop currently rebuilds a snapshot list of those devices on every tick. The list only changes when the user changes output ticks — once every few minutes at most. We could cache it and rebuild only when the user changes things. Removes another 100 allocations per second.
8. **Turn off the drift-correction resampler when it has nothing to do.** The receive side runs a sample-rate resampler on every render callback to nudge for clock drift between the sender and receiver. When the sender and receiver clocks happen to match (which is most of the time), the resampler is doing a calculation whose answer is "don't change anything" — and yet we still feed every sample through it. If the measured drift is essentially zero, we could skip the resampler entirely and copy the samples directly. Big win on CPU, and inaudible because we're skipping a "do nothing" operation. Comes back on automatically when real drift is measured again.
9. **Batch the per-second diagnostic reads instead of doing them one at a time.** The diag-log writer (when logging is on) calls roughly 20 separate "give me the latest number" methods every second; several of them each walk the list of active audio streams independently. We could walk the list ONCE and gather all the numbers at the same time. Halves the work done per second when logging is on. When logging is off, this whole path is already silent.
10. **Make the most-expensive per-sample probe optional.** There's a discontinuity detector ("RecordOutputSampleSteps") that runs maths on every single sample of audio (about 96,000 times a second). It's gated behind the Enable-logs checkbox, so it's off in normal use. But it's the single most expensive probe in the engine. Even when on, we could fire it once every N samples instead of every sample (or only when an interesting event is suspected). This makes "logs on" cheaper.
11. **Reduce the cost of sample-by-sample volume + limiter work.** On every render callback the receive engine walks every sample applying volume + a soft limiter. There's a built-in .NET feature for doing these in batches of 4 or 8 samples at once (SIMD/vector instructions); the same code, rewritten that way, would do the same work in about a quarter of the time. Most modern CPUs have this. The limiter's "tanh" call is also relatively expensive — a cheaper approximation is inaudible at the levels it acts on.
12. **The float-to-bytes packing on the send side has the same opportunity.** Every audio packet runs a per-sample loop converting floating-point to 24-bit integers. Same SIMD trick applies. Sends ~400 packets per second per active stream; lots of samples adding up.
13. **The single big main form file (`MainForm.cs`, ~5800 lines) is doing too much at runtime.** It owns timers, peer lists, hotkeys, status text, all in one class. Per-second status updates rebuild several text strings and check several state machines. Splitting the status work into its own class wouldn't directly save CPU but would make further optimisations safer (right now it's hard to reason about what runs when).
### A2. Things you can do that are likely MEDIUM wins
14. **Stop allocating a new byte array for every outgoing heartbeat packet.** Once a second per peer we build a tiny 21-byte packet and then call `.ToArray()` on it, which copies the stack buffer onto the heap. Cheap one-off cost, but it's 200 allocations a day per peer when idle. Easy to keep the buffer around and reuse it.
15. **Stop allocating a string for every diagnostic log call when logging is OFF.** Several places do `onDiagnostic?.Invoke($"some message with {variables}")`. The string interpolation happens *before* the null-check on `onDiagnostic`, so even when the sink is null (i.e. logs are off), we still build the message. There are dozens of these. A small wrapper that checks the gate first would skip building the string entirely.
16. **The heartbeat service walks the network-interface list every 1.5 seconds.** Inside discovery it asks Windows for "all my network interfaces" to compute broadcast addresses, on a schedule. Network interfaces don't change every 1.5 seconds — caching the result and only refreshing on a Windows network-change event would skip ~40 expensive interface-enumeration calls per minute.
17. **Stop rebuilding the per-second diagnostic baseline by reading from every active stream.** The receiver has six or seven aggregate counters (underruns, drops, drift drops, etc.) that the diag log reads each second. Each one walks the stream list independently. If we walked the list once and copied out all the counters at the same time, it'd be ~6× less work, and zero behaviour change.
18. **The render path duplicates two near-identical methods.** `PlayoutEngine.Read` (used in classic modes) and `PlayoutEngine.ReadForRoute` (used in Both Independent mode) do essentially the same loop, differing only in how they filter the session list. They could share one body with the filter passed in. Same speed; less code; safer to optimise once.
19. **The render and capture sides each have their own "scratch buffer that grows when needed" pattern, repeated five or six times.** Each one is fine on its own; the repetition is a maintenance and review burden. Centralising the pattern in one helper makes it easier to swap in a pooled allocator later (which would shave more allocations).
20. **Use a built-in "buffer pool" for the outbound UDP packets.** Sender builds a packet, sends it, throws away the byte array. We could rent a packet's worth of memory from a built-in .NET pool, use it, and return it. .NET's `ArrayPool` is designed for exactly this. Save a small allocation per packet × 400 packets/sec × however many seconds you stream.
### A3. Things that need more thought before doing
21. **The "Tanh" soft-limiter could be a faster approximation.** It triggers only when sample-loudness peaks at 90% of full scale. The current `MathF.Tanh` call is mathematically perfect; a 4th-order polynomial approximation is inaudible. Saves CPU on busy music. Risk: low, but it's the receive-side audio quality so test carefully.
22. **The drift-correction resampler can be cheaper at the cost of inaudible quality loss.** Today it runs in NAudio's "linear interpolation" mode. There's an even cheaper mode for sub-1000-ppm corrections. The difference would not be measurable. Risk: very low.
23. **Multi-threading the sender's encode loop:** Today the audio capture thread does the capture, the mix, the encode, AND the packet send all in sequence. On a slow machine with multiple peers, the send-to-many-peers step could be moved off the audio thread onto a small dedicated send thread, so the audio thread is freed up sooner for the next capture. Risk: medium. Would need careful ordering / no-allocation work between threads. Not a slam-dunk because today's audio thread isn't the bottleneck — but might matter when streaming to 5+ peers.
24. **Multi-threading isn't likely to help on the receiver side** because each capture/render is already on its own thread. Where we DO sometimes have contention is the session dictionary lock during incoming packet handling. Switching to a lock-free map for sessions would eliminate that contention. Risk: medium. Lock-free dictionaries are tricky to get right.
25. **A small amount of code runs at "high priority" inside every audio callback even when off.** The per-callback `Stopwatch.GetTimestamp()` reads happen even when logs are off, because they're not gated. Cheap (nanoseconds) but called hundreds of times per second. Gating them inside `if (DiagnosticsGate.Enabled)` would save a tiny but real cost.
### A4. Memory-specific findings
26. **Steady-state memory should be roughly: 4 MB for the receive-side per-stream ring buffer, 2 MB for record buffers when recording, a few hundred KB for everything else.** Beyond that, anything large is overhead — the per-output WASAPI buffers, the NAudio capture buffers, etc. Without the new measurement we don't know if we're holding more than that or not.
27. **The session-snapshot array is rebuilt every time a peer connects or disconnects.** Fine. But if churn is heavy (a peer in a flaky-network spot reconnecting every few seconds), the snapshot allocator goes up. The session prune does it correctly.
28. **The big main form keeps a lot of WinForms control fields alive for the life of the app.** Normal for a WinForms app. The cost is constant and shows up as ~30 MB of working set; doesn't grow over time. Nothing to do.
29. **Long-running sessions with logging on can accumulate a lot of memory in the log writer** if the disk is slow. The log writer writes through directly so this isn't a major risk, but worth measuring once we have the meter.
---
## Part B — Legacy code, dead code, and cleanups
Things that don't speed RemSound up directly but reduce the code base, make future work easier, and remove footguns. They've all earned their place in the codebase but they've all been superseded.
### B1. Definitely dead — can be deleted
30. **The "KeepAlive" packet type and all its supporting code.** There's a packet type called KeepAlive, an enum for its kinds, a struct (`KeepAliveInfo`), a writer, a reader, and a capabilities flag — total ~80 lines of code spread across one file. Nothing in the application actually creates, sends, or processes a KeepAlive packet. The Heartbeat service replaced it in May. The packet handler in the receiver explicitly says "informational only at this layer" and does nothing. Pure dead code.
31. **The legacy `AudioMode.AsioOnly` and `AudioMode.Both` enum values.** No UI path produces either. Every place that takes an AudioMode now "coerces" these into one of the two real modes (WasapiOnly or BothIndependent). The coercion logic is in three different files. Removing the two dead values lets all the coercion code disappear too.
32. **The two `ConcealmentArtifact` enum values that aren't in the dropdown any more** (`CosineToneShort` and `CosineToneLow`). The preferences dialog doesn't offer them and the load path coerces them to `NoiseBurst` automatically. The synthesis code paths for the cosine tones are still in the receive engine (`ApplyFadeOut` switch statement, `ConcealFadeFramesLow` constant) but they're unreachable from the UI.
33. **The `MuteConnectionCues` field on the profile.** Superseded in May by four per-cue enable flags (Connect / Disconnect / RecordStart / RecordStop). There's a "legacy fallback" code path that reads MuteConnectionCues only when the new flags are unset — which can only happen for profile files from before May. Once you're confident no users still have ancient profiles, this whole migration path can go.
34. **The "drift drop frames" and "drift repeat frames" counters in the receive engine.** They're never incremented any more (the Phase-4 resampler design replaced the splice-based drift corrector that wrote them). They're kept around as zero values so the diag log columns don't disappear. The comment in the code explicitly says "can be removed once the diag columns are pruned."
35. **The `DriftAccumulator` property always returns 0.** Same reason — old Phase-2 metric the Phase-4 design doesn't use. Comment says "kept so MainForm's existing diag log line still compiles; can be removed once the diag columns are pruned."
36. **The `TakeMaxFanOutCacheBytes` / `fanCacheMs` diagnostic also always returns 0.** From the "FanOut" architecture that was removed when each lane got its own filtered source. Code keeps it as a sentinel zero so the diag log column still emits. Can disappear when the diag log line is cleaned up.
37. **The `FormatPayloadSize` (32 bytes) constant is the legacy wire-format size from before May.** Senders now always emit the 36-byte extended format. The 32-byte reader is kept for "old senders" — but no shipped version of RemSound emits 32-byte format packets any more. After enough time has passed for any old users to have updated, the 32-byte path can collapse into just the 36-byte path.
### B2. Likely dead but worth a careful check
38. **The PCM frame assembler's multi-part code.** PCM frames at 5 ms or 2.5 ms (the only two send rates RemSound uses) are always small enough to fit in one UDP packet. The multi-part assembler exists for some future longer-frame mode that never shipped. Probably safe to delete the multi-part path and just expect single-packet frames; if a 10 ms PCM mode is added later we'd put it back.
39. **The `RemoteVolumeUp/Down` and `RemoteMuteToggle` hotkeys** plus the `Control` packet kind — these work and are documented but I'm not sure they're widely used. Worth asking your users.
40. **The "blank template" profile flow.** When a user picks "(Blank template)" at startup, several code paths special-case "currentProfileTitle is null/empty". The total amount of special-casing is significant. Could be simplified by treating "blank template" as a regular (never-saved) profile internally.
41. **The legacy `Settings` cache inside `RemSoundSettingsStore`.** Every "save one field" call creates a Settings object, sets one property, and replaces the cache. The class is in-memory only now (the doc comment explicitly says "this no longer persists to disk"). Could be flattened into a simple set of fields on the store, no Settings wrapper at all.
### B3. Cleanups that aren't dead-code but feel old
42. **The audio backend has TWO different "Both" modes in the enum** (`Both` and `BothIndependent`) and a long comment explaining the difference. Now that only `BothIndependent` is real, the name could just be `Both` and the legacy `Both` value retired. Less to read; less to mistake.
43. **The "AudioMode is derived from AsioDriverName" rule lives implicitly in several places.** Centralising "what mode are we in?" into one method that reads driver name + returns the mode would let other code stop duplicating the logic.
44. **The diagnostic log line is the longest single line in the codebase by a long way.** ~30 columns plus the new XB/WB split fields plus the new GC/network ones. Worth splitting into two physical lines in the log file (one for "what's happening" and one for "how is it happening"). Saves nothing in CPU; readers — including you and me — would find them easier to scan.
45. **The PowerResume splash, the ASIO loading splash, the Update install notice, the Save-confirmation TaskDialog, and Save-blocked-by-read-only TaskDialog** are all similar small popup forms with similar wiring. Could share a tiny base class. Doesn't save CPU; saves ~150 lines of code.
46. **The MainForm has multiple `MarkProfileDirty()` call sites scattered across event handlers.** A small "DirtyTracker" helper that knows which controls should dirty the profile could replace the scatter. Cleaner; same behaviour.
47. **Several places use `.ToArray()` / `.ToList()` defensively at the end of LINQ chains** (e.g. `recentPingSources.Where(...).Select(...).ToList()`) when an enumeration would suffice. These are not in hot paths but they show up dozens of times.
### B4. Risk-flagged for awareness, not necessarily change
48. **The audio-thread "diagnostic gate" pattern relies on every probe checking it.** Most do; a handful of legacy probes don't (they pay a tiny cost always). Tightening that consistency would make "logs off" a hard zero-cost state.
49. **There's a comment in the code apologising for the soft-clamp not being SIMD-vectorised.** Worth doing eventually.
50. **The receive side stack-allocates float scratch buffers via `stackalloc` in two places.** Excellent for performance, but if the audio packet is unusually large (which can't happen on RemSound's protocol but could if a malformed peer sent one), this could overflow the stack. The current code limits the stackalloc to 16 KB and falls back to a heap allocation otherwise — that fallback IS allocating. Fine.
---
## Where I'd start if it were me
If I had to pick the three changes that would make the biggest measurable difference for users on modest laptops:
- **Item 1 + 2 + 3 first** — build the meter. Without the meter, every other item is a guess. Half a day of work.
- **Item 4** (don't probe ASIO every second) — likely the single biggest CPU saving for any ASIO user, and it's not even on the audio path.
- **Item 8** (skip the resampler when drift is zero) — biggest CPU saving on the audio render thread itself, and inaudible.
After those land and you can SEE the improvement in your new meter, items 6, 7, 9, 11, 12 are the next tier and they're all low-risk because they don't change audio behaviour — only how many CPU cycles and allocations they cost.
The legacy-code cleanups (Part B) are independent: do them whenever convenient. They reduce surface area for bugs but don't directly improve performance.
---
## Measurement results — first baseline (test 2 on 2026-05-23)
Test 2 was run for ~3.5 minutes on the desktop with the new meters active, deliberately cycling through configurations: ASIO PCM → WASAPI-only PCM → BothIndep WASAPI+ASIO PCM → Opus 10ms → Priority mode → Tight latency push-mode. Per-phase median readings, all from a single machine with no peer connection:
| Phase | Config | CPU % | Allocs KB/s | captureMs | sendMs |
|---|---|---|---|---|---|
| P1 | BothIndep + ASIO only, PCM | 6.3% | 213 | 4.9 | 4.0 |
| P3 | WasapiOnly + WASAPI loopback, PCM | **3.1%** | 137 | 2.5 | 2.1 |
| P4 | BothIndep + WASAPI only, PCM | 4.7% | 150 | 2.5 | 2.2 |
| P5 | BothIndep + WASAPI + ASIO, PCM | 4.6% | 251 | 6.5 | 5.5 |
| P6 | Same + **Opus 10ms** | 4.7% | **8,718** | 11.1 | 10.0 |
| P7 | + Priority ON, Opus 10ms | 4.7% | **14,007** | 25.7 | 24.2 |
| P8 | Priority ON, back to PCM | 11.0% | 253 | 7.5 | 6.0 |
| P9 | + tight latency + push-WASAPI | 11.1% | 312 | 9.8 | 8.3 |
Key takeaways from the baseline:
* **WASAPI-only with WASAPI loopback is the lightest mode** at 3.1% CPU and ~137 KB/s of allocations.
* **The ASIO probe (item 4) costs about 1.6% CPU + ~13 KB/s allocations** — visible by comparing P3 (no probe) to P4 (probe firing every second, no actual ASIO source yet). Real but smaller than originally guessed.
* **Adding an ASIO source on top of WASAPI doubles capture/send work** (P4→P5 captureMs 2.5→6.5). Expected.
* **Priority mode roughly doubles total CPU** for the same workload (P5 4.6% → P8 11.0%). Per-thread captureMs/sendMs only modestly increase, so the extra CPU is elsewhere — most likely the 1ms scheduler quantum increasing wake-up frequency across all process threads. A deliberate user opt-in, so lower urgency.
* **🚨 OPUS ENCODING IS A MAJOR ALLOCATOR** — see item 51 below.
## Part C — Findings added after the first measurement pass (2026-05-23)
51. **Opus encoding allocates ~89 MB/sec at the steady state.** This was not on the original list and DWARFS every other allocation source by an order of magnitude. Switching from PCM to Opus 10ms in the test took allocation rate from 251 KB/s straight to 8,718 KB/s — a 35× jump, with no other configuration change. Adding priority mode on top pushed it to 14,007 KB/s. At ~100 encodes per second this works out to ~87 KB allocated per Opus.Encode() call.
* The wrapper code in `OpusEncoderState.cs` itself doesn't allocate (it uses pre-allocated scratch arrays for both the int16 conversion buffer and the output packet buffer).
* Cause is inside the Concentus library (we use Concentus 2.2.2). The C# port of libopus appears to allocate per-call working buffers for the CELT encode path.
* **First mitigation attempt**: switch from the `Encode(ReadOnlySpan<short>...)` overload to `Encode(ReadOnlySpan<float>...)`. Concentus CELT runs in float natively, so the float-input path may avoid one internal round-trip and may have different allocation behaviour. Also removes our own per-sample clamp+convert loop (saves a small amount of CPU). Risk is very low — Concentus' XML doc explicitly says the float overload clips out-of-range samples internally.
* If that doesn't move the needle, deeper options are: pin Concentus to an older version with different alloc patterns, fork+patch Concentus to pre-allocate scratch on the encoder instance, or swap to a native Opus library via P/Invoke.
* This is also a strong incentive to keep PCM as the default codec for users on LAN — PCM allocation stayed at ~251 KB/s in the same config that gave us 8,718 KB/s on Opus.
## Status log
* 2026-05-22 — Initial analysis written by Claude after a full pass over the codebase.
* 2026-05-22 — Implementing items 1, 2, 3 (the measurement layer). All gated behind the existing Enable-logs checkbox so they cost nothing when logging is off.
* 2026-05-23 — First measurement pass run by Ed (test 2). Added Part C above with the headline finding: Opus encoding allocates 89 MB/s. First mitigation: switching `OpusEncoderState` to the float-input `Encode` overload.
* 2026-05-23 — Items 4 + 6 + 7 done (slowed `deviceRefreshTimer` to 3 s, swapped `WaitHandle.WaitAny(new[]{...})` for `WaitOne()` in both audio-pipeline tick loops, cached the output-buffer snapshot in `MultiOutputPlayout` so the producer loop no longer rebuilds it per tick). Measured impact small (~2030 KB/s allocation reduction, ~12 % CPU at the margin); the WaitHandle allocations were 24 bytes each so removing 100/sec is only ~2.4 KB/s — original prediction of 100200 KB/s saving was wrong, the actual scale is much smaller.
* 2026-05-23 — **Correction**: the earlier "Opus down 47 %" claim from the float-input overload switch was wrong. The 8,718 KB/s reference was a 2-encoding-lane configuration; the 4,625 KB/s test was a 1-encoding-lane configuration. Per-lane, the allocation rate was essentially unchanged (~4,400 vs ~4,600). What the float overload DID do, that I undersold: dropped per-call CPU work meaningfully (captureMs+sendMs ~21 → ~12 in the same config). Concentus' internal per-call allocations remain the actual bottleneck for Opus — confirmed by two independent tests now.
* 2026-05-23 — Items 14 + 16 done (reused outbound heartbeat byte buffer in `SendPings`; cached `GetBroadcastAddresses` in `PeerDiscoveryService` with invalidation on `NetworkChange.NetworkAddressChanged`). Item 14 is housekeeping — saves ~150 bytes/s, will not be visible in the meter. Item 16 is more meaningful — saves a `NetworkInterface.GetAllNetworkInterfaces` walk every 1.5 s — probably a few KB/s of allocs and a small slice of CPU. Will measure.
* 2026-05-23 — **Item 8 dropped from this round.** On careful reading: the drift-resampler IS the drift compensator. It engages once per ~10 s with whatever clock-rate ratio was measured. Bypassing the resampler when the ratio is "near unity" loses drift correction — buffer drift accumulates at ~50500 ppm (typical USB audio clock mismatch), which over a long session produces audible trim or overflow clicks. The only safe bypass case is exactly ratio == 1.0, which only happens in the first 10 s of each session before the first measurement window completes. That's not a meaningful saving and isn't worth the cost of carrying the bypass code. Reopening this item would require a different design (e.g. SIMD inside the resampler's interpolation loop, item 11-style) rather than a bypass.
## Part D — Tight-latency cost (added 2026-05-23 from laptop test)
52. **Tight-latency PCM mode is significantly more expensive than standard mode** — quantified for the first time in the 2026-05-23 laptop send test. Mechanics: tight-latency makes `SenderLane.ProcessPcm` emit one UDP packet per ASIO capture callback rather than accumulating to 480-sample (5 ms) frames. With a small ASIO buffer (the laptop runs at 32 samples = ~0.67 ms callback period), that's **~1,500 UDP packets per second** vs the standard-mode rate of ~400 packets/sec. Same audio bandwidth, ~4× the per-second framing/encode/send work, ~4× the per-second context-switching into the kernel for SendTo.
* Measured laptop CPU at this setting: **~12.4 % of one core** sending PCM with one ASIO source and no peer. ~8.6 % of that is in code outside our instrumented threads (ASIO host thread + Windows audio stack); the audio-thread work itself is `captureMs + sendMs ≈ 77 ms/sec ≈ 7.7 % of one core`.
* Measured desktop CPU at standard-mode (no tight latency, larger ASIO buffer): **~1.6 % of one core** for the same audio task. Per-packet, both machines are similarly efficient — the laptop is just doing four times as many packets per second.
* **Worth documenting in the user manual**: "tight latency" / "Lock to audio clock" can quadruple RemSound's CPU footprint when paired with a small ASIO driver buffer. Users on lower-spec hardware should expect 10-15 % CPU even with no peer connected if they tick tight latency. Users on desktops with healthy CPUs won't notice.
* No code change recommended — tight latency is a deliberate opt-in for the lowest possible latency. The user is correctly paying the cost they asked for. This is a documentation item, not an efficiency bug.
## Recommendation on item 11 (SIMD per-sample loops)
Item 11 (SIMD the volume + limiter loop on receive, and the float→int24 pack loop on send) would realistically save **~0.4 % of one core** based on the measured renderMs split. The two loops it targets account for roughly 5-6 ms/sec of audio-thread CPU; a 4× SIMD speedup would shave ~4 ms/sec.
Honest assessment: **not worth doing right now**. SIMD audio code carries real risk (one-bit-off mistakes are inaudible in normal content but click on transients), is harder to review, harder to debug. For a 0.4 % CPU saving on already-healthy numbers, the risk-to-reward isn't there. Revisit only if a specific user complaint surfaces about CPU on a slow machine.
**Where the real remaining inefficiency lives** — and what it would take to attack:
* **Opus per-encoder allocation rate (~4.5 MB/s)**: inside Concentus, not our wrapper. Would need a different library (native libopus via P/Invoke, e.g. NativeOpus or FFmpeg.AutoGen) — a real project with its own audio-quality verification burden.
* **The ~8.6 % laptop CPU we don't account for**: ASIO host thread, NAudio's MultiOutputPlayout when no WASAPI outputs are ticked, Windows audio stack. Not our code; not addressable without architectural changes (e.g. ditching NAudio for direct WASAPI/ASIO via P/Invoke, which is a year-scale project).
Diminishing returns kick in here.
## Status log (continued)
* 2026-05-23 — **Opus native binding (item 51 follow-up).** Research pass on Jamulus (uses Opus Custom + CBR + no FEC + complexity 1 + int16 + pre-allocated buffers) and Concentus internals (issue #22, open since 2018, confirms the C# port `new`s ~15 working buffers per `celt_encode_with_ec` call and there's no config knob to suppress them). Cleanest fix: add the `Concentus.Native` NuGet package. Concentus 2.0+ auto-detects native libopus at runtime and routes encode/decode through it; encoder state is allocated once on the C side and reused, eliminating the per-call managed scratch entirely. **Zero changes to `OpusEncoderState.cs` or the call site.** Output is bit-for-bit identical (same library, same encoder configuration). Cross-platform natives ship via the package; `dotnet publish` correctly trims them to just the Windows RIDs (we ship `runtimes/win-x64/native/opus.dll` and the win-x86/arm64 variants alongside it). Managed `Concentus.dll` remains in the publish as a fallback in case the native fails to load. Pending measurement on the next test.
* 2026-05-23 — **Tuning options held in reserve** (not done this round): switch from VBR to CBR (Jamulus' choice), turn off inband FEC (Jamulus doesn't set it), drop complexity from 10 (current) down to 1 or 5. These affect audio quality / packet-loss recovery so they're left as a follow-up once the native-binding switch is measured. If the native switch alone brings allocations to a reasonable level (target: under 500 KB/s per lane), we can leave the tuning alone — current settings give better quality and FEC robustness than Jamulus'.
* 2026-05-23 — **Legacy cleanup (Part B1)** done where back-compat allows:
* Item 30 (KeepAlive): removed `KeepAliveCapabilities`, `KeepAliveKind`, `KeepAliveInfo`, `KeepAlivePayloadSize`, `WriteKeepAlivePayload`, `TryReadKeepAlive` — all dead since HeartbeatService landed 2026-05-06. Kept `RemPacketType.KeepAlive = 3` and the silent-drop dispatch in `AudioReceiver` so any pre-2026-05-06 build still in the wild has its packets ignored rather than counted as malformed. ~50 lines of dead code gone.
* Items 34 + 35 (drift drop / repeat / accumulator counters): the Phase-2 splice corrector was retired in favour of the Phase-4 fixed-ratio resampler. Counters never incremented, accessors always returned 0, diag log carried `driftDrop= driftDropΔ= driftRep= driftRepΔ= driftAcc=` columns full of zeros. Removed the backing fields, all five accessors across `SessionPlayout` / `PlayoutEngine` / `AudioReceiver`, the prev-tracking fields in `MainForm`, and the five log columns.
* Item 36 (fanCacheMs): the FanOutSource architecture was retired in mid-May when each lane got its own filtered PlayoutEngine source. `TakeMaxFanOutCacheBytes`/`Ms` always returned 0; column was a placeholder. Removed.
* **Skipped** items 31 (AudioMode.AsioOnly / .Both), 32 (ConcealmentArtifact.CosineTone*), 33 (MuteConnectionCues), 37 (32-byte format payload reader). All four are back-compat carry-overs for old profile JSONs or older RemSound peers in the wild. The space saved isn't worth the migration risk. Recorded here so future cleanup passes know not to re-investigate them.
* 2026-05-23 — **Opus native binding confirmed working** in the desktop test at 15:36:55. Opus 10 ms allocation rate dropped from 4,625 KB/s (pre-fix) to **108 KB/s** — a 97.7 % reduction. Process CPU dropped from 4.7 % to 1.6 % in the same config. The remaining 108 KB/s is now in the same ballpark as PCM's 150 KB/s, so Opus is no longer the dominant allocator at all. captureMs + sendMs went UP modestly (5.7 + 5.7 → 11.2 + 12.0) — that's the audio thread doing real encode work now without GC interruptions inflating the Stopwatch reading. Process is doing less total work; the audio thread is doing more honest work. Net good. Item 51 is fully resolved.
* 2026-05-23 — **Honest item-by-item status review** of the remaining items after the round of small fixes that landed today. Conducted because the original analysis sized several items optimistically without quantitative arithmetic; today's measurements show many of those wins to be 5-10× smaller than I claimed. Updated to be conservative about future estimates.
* **Item 5** (mix loop only ticks when capture has data): the existing `MixingEngine.MixLoop` already short-circuits via `if (localMixer is null) continue;` when no sources are added. Effectively no work to do; only one Stopwatch read per skipped tick. NO-OP, marking done.
* **Item 8** (drift-resampler bypass): documented earlier as overstated; the only safe bypass is `smoothedRateRatio == 1.0` exactly, which is the first 10 s of every session. Real win = 10 seconds per session of skipped resampler work. Below measurable. Not pursuing. The original analysis's claim that this was "biggest single CPU saving on the audio render thread" was wrong — I was thinking of the resampler as discretionary work when it's actually the drift compensator itself.
* **Item 9** (batch the per-second diagnostic reads): the multiple metric drains in `MainForm`'s diag emitter all go through pre-existing accessors that walk `sessionsSnapshot` once each. The walks are over a typically 1-2-element array; ~5-20 nanoseconds per walk; 14 walks/sec total = ~280 ns/sec of CPU. Below the measurement floor. CODE QUALITY only, no measurable win. Skipping.
* **Item 10** (per-sample probe optional gating): the `RecordOutputSampleSteps` second-derivative probe is already gated on `DiagnosticsGate.Enabled` at its first line. With logs off it's free. With logs on it's the cost the user opted into. NO-OP.
* **Item 11** (SIMD volume + limiter): documented; ~0.4 % CPU saving for real risk of audio bugs. Not doing.
* **Item 12** (SIMD float-to-int24): same shape as 11, ~0.3 % CPU. Not doing.
* **Item 13** (MainForm split): pure refactoring, 0 % CPU. Not pursuing in an efficiency pass.
* **Item 15** (gated string interpolation on log calls): after grepping the 126 `?.Invoke($"…")` sites, the hot-path ones fire 1-3 Hz per peer. Each interpolation is ~80 bytes. Total realistic saving: 1-5 KB/s. The architectural change to gate properly (introducing an interpolation-handler wrapper across 14 files) is disproportionate. Skipping. Worth revisiting only if a future profiling pass shows this is a top-N allocation source.
* **Item 17** (combine session-snapshot walks): same data as item 9 — the walks are nanoseconds. Code-quality observation, not a perf win. Skipping.
* **Item 18** (Read / ReadForRoute dedup): real code-quality win (the two methods share ~80 % of their body), 0 % CPU win. Not pursuing in an efficiency pass; would be appropriate work in a future refactor pass.
* **Item 19** (centralised scratch-buffer pattern): same shape as 18. Refactor, not perf.
* **Item 20** (ArrayPool for outbound UDP): I had this wrong in the original analysis. The send path was ALREADY converted from `packet.ToArray()` per-send to span-based `Socket.SendTo` in the May 11 refactor (see comment in `AudioSender.SendToAll`). `SenderLane.outboundScratch` is pre-allocated once per lane (2 KB byte buffer, written via spans, reused). No per-packet allocation exists to be eliminated. ALREADY DONE before today.
* **Item 21** (cheaper Tanh limiter): triggers only at sample magnitudes above 0.9. Real rate is well under 1 % of samples on normal content. Sub-0.1 % CPU. Skipping.
* **Item 22** (cheaper resampler mode): WdlResampler is already configured in `interp:true, filtercnt:0, sinc:false` — its cheapest mode. ALREADY DONE.
* **Item 25** (gate Stopwatch reads on DiagnosticsGate): about 10 KB/s of `Stopwatch.GetTimestamp()` reads happen even when logs are off. Each read is a couple ns. Below measurable. Skipping.
### Bottom line
The original 50-item list overweighted some items (items 8, 15, 17, 20 in particular). After today's work and honest re-measurement, the items that have actually moved the meter are:
* Item 51 (Opus native binding): 4,500 KB/s on the Opus path ✅
* Items 4, 6, 7 (small audio-thread allocs + ASIO probe rate): 20-30 KB/s ✅
* Items 14, 16 (heartbeat + discovery): a few KB/s ✅
* Legacy cleanup items 30, 34, 35, 36: zero perf win, ~120 lines removed ✅
The remaining items in the original list are now categorised honestly:
* **Already done before today**: items 20 (UDP allocs), 22 (resampler mode)
* **Code quality, not perf**: items 13, 18, 19
* **Below measurable**: items 5, 9, 10, 17, 21, 25
* **Real but too small / wrong shape**: items 8, 15
* **Real wins but risky**: items 11, 12 (SIMD) — only revisit if we have a CPU complaint
* **Big remaining lever, requires real project**: replacement of NAudio for direct WASAPI/ASIO P/Invoke. Year-scale work. Not on the table now.
**RemSound's efficiency is in a healthy state.** Steady-state PCM-sending: 1.6-4.7 % CPU, 150 KB/s allocs. Receive side: 10 % CPU (mostly NAudio + ASIO host thread, not ours), 178 KB/s. Opus is no longer special. No further efficiency work is recommended in this round; the next time someone surfaces a real performance complaint, this document should be the starting point for measuring before guessing.
+50 -7
View File
@@ -20,14 +20,57 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary> /// updates" path.</summary>
private const string ReleaseNotes = private const string ReleaseNotes =
""" """
RemSound v2.1 RemSound v2.2
Automatic router setup for internet streaming, a small A maintenance release that makes RemSound use less of
notice before background updates install, a "lock this your computer's CPU and memory, especially when sending
profile" option for users who don't want close prompts, with the Opus codec. No new features to learn, no
and a fix for the "no sound after the laptop wakes up" settings have changed, and audio sounds exactly the same.
problem. No wire-format or audio-pipeline changes Wire format and audio pipeline are unchanged every
v1.5 through v2.1 peers interoperate. 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: What's new:
* Automatic router port opening (UPnP). RemSound can * Automatic router port opening (UPnP). RemSound can
+55 -27
View File
@@ -359,7 +359,15 @@ public sealed class MainForm : Form
// threads (which run on separate MMCSS-boosted threads). The listbox itself is only // 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 // rebuilt when the (id, name) signature actually changes, so NVDA isn't pestered on every
// tick — only when a device truly came or went. // 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 // 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 // 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), // (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 // 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 // DiagnosticsGate.Enabled (i.e. logs on); otherwise SnapshotLogIfDue early-outs before
// touching these. // touching these.
private long prevDiagDriftDrops; // prevDiagDriftDrops / prevDiagDriftReps removed 2026-05-23. Drift drop/repeat counters
private long prevDiagDriftReps; // were dead since the Phase-4 fixed-ratio resampler design (always zero); diag columns
// are gone too.
private long prevDiagConceal; private long prevDiagConceal;
private long prevDiagShortRead; private long prevDiagShortRead;
private long prevDiagTrimFires; private long prevDiagTrimFires;
@@ -412,6 +421,10 @@ public sealed class MainForm : Form
private int prevDiagGc0Count; private int prevDiagGc0Count;
private int prevDiagGc1Count; private int prevDiagGc1Count;
private int prevDiagGc2Count; 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 // 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 // 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. // >0 = real anomalous samples in RemSound's output. ~0 = clean output.
// sampleStepMax = raw peak step magnitude (false-positive prone on bright // sampleStepMax = raw peak step magnitude (false-positive prone on bright
// music; informational only). // music; informational only).
var driftDrops = receiver.DriftDropFrames; // driftDrops / driftReps / driftAccumulator readings removed 2026-05-23 along
var driftReps = receiver.DriftRepeatFrames; // with their dead accessors. The Phase-4 fixed-ratio resampler design never
// Per-second deltas for the same counters — easier to read at a glance than // increments those counters; the columns were always zero. filteredErrorFrames
// ever-growing cumulative numbers. driftDropΔ + driftRepΔ tell us how fast // below is the still-useful "where the buffer is sitting on average" signal —
// the corrector is firing right now. concealΔ tells us how many real underruns // computed every Read by the active LP filter.
// 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".
var concealNow = receiver.ConcealmentFires; var concealNow = receiver.ConcealmentFires;
var shortReadNow = receiver.ShortReadFires; var shortReadNow = receiver.ShortReadFires;
var driftDropDelta = driftDrops - prevDiagDriftDrops; prevDiagDriftDrops = driftDrops;
var driftRepDelta = driftReps - prevDiagDriftReps; prevDiagDriftReps = driftReps;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow; var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow; var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires; var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state (not deltas) — current LP-filtered drift error and accumulator // Live state — current LP-filtered drift error. Negative = buffer running below
// value. Both let us see "where the corrector thinks the buffer is" between // target on average; positive = above.
// explicit drop/repeat events. filtErr negative = buffer running below target
// on average; positive = above. driftAcc near 0 = corrector idle; near ±1 =
// about to fire.
var filteredErrorFrames = receiver.FilteredDriftErrorFrames; var filteredErrorFrames = receiver.FilteredDriftErrorFrames;
var driftAccumulator = receiver.DriftAccumulator;
// 2026-05-11 added timing-split metrics: // 2026-05-11 added timing-split metrics:
// emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send) // emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send)
// sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only) // 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 // thread, kernel batching, GC pause — rather than the sender stalling or
// RemSound's own decode/dispatch chain. 2026-05-21. // RemSound's own decode/dispatch chain. 2026-05-21.
var rxNetGapMs = receiver.TakeMaxInterPacketGapMs(); var rxNetGapMs = receiver.TakeMaxInterPacketGapMs();
// fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single // fanCacheMs reading + column removed 2026-05-23. The FanOutSource was retired
// active render lane should sit at ~0; non-zero says the FanOut is sitting on // mid-May (each lane reads its own filtered PlayoutEngine source directly); the
// samples that aren't reaching the audio output, i.e. extra perceived latency // measurement always returned 0 and surfaced an unhelpful diag column.
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the // GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the
// previous tick gives the per-second collection count per generation. Gen-0 // previous tick gives the per-second collection count per generation. Gen-0
// collections are cheap (microseconds); Gen-1 takes longer; Gen-2 / LOH can // 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 gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now; var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now; 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 // Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced: // pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero = // 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 " + 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} " + $"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} " + $"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} " + $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " + $"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} " + $"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} " + $"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB: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 gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now; var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now; 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( logFile.Event(
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " + $"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " + $"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} " + $"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " + $"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " + $"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}"); $"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
} }
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Diagnostics;
namespace RemSound.App;
/// <summary>
/// Process-wide CPU / memory / allocation / GC meter. Sampled once a second by the diag-
/// log emitter (gated behind <see cref="RemSound.Core.DiagnosticsGate"/>) so we get a
/// continuous baseline of "how heavy is RemSound right now?" alongside every audio-pipeline
/// stat we already track. Item 1 + 3 of <c>RemSoundefficiency.md</c> — the "build the
/// measurement layer first" finding.
///
/// All readings are deltas since the previous <see cref="Take"/> call, so consumers see
/// "this second's CPU" not "since process start". The first call returns zeros for the
/// delta-based fields (no previous sample to compare to) and the steady-state fields
/// already meaningful at that point (memory, working set).
///
/// Threading: <see cref="Take"/> is called from the App's status-tick handler on the UI
/// thread. Snapshot fields are mutated by that same single thread; no locks needed.
/// </summary>
internal sealed class ProcessSelfMeter
{
private TimeSpan prevTotalCpu;
private long prevAllocBytes;
private DateTime prevSampleUtc;
// Cached Process handle. Process.GetCurrentProcess() allocates a new object each call
// and the underlying handle is the same for the process lifetime — caching it saves an
// allocation per Take.
private readonly Process selfProcess = Process.GetCurrentProcess();
/// <summary>One-second meter reading.</summary>
/// <param name="CpuPercentOneCore">CPU used in the last sample interval as a percentage
/// of one CPU core (so a fully-loaded core reads 100, two cores read 200, etc.). Zero
/// on the first call (no previous sample). Includes time across all of the app's
/// threads — kernel + user.</param>
/// <param name="ManagedHeapMb">Managed heap occupancy in megabytes right now. The
/// .NET garbage collector's view of "stuff RemSound is holding"; doesn't include
/// unmanaged buffers held via NAudio / Concentus / etc.</param>
/// <param name="WorkingSetMb">Working set in megabytes — what Task Manager shows for
/// the process. Includes managed heap, unmanaged buffers, and pages currently resident.</param>
/// <param name="AllocatedKbPerSecond">Bytes allocated to the managed heap in this
/// interval, divided by 1024 and normalised to per-second. A steady-state RemSound
/// should run in the single-digit-kilobytes-per-second range; sustained megabytes is
/// a leak somewhere in the hot path.</param>
/// <param name="ElapsedMs">Wall-clock milliseconds since the previous sample, so the
/// caller can sanity-check the delta calculation. Roughly 1000 in steady state.</param>
public readonly record struct Snapshot(
double CpuPercentOneCore,
double ManagedHeapMb,
double WorkingSetMb,
double AllocatedKbPerSecond,
double ElapsedMs);
public Snapshot Take()
{
var now = DateTime.UtcNow;
// TotalProcessorTime is "user + kernel time across every thread", refreshed lazily.
// Refresh() asks the OS for the current value; without it the property is sticky
// from the first access. Done explicitly so the math below is meaningful.
selfProcess.Refresh();
var totalCpu = selfProcess.TotalProcessorTime;
var workingSet = selfProcess.WorkingSet64;
// GC.GetTotalAllocatedBytes(precise: true) is the official .NET counter for
// "total bytes allocated across all threads since process start". precise: true
// forces a fast cross-thread sync; the cost is a thread-list walk (cheap). We
// need precise=true because the audio threads allocate too and we want their
// contribution included.
var totalAllocBytes = GC.GetTotalAllocatedBytes(precise: true);
// GetTotalMemory(false) doesn't trigger a collection; we just want the current
// size of the heap as the GC knows it.
var managedHeapBytes = GC.GetTotalMemory(false);
double cpuPercent = 0;
double allocKbps = 0;
double elapsedMs = 0;
if (prevSampleUtc != default)
{
elapsedMs = (now - prevSampleUtc).TotalMilliseconds;
if (elapsedMs > 0)
{
var cpuDeltaMs = (totalCpu - prevTotalCpu).TotalMilliseconds;
cpuPercent = cpuDeltaMs / elapsedMs * 100.0;
var allocDelta = totalAllocBytes - prevAllocBytes;
allocKbps = allocDelta / 1024.0 * (1000.0 / elapsedMs);
}
}
prevTotalCpu = totalCpu;
prevAllocBytes = totalAllocBytes;
prevSampleUtc = now;
return new Snapshot(
CpuPercentOneCore: cpuPercent,
ManagedHeapMb: managedHeapBytes / (1024.0 * 1024.0),
WorkingSetMb: workingSet / (1024.0 * 1024.0),
AllocatedKbPerSecond: allocKbps,
ElapsedMs: elapsedMs);
}
}
+17 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. --> is what the About dialog and the updater both read. -->
<Version>2.1.0</Version> <Version>2.2.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -38,6 +38,22 @@
manual port forwarding. Cross-protocol — picks whichever the router speaks. Used manual port forwarding. Cross-protocol — picks whichever the router speaks. Used
under the AppConfig.UpnpEnabled toggle, off by default. --> under the AppConfig.UpnpEnabled toggle, off by default. -->
<PackageReference Include="Mono.Nat" Version="3.0.4" /> <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>
<ItemGroup> <ItemGroup>
+15 -7
View File
@@ -63,6 +63,12 @@ public sealed class HeartbeatService : IDisposable
private CancellationTokenSource? cts; private CancellationTokenSource? cts;
private Task? sendTask; private Task? sendTask;
private uint sequence; 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> /// <summary>
/// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but /// 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; foreach (var p in targets) p.FirstPingSentUtc ??= nowUtc;
} }
// Build packet. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable // Build packet directly into the reusable outboundPingBuffer instead of stack-
// in any future stream-aware filter; sequence increments locally per send. // allocating + ToArray(). Same wire format, no per-call allocation. SendPings runs
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize]; // 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 seq = Interlocked.Increment(ref sequence);
var tickMs = monotonic.ElapsedMilliseconds; var tickMs = monotonic.ElapsedMilliseconds;
RemPacket.WriteHeader(packet, RemPacketType.Heartbeat, 0xFFFF, seq); var packetSpan = outboundPingBuffer.AsSpan();
RemPacket.WriteHeartbeatPayload(packet[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs); RemPacket.WriteHeader(packetSpan, RemPacketType.Heartbeat, 0xFFFF, seq);
var bytes = packet.ToArray(); RemPacket.WriteHeartbeatPayload(packetSpan[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs);
foreach (var p in targets) foreach (var p in targets)
{ {
try 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")}"); onDiagnostic?.Invoke($"send seq={seq} to={p.AudioEndpoint} {(ok ? "ok" : "FAILED")}");
} }
catch (Exception ex) catch (Exception ex)
+61 -12
View File
@@ -39,6 +39,15 @@ public sealed class PeerDiscoveryService : IDisposable
// reference once per tick. Volatile-write semantics via the assignment under the gate are // 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. // sufficient because we only ever swap the reference, never mutate in place.
private IReadOnlyList<IPAddress> unicastTargets = []; 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; public event Action? PeersChanged;
@@ -70,6 +79,15 @@ public sealed class PeerDiscoveryService : IDisposable
announcer = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true }; 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)); listenTask = Task.Run(() => ListenLoop(cts.Token));
announceTask = Task.Run(() => AnnounceLoop(cts.Token)); announceTask = Task.Run(() => AnnounceLoop(cts.Token));
} }
@@ -105,6 +123,12 @@ public sealed class PeerDiscoveryService : IDisposable
public void Stop() public void Stop()
{ {
if (networkChangeHandler is not null)
{
try { NetworkChange.NetworkAddressChanged -= networkChangeHandler; }
catch { /* ignore — best-effort unsubscribe */ }
networkChangeHandler = null;
}
cts?.Cancel(); cts?.Cancel();
listener?.Dispose(); listener?.Dispose();
announcer?.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 }; // Fast path: cache is current.
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) if (Volatile.Read(ref broadcastCacheDirty) == 0)
{ {
if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; return cachedBroadcastAddresses;
foreach (var unicast in ni.GetIPProperties().UnicastAddresses) }
// 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; if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
var addr = unicast.Address.GetAddressBytes(); foreach (var unicast in ni.GetIPProperties().UnicastAddresses)
var mask = unicast.IPv4Mask.GetAddressBytes(); {
var bcast = new byte[4]; if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue;
for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]); var addr = unicast.Address.GetAddressBytes();
addresses.Add(new IPAddress(bcast)); 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() private void PruneExpiredPeers()
+14 -52
View File
@@ -52,26 +52,14 @@ public enum RemoteControlKind : byte
SystemMuteToggle = 5, SystemMuteToggle = 5,
} }
[Flags] // KeepAliveCapabilities / KeepAliveKind / KeepAliveInfo + the KeepAlivePayloadSize +
public enum KeepAliveCapabilities : byte // WriteKeepAlivePayload / TryReadKeepAlive methods that lived here were removed 2026-05-23.
{ // They date from before HeartbeatService (which arrived 2026-05-06). After HeartbeatService
None = 0, // went in, no code in RemSound ever wrote or read a KeepAlive packet again — they were dead
CanSend = 1, // code carried through 16 releases. RemPacketType.KeepAlive = 3 and the silent-drop dispatch
CanReceive = 2, // 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.
public enum KeepAliveKind : byte
{
Heartbeat = 1,
Ack = 2,
}
public readonly record struct KeepAliveInfo(
Guid SessionId,
KeepAliveKind Kind,
KeepAliveCapabilities Capabilities,
AudioTransportCodec Codec,
long UnixTimeMilliseconds);
/// <summary> /// <summary>
/// Wire format for RemSound packets. Header is 12 bytes; body length is implied by the UDP datagram. /// 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 /// 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> /// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
public const int FormatPayloadExtendedSize = 36; 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> /// <summary>
/// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 8 bytes originator-monotonic /// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 8 bytes originator-monotonic
/// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent). /// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent).
@@ -179,24 +168,8 @@ public static class RemPacket
return FormatPayloadExtendedSize; return FormatPayloadExtendedSize;
} }
public static int WriteKeepAlivePayload(Span<byte> destination, KeepAliveInfo info) // WriteKeepAlivePayload removed 2026-05-23 — dead since HeartbeatService landed
{ // 2026-05-06. See top-of-file comment.
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;
}
public static bool TryReadHeader(ReadOnlySpan<byte> packet, out RemPacketType type, out ushort streamId, out uint sequence) 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; return true;
} }
public static bool TryReadKeepAlive(ReadOnlySpan<byte> payload, out KeepAliveInfo info) // TryReadKeepAlive removed 2026-05-23 — dead since HeartbeatService landed 2026-05-06.
{ // See top-of-file comment.
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;
}
} }
/// <summary> /// <summary>
+24 -22
View File
@@ -248,20 +248,25 @@ public sealed class AudioReceiver : IDisposable
/// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary> /// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary>
public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs(); public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in /// <summary>Cumulative milliseconds the network receive thread spent inside packet-
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the /// handler work since the last call (drain-on-read pattern). Diag log emits this as
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a /// recvMs per second — a direct read of how busy the network thread is. Item 2 of
/// render lane is holding samples (slow consumer holding back compaction, or the fast /// RemSoundefficiency.md. Resets on read.</summary>
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets public double TakeReceiveWorkMs() =>
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly listener.TakeCumulativeOnPacketTicks() * 1000.0 / Stopwatch.Frequency;
/// inflating latency on either lane.</summary>
public int TakeMaxFanOutCacheMs() /// <summary>Cumulative milliseconds the audio render threads spent inside
{ /// <see cref="PlayoutEngine.Read"/> / <see cref="PlayoutEngine.ReadForRoute"/>
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec. /// (per-session mix + volume + limiter + pack-to-bytes) since the last call. Diag log
const int MixBytesPerSecond = 48000 * 2 * 4; /// emits this as renderMs per second. Resets on read. 2026-05-22.</summary>
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0; public double TakeRenderWorkMs() =>
return bytes * 1000 / MixBytesPerSecond; 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 string OutputDeviceName => multiOutput.ActiveDeviceSummary;
public int CurrentBufferMs => playoutEngine.CurrentBufferMs; public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
public int TargetLatencyMs => playoutEngine.TargetLatencyMs; public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
@@ -412,11 +417,9 @@ public sealed class AudioReceiver : IDisposable
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes; public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes; public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
public long TrimFireCount => playoutEngine.AggregateTrimFireCount; public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been // DriftDropFrames / DriftRepeatFrames accessors removed 2026-05-23. They aggregated
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout // Phase-2 splice-corrector counters that the Phase-4 fixed-ratio resampler design never
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary> // increments. Always-zero. Surfaced two unhelpful diag-log columns that are now gone.
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
/// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible /// <summary>Cumulative count of FULL-empty playout reads (framesRead == 0) — the audible
/// underrun events that trigger noise-burst concealment + fade-in. Separated from /// underrun events that trigger noise-burst concealment + fade-in. Separated from
/// <see cref="Underruns"/> (which conflates full and partial short reads) so the diag /// <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, /// <summary>Live LP-filtered drift error of the primary active session (stereo frames,
/// signed). Negative = buffer running below target on average; positive = above.</summary> /// signed). Negative = buffer running below target on average; positive = above.</summary>
public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames; public double FilteredDriftErrorFrames => playoutEngine.PrimaryFilteredDriftErrorFrames;
/// <summary>Live drift integrator accumulator of the primary session. Crosses ±1 to fire // DriftAccumulator removed 2026-05-23. Phase-4 fixed-ratio resampler never sets an
/// a drop / repeat correction.</summary> // integrator value; always returned 0. Removed alongside the driftAcc= diag column.
public double DriftAccumulator => playoutEngine.PrimaryDriftAccumulator;
/// <summary>Take the worst single-sample step out of the ring buffer (after decode + /// <summary>Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.</summary> /// SessionPlayout.Write, before resampler) since the last call.</summary>
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep(); public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
@@ -89,12 +89,11 @@ internal sealed class CompositeRenderBackend : IRenderBackend
public bool IsRunning => started; public bool IsRunning => started;
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads // TakeMaxFanOutCacheBytes removed 2026-05-23. The FanOutSource architecture was retired
/// per-lane sources directly with no intermediate cache. Kept on the surface so the // in mid-May when each lane got its own filtered PlayoutEngine source — there's no shared
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero // cache to measure any more, so the method always returned 0. The receiver-side
/// rather than disappearing. Can be removed once we're confident the per-lane wiring // pass-through (AudioReceiver.TakeMaxFanOutCacheMs) and the fanCacheMs= diag column were
/// is the right shape long-term.</summary> // removed alongside it.
public int TakeMaxFanOutCacheBytes() => 0;
public string ActiveDeviceSummary public string ActiveDeviceSummary
{ {
+34 -17
View File
@@ -38,6 +38,14 @@ internal sealed class MultiOutputPlayout : IRenderBackend
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
private readonly byte[] frameScratch = new byte[FrameBytes]; private readonly byte[] frameScratch = new byte[FrameBytes];
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); 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 CancellationTokenSource? cts;
private Task? produceTask; private Task? produceTask;
@@ -95,6 +103,10 @@ internal sealed class MultiOutputPlayout : IRenderBackend
foreach (var o in outputs.Values) DisposeOutput(o); foreach (var o in outputs.Values) DisposeOutput(o);
outputs.Clear(); 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 */ } 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) if (nextTickStopwatch > now)
{ {
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50); 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; continue;
} }
@@ -188,22 +211,16 @@ internal sealed class MultiOutputPlayout : IRenderBackend
} }
nextTickStopwatch += ticksPerFrame; nextTickStopwatch += ticksPerFrame;
// Snapshot the buffers under the gate so we don't iterate a mid-mutation dict. // Read the pre-built snapshot. Volatile load — no lock, no allocation per
// Also skip the source.Read entirely when no outputs are ticked: in // tick. SetOutputDevices rebuilds the snapshot under the gate whenever the
// BothIndependent mode the source is a FanOutSource view shared with the ASIO // device set changes (rare event), so reads here see a consistent view.
// lane, and pulling here when WASAPI has nothing ticked makes the FanOut // Skip the source.Read entirely when no outputs are ticked: in BothIndependent
// consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the // mode the source is shared between WASAPI and ASIO, and pulling here when
// ASIO lane permanently reading from a cache 10 ms behind the source. That // WASAPI has nothing ticked would consume PlayoutEngine audio ahead of the
// showed up in test logs as fanCacheMs sustained at 1214 ms with bufAvg=0, // ASIO consumer. Pre-2026-05-23 this whole block ran under `lock (gate)` and
// and audibly as an extra 10 ms baked into the ASIO lane's perceived latency. // rebuilt the array on every tick — fixed as item 7 of RemSoundefficiency.md.
// The gate-then-read order matters; the previous order (read first, then var targets = outputBufferSnapshot;
// check outputs.Count) was the bug. if (targets.Length == 0) continue;
BufferedWaveProvider[] targets;
lock (gate)
{
if (outputs.Count == 0) continue;
targets = outputs.Values.Select(o => o.Buffer).ToArray();
}
var produced = source.Read(frameScratch, 0, FrameBytes); var produced = source.Read(frameScratch, 0, FrameBytes);
if (produced <= 0) continue; if (produced <= 0) continue;
+14
View File
@@ -43,6 +43,15 @@ internal sealed class NetworkListener : IDisposable
public int TakeMaxInterPacketGapMs() => public int TakeMaxInterPacketGapMs() =>
(int)(Interlocked.Exchange(ref maxInterPacketGapTicks, 0) * 1000 / Stopwatch.Frequency); (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) public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
{ {
this.onPacket = onPacket; this.onPacket = onPacket;
@@ -89,6 +98,7 @@ internal sealed class NetworkListener : IDisposable
// spurious huge gap. // spurious huge gap.
Interlocked.Exchange(ref lastReceiveTicks, 0); Interlocked.Exchange(ref lastReceiveTicks, 0);
Interlocked.Exchange(ref maxInterPacketGapTicks, 0); Interlocked.Exchange(ref maxInterPacketGapTicks, 0);
Interlocked.Exchange(ref cumulativeOnPacketTicks, 0);
} }
public void Dispose() => Stop(); public void Dispose() => Stop();
@@ -142,6 +152,10 @@ internal sealed class NetworkListener : IDisposable
long current; long current;
do { current = Volatile.Read(ref maxOnPacketTicks); } do { current = Volatile.Read(ref maxOnPacketTicks); }
while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current); 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 else
{ {
+47 -34
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net; using System.Net;
using NAudio.Wave; using NAudio.Wave;
using RemSound.Core; using RemSound.Core;
@@ -82,6 +83,15 @@ internal sealed class PlayoutEngine : IWaveProvider
private volatile bool asioLaneActive = true; private volatile bool asioLaneActive = true;
private volatile bool muted; private volatile bool muted;
private volatile float volume = 1f; 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. // 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 // 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 // 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> // AggregateDriftDropFrames + AggregateDriftRepeatFrames removed 2026-05-23 alongside the
public long AggregateDriftDropFrames // backing per-session fields. They surfaced two always-zero diag-log columns; both columns
{ // and accessors are gone.
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;
}
}
/// <summary>Cumulative count of full-empty reads (framesRead == 0) across all sessions. /// <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 /// 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. // PrimaryDriftAccumulator removed 2026-05-23 alongside SessionPlayout.DriftAccumulator
/// Crosses ±1 to fire a single-frame drop / repeat. Useful for "is the corrector about // (which always returned 0 under the Phase-4 resampler design) and the driftAcc= diag
/// to fire?" diagnosis.</summary> // log column.
public double PrimaryDriftAccumulator
{
get
{
var snap = sessionsSnapshot;
return snap.Length > 0 ? snap[0].DriftAccumulator : 0.0;
}
}
/// <summary>Worst single-sample step seen out of the ring buffer since the last call. /// <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 /// 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 /// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
/// long-standing cross-backend send/receive flow. /// long-standing cross-backend send/receive flow.
/// </summary> /// </summary>
public int Read(byte[] buffer, int offset, int count) => public int Read(byte[] buffer, int offset, int count)
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true); {
// 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> /// <summary>
/// Shared per-route render pull. Iterates the session snapshot, summing only those /// 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. /// per-tick stats columns are still the user-visible source of truth.
/// </summary> /// </summary>
internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics) 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); if (recordDiagnostics) diagnostics.RecordRenderRead(count);
+11 -19
View File
@@ -163,14 +163,11 @@ internal sealed class SessionPlayout : IDisposable
// we don't realloc on the hot path. // we don't realloc on the hot path.
private float[] resamplerInputScratch = new float[2048]; private float[] resamplerInputScratch = new float[2048];
// Retained for backward compatibility with the diagnostic surface — the diag log line // driftDropFramesTotal + driftRepeatFramesTotal fields removed 2026-05-23. They were
// still emits driftDrop / driftRep counters and the DriftAccumulator / FilteredError // Phase-2/3 splice-corrector counters that the Phase-4 fixed-ratio resampler design
// accessors. In the Phase-4 design these are all just informational metrics that stay // never incremented; they sat at zero and fed dead diag-log columns that have also been
// at zero / track the same buffer-vs-target offset, but old log parsers don't break. // removed. The current corrector's "where is the buffer" signal is filteredErrorFrames
// Explicit zero init so the compiler doesn't flag them as never-assigned when the // (below) — that one IS still active and IS still surfaced via FilteredDriftErrorFrames.
// Phase-4 design no longer increments them anywhere.
private long driftDropFramesTotal = 0;
private long driftRepeatFramesTotal = 0;
// Live state for the diag log — the current buffer-level offset from target, low-pass // 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". // filtered. Lets the diag line continue to surface "where the buffer is sitting".
// Updated each Read; no longer drives any correction logic itself. // 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; private const double DriftFilterTimeConstantSec = 2.0;
// Number of stereo frames each side of a splice point that get blended when a drop or // 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 // 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 // DriftDropFramesTotal / DriftRepeatFramesTotal accessors removed 2026-05-23 alongside
// surface (the Phase-4 resampler doesn't increment them, so they stay flat at the // their backing fields — they only ever surfaced two always-zero columns in the diag log,
// last value from any pre-Phase-4 fallback path — informationally that's "the splice // and the columns have been removed too.
// 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);
/// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the /// <summary>Diagnostic accessor — current smoothed sender-rate-ratio applied to the
/// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running /// resampler. 1.0 = no resampling (matched clocks). Values like 1.0002 = sender running
/// 200 ppm faster than receiver; 0.9998 = 200 ppm slower.</summary> /// 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 /// 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> /// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
public double FilteredDriftErrorFrames => filteredErrorFrames; public double FilteredDriftErrorFrames => filteredErrorFrames;
/// <summary>Legacy diag accessor — the Phase-2 / Phase-3 integrator accumulator is no // DriftAccumulator accessor removed 2026-05-23. The Phase-4 fixed-ratio resampler design
/// longer used in the Phase-4 resampler design. Always returns 0. Kept on the surface // never sets an integrator accumulator value; the property always returned 0. Removed
/// so MainForm's existing diag log line still compiles; can be removed once the diag // along with the driftAcc= diag column.
/// columns are pruned.</summary>
public double DriftAccumulator => 0.0;
public IPEndPoint Endpoint { get; } public IPEndPoint Endpoint { get; }
/// <summary>The stream ID this session was opened for. Sessions are keyed by /// <summary>The stream ID this session was opened for. Sessions are keyed by
+18 -1
View File
@@ -68,6 +68,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// is via Interlocked which provides its own memory barriers (no need for volatile). // is via Interlocked which provides its own memory barriers (no need for volatile).
private long lastCallbackTimestamp; private long lastCallbackTimestamp;
private int maxCallbackGapMs; 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) 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 TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer(); public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer(); public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public long TakeCumulativeCaptureTicks() => Interlocked.Exchange(ref cumulativeCaptureTicks, 0);
public bool IsRunning => asio is not null; public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount); 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 // 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 // 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. // 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(); var now = Stopwatch.GetTimestamp();
workStart = now;
var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now); var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now);
if (prev != 0) if (prev != 0)
{ {
@@ -312,6 +321,14 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
} }
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, stereoFloats)); 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 /// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
+20
View File
@@ -110,11 +110,17 @@ public sealed class AudioSender : IDisposable
// Both are reset on each Take() so the SNAP gets per-second peaks. // Both are reset on each Take() so the SNAP gets per-second peaks.
private long maxEmitTicks; private long maxEmitTicks;
private long maxSendCallTicks; 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) internal void RecordEmitTicks(long ticks)
{ {
long current; long current;
do { current = Volatile.Read(ref maxEmitTicks); } do { current = Volatile.Read(ref maxEmitTicks); }
while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current); while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current);
Interlocked.Add(ref cumulativeEmitTicks, ticks);
} }
internal void RecordSendCallTicks(long 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 TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 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). // 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 // 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; 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) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
lock (gate) lock (gate)
+8
View File
@@ -68,6 +68,14 @@ internal interface ICaptureBackend : IDisposable
/// Resets on read. Backends that can't sensibly expose raw samples return 0.</summary> /// Resets on read. Backends that can't sensibly expose raw samples return 0.</summary>
float TakeMaxRawCaptureStepWithinBuffer(); 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); void Start(IReadOnlyList<CaptureSourceSpec> specs);
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes /// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
+26 -2
View File
@@ -120,6 +120,13 @@ internal sealed class MixingEngine : ICaptureBackend
public float TakeMaxRawCaptureStep() => 0f; public float TakeMaxRawCaptureStep() => 0f;
public float TakeMaxRawCaptureStepCrossBuffer() => 0f; public float TakeMaxRawCaptureStepCrossBuffer() => 0f;
public float TakeMaxRawCaptureStepWithinBuffer() => 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> /// <summary>
/// Starts the mix loop with the given initial source set. If already running, the existing /// 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) if (nextTickStopwatch > now)
{ {
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50); 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; continue;
} }
@@ -330,8 +343,18 @@ internal sealed class MixingEngine : ICaptureBackend
var localMixer = mixer; var localMixer = mixer;
if (localMixer is null) continue; 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); 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 // Hard-clamp mixed sum to [-1, 1] to prevent encoder clipping when multiple loud
// sources sum past unity. Counts clipped samples for diagnostics. // sources sum past unity. Counts clipped samples for diagnostics.
@@ -346,6 +369,7 @@ internal sealed class MixingEngine : ICaptureBackend
Interlocked.Increment(ref mixTickCount); Interlocked.Increment(ref mixTickCount);
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, read)); onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, read));
if (diag) Interlocked.Add(ref cumulativeMixLoopTicks, Stopwatch.GetTimestamp() - workStart);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
+13 -9
View File
@@ -7,6 +7,14 @@ namespace RemSound.Sender;
/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio. /// 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 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. /// frame size the sender announces in the format packet — no coordination required.
///
/// 2026-05-23 — switched from the <c>Encode(ReadOnlySpan&lt;short&gt;...)</c> overload to the
/// float overload after the first allocation-rate measurement (Part C, item 51 of
/// RemSoundefficiency.md). The float overload skips one internal float→short→float round trip
/// inside Concentus (CELT runs in float natively in RESTRICTED_LOWDELAY mode), and lets us
/// drop our own per-sample Math.Clamp + cast loop — Concentus' float overload does its own
/// out-of-range clipping per its XML docs. Same encoder configuration, same bitrate, same
/// frame size, same audio output bit-for-bit.
/// </summary> /// </summary>
internal sealed class OpusEncoderState : IDisposable internal sealed class OpusEncoderState : IDisposable
{ {
@@ -14,7 +22,6 @@ internal sealed class OpusEncoderState : IDisposable
private const int PacketBufferBytes = 4000; private const int PacketBufferBytes = 4000;
private readonly IOpusEncoder encoder; private readonly IOpusEncoder encoder;
private readonly short[] pcm16Scratch;
private readonly byte[] packetScratch = new byte[PacketBufferBytes]; private readonly byte[] packetScratch = new byte[PacketBufferBytes];
public int FrameMilliseconds { get; } public int FrameMilliseconds { get; }
@@ -27,7 +34,6 @@ internal sealed class OpusEncoderState : IDisposable
// share). We expose 10 and 20 as the user-selectable choices. // share). We expose 10 and 20 as the user-selectable choices.
FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60); FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60);
FrameSizePerChannel = 48000 * FrameMilliseconds / 1000; FrameSizePerChannel = 48000 * FrameMilliseconds / 1000;
pcm16Scratch = new short[FrameSizePerChannel * Channels];
encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null); encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null);
encoder.Bitrate = bitrate; encoder.Bitrate = bitrate;
@@ -55,13 +61,11 @@ internal sealed class OpusEncoderState : IDisposable
throw new ArgumentException($"Expected {FrameSizePerChannel * Channels} samples, got {stereoFloats.Length}", nameof(stereoFloats)); throw new ArgumentException($"Expected {FrameSizePerChannel * Channels} samples, got {stereoFloats.Length}", nameof(stereoFloats));
} }
for (var i = 0; i < stereoFloats.Length; i++) // 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
var clamped = Math.Clamp(stereoFloats[i], -1f, 1f); // to run on every sample before calling the int16 overload is no longer needed. That
pcm16Scratch[i] = (short)(clamped * 32767f); // also lets us delete the pcm16Scratch field entirely.
} return encoder.Encode(stereoFloats, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length);
return encoder.Encode(pcm16Scratch, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length);
} }
public ReadOnlySpan<byte> LastEncoded(int length) => packetScratch.AsSpan(0, 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 TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer(); public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer(); 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) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
@@ -247,6 +253,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
Interlocked.Add(ref bytesCaptured, e.BytesRecorded); Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
if (e.BytesRecorded <= 0) return; if (e.BytesRecorded <= 0) return;
var diag = RemSound.Core.DiagnosticsGate.Enabled;
var workStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
try try
{ {
// 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start). // 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start).
@@ -356,6 +364,16 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
lastError = ex.Message; lastError = ex.Message;
onDiagnostic?.Invoke($"push-wasapi: callback error: {ex.GetType().Name}: {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) private void OnRecordingStopped(object? sender, StoppedEventArgs e)