Bug: Andre reported audio latency feeling laggier after long sessions
on his Win10 desktop receiving Opus from his laptop. His 23-hour log
showed working set climbing from 83 MB at startup to 3.5 GB at the
end, with the managed heap staying tiny (~5-7 MB) the whole time.
CPU climbed alongside (from steady-state ~7% mid-session to peaks of
~60% by the end) and audio threads ended up doing ~4x the work they
did at the start. Andre's perception of latency drift was the CPU
pressure showing up in audio scheduling, not the buffer itself
growing (bufAvg stayed roughly stable at 25-28 ms).
Root cause: Concentus.Native (introduced in v2.2 / shipped in v3.0)
returns concrete NativeOpusDecoder / NativeOpusEncoder objects that
implement IDisposable and own native libopus state. Three call sites
were taking the IOpusDecoder / IOpusEncoder interface reference and
never calling Dispose:
* StreamSession.Dispose — comment literally said "IOpusDecoder has
no Dispose; nothing else to free", which was correct for the
pure-managed Concentus.OpusDecoder pre-v2.2 but stopped being
correct the moment we added the native binding
* OpusEncoderState.Dispose — same misleading comment, same bug
* SenderLane.OnCodecChanged — overwrote the existing encoder field
without disposing the old instance on codec change
Compounding factor: Program.Main sets GCSettings.LatencyMode =
GCLatencyMode.SustainedLowLatency to keep audio scheduling smooth
(it suppresses gen2 collections). That's correct for the hot path
but it ALSO suppresses the finalizer pass that would have released
the leaked native handles as a backstop. Because the managed heap
stayed tiny, the GC never saw enough pressure to force a gen2 pass
on its own, and the native state piled up indefinitely. Multi-output
receive multiplied the per-output growth.
Fix is in two parts:
1. Call (... as IDisposable)?.Dispose() at every release point —
StreamSession.Dispose, OpusEncoderState.Dispose,
SenderLane.OnCodecChanged before overwrite, AudioRecorder's
Concentus.Oggfile-backed OpusOggFileWriter.Dispose. The
as-IDisposable cast handles both the native and the pure-managed
path transparently (managed-only IOpusDecoder isn't IDisposable;
the as-cast yields null and the null-conditional is a no-op).
2. Periodic native-memory reaper in MainForm.SnapshotLogIfDue — once
every 300 snapshot ticks (~5 min), run
GC.Collect(2, Optimized, blocking, !compacting) +
WaitForPendingFinalizers on a background Task.Run so the gen2
work doesn't hitch the UI thread. Audio threads are separate and
unaffected. Serves as belt-and-braces for any future code path we
forget to wire and for cleaning up any per-call native scratch
the underlying library might accumulate that isn't owned by a
single .NET wrapper.
Expected behaviour after fix: working set settles around 100-200 MB
on a typical receive session and holds roughly flat for as long as
the app stays running. CPU stays at its early-session baseline
across multi-hour sessions. Andre's "latency drift" symptom should
disappear.
Wire format unchanged; same codec list, same UI, same defaults.
v3.0.2 talks to other v3.0.x peers exactly as v3.0 / v3.0.1 do.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RemSound
Low-latency peer-to-peer audio between two or more Windows PCs over UDP. Pick what each machine captures and what it plays back; audio flows directly between them, no central server.
Built for music collaboration over the internet, live monitoring across rooms in a house, podcast co-hosting, NVDA-Remote audio workflows, and anything else that wants "send the sound from this PC to that PC, fast".
Highlights
- WASAPI and ASIO side by side. Run them as two independent UDP streams at their own native latencies, or use WASAPI alone. ASIO drivers get hardware-clocked timing; WASAPI gets push-mode timing on single-source captures.
- Profiles. Save your full setup (device ticks, peers, codec, latency targets, hotkeys, ASIO driver) into one JSON file. Pick which profile to load at launch.
- Continuous auto-tune. Watches receive jitter and nudges the latency target to stay click-free without forcing you to overshoot. Independent per-lane in WASAPI+ASIO mode.
- Opus with inband FEC. Single-packet losses recover transparently — no click. PCM 24-bit 48 kHz is also available for clean LAN.
- Remote control hotkeys. Configurable global hotkeys can nudge a peer's RemSound volume or their Windows system master volume, opt-in on the receiver.
- Built-in self-updater. Optional GitHub-driven update check on a schedule you set.
- Designed for screen readers. Each control has a paired Alt+letter mnemonic. State changes raise the right UIA notifications. F1 anywhere opens the user manual.
Install
- Download the latest
RemSound-vX.Y.zipfrom Releases. - Extract somewhere it can write — e.g.
C:\RemSound\, yourDocuments, or a folder in your user profile. AvoidProgram Filesunless you grant write permission to the install folder (the self-updater needs to overwrite files in place). - Run
RemSound.exe. On first launch Windows Firewall will prompt — allow on private networks. - Open the user manual from the Help menu (or press F1) for the full walkthrough.
RemSound requires the .NET 10 Desktop Runtime. If it's not installed, Windows offers to fetch it on first launch. You can also install it from https://dotnet.microsoft.com/download/dotnet/10.0 (pick the "Windows x64 Desktop Runtime").
Updates
RemSound can check this repository's Releases page on a schedule (never, hourly, every 6 hours, every 24 hours) and either prompt you to install or do it silently. Configure via File → Preferences. You can also trigger a manual check from the Help menu or the same Preferences dialog.
Build from source
You need the .NET 10 SDK. The solution lives at RemSound.slnx.
cd D:\proj\RemSound
dotnet build -c Release
dotnet publish src\RemSound.App\RemSound.App.csproj -c Release
The publish output lands at src\RemSound.App\bin\Release\net10.0-windows\publish\. Copy its contents into a folder of your choice — or zip it for distribution. Don't enable PublishSingleFile or SelfContained=true; RemSound ships framework-dependent on purpose so the publish folder stays under 2 MB.
Project layout
src/RemSound.Core packet protocol, peer discovery, hotkeys, MMCSS, heartbeat, settings, AppConfig
src/RemSound.Sender capture → mix → encode → UDP send
src/RemSound.Receiver UDP receive → ring buffer → drift-corrected playout → render
src/RemSound.Harness console test program (1 sender → 1 receiver, no UI)
src/RemSound.App WinForms UI (sender + receiver + heartbeat + discovery + updater)
server/ optional Raspberry Pi / systemd-Linux relay bundle (see below)
Optional: running your own relay server
Two RemSound peers normally reach each other directly over your LAN, or via Tailscale across the internet. If neither of those work for your situation — for example one peer is behind a router that won't forward inbound UDP and you'd prefer not to use Tailscale — you can run a small Python relay on a publicly-reachable host (a Raspberry Pi at home with one UDP port forwarded works fine) and have both peers dial that.
The server/ folder in this repo is a self-contained bundle: relay script, systemd unit, install / uninstall / smoke-test scripts, and a step-by-step README. See server/README.md for the setup walkthrough.
Issues and feedback
Open an issue on the GitHub issues page. If reporting an audio problem, please tick File → Preferences → Enable logs, reproduce the issue, then attach the latest log file from logs\ next to RemSound.exe.
Licence
MIT. See LICENSE.