commit 17259438c6897b71faa9ee7ac4bc918520ded492 Author: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Wed May 13 15:08:31 2026 +0100 Initial commit: RemSound v1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..472c4d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Build output +bin/ +obj/ +dist/ + +# Local publish folder — used for hand-testing and for the self-updater's "_update" staging +# area. Release artefacts ship via the dist/ zip attached to the GitHub release; the publish +# folder is regenerated by build-release.ps1 from the source on every release. +publish/ + +# AI interaction preferences (project-local file used while authoring the source, not +# relevant to anyone cloning the repo). +CLAUDE.md + +# Dev notes — running development chronology and per-investigation plans. Useful locally +# for the author; not intended for the public repo. +HANDOVER.md +PLAN-*.md + +# IDE / editor +.vs/ +.vscode/ +.idea/ +*.user +*.suo + +# OS +Thumbs.db +.DS_Store + +# Misc +*.log +*.tmp +*.tmp.* +*.swp + +# Tokens / secrets — these should never be checked in +.gh-token.txt +gh-token.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..544d6c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ednun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70ae399 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# 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 + +1. Download the latest `RemSound-vX.Y.zip` from [Releases](https://github.com/Ednunp/RemSound/releases). +2. Extract somewhere it can write — e.g. `C:\RemSound\`, your `Documents`, or a folder in your user profile. Avoid `Program Files` unless you grant write permission to the install folder (the self-updater needs to overwrite files in place). +3. Run `RemSound.exe`. On first launch Windows Firewall will prompt — allow on private networks. +4. 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 (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`. + +```powershell +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) +``` + +## Issues and feedback + +Open an issue on the [GitHub issues page](https://github.com/Ednunp/RemSound/issues). 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`. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..cba0e81 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,22 @@ +# RemSound v1.0 + +Initial public release. + +## Highlights + +- Low-latency peer-to-peer audio over UDP. WASAPI for any Windows audio device, with a parallel ASIO lane for pro audio interfaces (Audient, Komplete Audio, Focusrite, RME). Each lane keeps its own native callback latency. +- Pick an ASIO driver from the dropdown at the top of the Audio inputs and outputs tab to bring ASIO into the pipeline; select **(none)** to run WASAPI-only. +- Profile system. Save your entire setup — device ticks, peers, codec, latency targets, hotkeys, ASIO driver choice — into a JSON file. Pick which profile to load at every launch. +- Continuous auto-tune on either lane. Watches receive jitter and nudges the latency target up or down to stay click-free without forcing you to overshoot. +- Opus inband FEC. Single-packet losses recover transparently in both Opus modes; you don't hear them at all. PCM is also available for clean LAN connections. +- Remote control. Configurable global hotkeys can nudge a peer's RemSound volume or their Windows default-output-device master volume, opt-in on the receiver. +- Built-in self-updater. Optionally polls GitHub for newer releases on a schedule you set; can install them silently if you want. + +## Install + +1. Download `RemSound-v1.0.zip` from this release. +2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`, etc.). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place. +3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. +4. Press F1 (or use the Help menu) for the user manual. + +Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch. diff --git a/RemSound.slnx b/RemSound.slnx new file mode 100644 index 0000000..86deb09 --- /dev/null +++ b/RemSound.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build-release.ps1 b/build-release.ps1 new file mode 100644 index 0000000..559aaa9 --- /dev/null +++ b/build-release.ps1 @@ -0,0 +1,42 @@ +# Build script for a tagged RemSound release. +# +# .\build-release.ps1 v1.0 +# +# Produces dist\RemSound-v1.0.zip, ready for `gh release create`. The asset name matches +# what RemSoundUpdater expects on the GitHub Releases page (RemSound-.zip); change +# RemSoundUpdater.AssetNameTemplate if you rename here. + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true, Position=0)] + [string]$Tag +) + +$ErrorActionPreference = 'Stop' +$repoRoot = $PSScriptRoot + +Write-Host "Cleaning publish staging..." -ForegroundColor Cyan +$stage = Join-Path $repoRoot 'src\RemSound.App\bin\Release\net10.0-windows\publish' +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + +Write-Host "Publishing framework-dependent..." -ForegroundColor Cyan +& dotnet publish (Join-Path $repoRoot 'src\RemSound.App\RemSound.App.csproj') -c Release | Out-Null +if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit $LASTEXITCODE)" } + +$distDir = Join-Path $repoRoot 'dist' +if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } + +$zipName = "RemSound-$Tag.zip" +$zipPath = Join-Path $distDir $zipName +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + +Write-Host "Zipping $zipName..." -ForegroundColor Cyan +Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zipPath -CompressionLevel Optimal + +$size = [math]::Round((Get-Item $zipPath).Length / 1MB, 2) +Write-Host "" +Write-Host "Built $zipPath ($size MB)" -ForegroundColor Green +Write-Host "" +Write-Host "Next:" +Write-Host " git add -A; git commit -m 'Release $Tag'; git push" +Write-Host " gh release create $Tag $zipPath --title `"$Tag`" --notes-file RELEASE_NOTES.md" diff --git a/connect.wav b/connect.wav new file mode 100644 index 0000000..bfe55d3 Binary files /dev/null and b/connect.wav differ diff --git a/disconnect.wav b/disconnect.wav new file mode 100644 index 0000000..df0d5e2 Binary files /dev/null and b/disconnect.wav differ diff --git a/readme.html b/readme.html new file mode 100644 index 0000000..fa8343e --- /dev/null +++ b/readme.html @@ -0,0 +1,815 @@ + + + + +RemSound user manual + + + + +

RemSound — user manual

+ +

RemSound is a Windows program for sending live audio between two or more computers with very low delay. You can think of it as a private, peer-to-peer audio link: each machine picks what it wants to capture and what it wants to play out, and audio flows directly between them over UDP.

+ +

It was built for music collaboration over the internet (a guitarist on one machine, a vocalist on another, listening to each other in real time), but works just as well for low-latency monitoring across rooms in a house, podcast co-hosting, or any other “send audio from this PC to that PC, fast” use case.

+ +

Table of contents

+
    +
  1. What RemSound does
  2. +
  3. Quick start
  4. +
  5. Profiles
  6. +
  7. The main window: menu bar + three tabs
  8. +
  9. Menus (File and Help)
  10. +
  11. Connectivity tab
  12. +
  13. Audio inputs and outputs tab
  14. +
  15. Audio profile tab
  16. +
  17. ASIO and WASAPI
  18. +
  19. Peers — finding and connecting
  20. +
  21. How the network works (LAN, WAN, Tailscale)
  22. +
  23. Latency and audio quality
  24. +
  25. Keyboard shortcuts
  26. +
  27. Global hotkeys (mute, volume, tray)
  28. +
  29. Remote control: adjusting a peer's listening volume from your end
  30. +
  31. Startup behaviour
  32. +
  33. Updating RemSound
  34. +
  35. Logs and diagnostics
  36. +
  37. Troubleshooting
  38. +
  39. Glossary
  40. +
+ +

1. What RemSound does

+ +

RemSound moves audio from one PC’s microphone or sound card to another PC’s speakers or audio interface, in close-to-real-time, over your network. It’s symmetric: each side runs the same program and chooses independently whether to send, receive, or both.

+ +

The basic flow

+ + + + + + +
StepWhat happens
1You tick “Send my audio” on the Audio inputs and outputs tab and pick which microphone or audio output you want captured.
2Your friend ticks “Receive audio” on the same tab and picks which speakers or headphones to play received audio through.
3One of you ticks the other in the Discovered peers list on the Connectivity tab (or types their IP address manually).
4Audio starts flowing. The other direction works exactly the same way, independently — both can speak at once.
+ +

There is no central server, no account, nothing in the cloud. Packets go straight from one machine to the other.

+ +

2. Quick start

+ +

Assume you and a friend both have RemSound running and a way to reach each other on the network (same Wi-Fi, same Tailscale account, etc.).

+ +
    +
  1. Launch RemSound. The first thing you’ll see is the profile picker. On a fresh install your only option is (Blank template) — select it and press Enter or click OK. Once you’ve saved a profile or two later on, this dialog is how you pick which one to load. See Profiles for the full story.
  2. +
  3. Once the main window opens, use Ctrl+Tab to move to the Audio inputs and outputs tab. Tick Receive audio (Alt+R), then tick the device you want incoming audio played through in WASAPI outputs for received sound (Alt+3).
  4. +
  5. On the same tab, tick Send my audio (Alt+S) and tick your microphone in WASAPI inputs to send (Alt+5).
  6. +
  7. Move to the Connectivity tab (Ctrl+Shift+Tab to go back left, or Ctrl+Tab from elsewhere). Find your friend in the Discovered peers (Alt+D) list and tick them. Or, if they aren’t appearing, click Add peer by IP (Alt+A) and type their address.
  8. +
  9. Have your friend do the same with you on their machine.
  10. +
  11. Within a second or two, both sides will hear each other.
  12. +
  13. Open the File menu (Alt+F) and choose Save as to give your setup a name. Next time you launch, picking that profile from the startup dialog restores all your settings, device ticks, peers, and connections in one go.
  14. +
+ +
+If you have a pro audio interface (Audient, Komplete Audio, RME, Focusrite, etc.): on the Audio inputs and outputs tab pick your driver in the ASIO driver (Alt+D) list to unlock its low-latency channels. Selecting a real driver makes the ASIO device lists appear; selecting (none) hides them again and the app runs WASAPI-only. See ASIO and WASAPI below. +
+ +

3. Profiles

+ +

RemSound saves your entire setup — device ticks, send / receive states, codec, packet size, smoothness, latency targets, hotkeys, ASIO driver choice, remembered peers, currently-connected peers — into one JSON file per profile. You pick which profile to load every time you launch the program. Tick a different combination of devices for “morning podcast” vs “evening jam session” and switch between them in two clicks.

+ +

The startup picker

+ +

When RemSound launches, the first thing you see is the profile selection dialog. It’s a listbox of profile names plus a synthetic (Blank template) entry at the top. The keys are deliberate:

+ + + + + + + + + + +
KeyAction
Up / DownMove between profiles in the list.
EnterLoad the highlighted profile (or blank template) and open the main window.
OK buttonSame as Enter.
DelDelete the highlighted profile, with a yes / no confirmation.
Browse… buttonPick a different folder to read profiles from. Useful if you keep your profiles in Dropbox or another sync folder so they roam between machines. The choice is remembered next time RemSound launches.
EscDeliberately ignored. Picking a profile is required to launch.
Alt+F4Closes the dialog and exits RemSound — equivalent to “I don’t want to start the program right now.”
+ +

The first profile in the list is highlighted by default, so a fresh install where you only have (Blank template) is just Enter to proceed.

+ +

What “Blank template” means

+ +

Blank template is a one-shot session with all defaults: nothing ticked in any device list, neither Receive nor Send checked, default codec, no ASIO driver selected, no remembered peers, default hotkeys. You’d pick it for a one-off session you don’t intend to save, or as the starting point for a new profile. The Save and Update button is intentionally hidden when on Blank template — there’s nothing to update, only to save-as-new.

+ +

Saving and updating

+ +

The File menu has two save items:

+ + + + + +
ItemWhat it does
File → Save (Ctrl+S)Overwrites the active profile with the current state. If you’re on Blank template, this falls through to Save as (because there’s no existing profile to overwrite).
File → Save as… (Alt+F, A)Always available. Prompts for a profile name. From Blank template this is how you create the first profile. From an existing profile this forks a copy under a new name and switches to the copy as the active profile.
+ +

The window title bar shows which profile is currently active: RemSound — Active profile: My session name. NVDA reads this on Alt+Tab.

+ +

Switching, renaming, and deleting

+ + + + + + +
ActionHow
Switch to a different profileFile → Open profile (Alt+F, O). Pick a JSON in the file picker. RemSound reloads under that profile.
Rename the active profileFile → Rename current profile (Alt+F, R). Prompts for the new name and renames the JSON on disk; window title updates immediately.
Delete a profileFile → Open profile, then right-click the entry in the Windows file picker and choose Delete. Hands the action off to Windows Explorer rather than reimplementing it inside RemSound.
+ +

Where profiles live on disk

+ +

One JSON file per profile. By default, stored at:

+ +
<RemSound folder>\profiles\<your machine name>\<profile name>.json
+ +

The per-machine subfolder keeps each computer’s profiles separate. If you used the Browse… button on the startup dialog to pick a different folder (e.g. inside Dropbox), profiles are stored directly under that folder — no per-machine subfolder, so two machines pointing at the same shared folder see exactly the same list.

+ +

You can also copy a profile JSON between machines: drop it into the other machine’s profile folder and it appears in their startup dialog. If the new machine doesn’t have the same hardware (different sound cards, different ASIO drivers), those device ticks are silently skipped at load time — RemSound won’t error or warn, the relevant lists just won’t have those entries ticked.

+ +
+Tip: profile JSON files are plain text and human-readable. If you ever need to manually edit one (e.g. to change a hotkey without launching the app), open it in any text editor. +
+ +

What’s NOT in profiles

+ +

A short list of things that intentionally aren’t profile-saved:

+ + +

4. The main window: menu bar + three tabs

+ +

The main window has three things stacked vertically:

+ +
    +
  1. A menu bar at the top with two menus — File (profile management, preferences, keyboard shortcuts, minimise to tray, exit) and Help (open the user manual, check for updates, About). See Menus.
  2. +
  3. A tab strip with three tabs — Connectivity, Audio inputs and outputs, Audio profile. Use Ctrl+Tab and Ctrl+Shift+Tab to move between them, or arrow left/right when focus is on the tab strip. Each tab has its own Alt+letter shortcuts that fire only when that tab is the visible one — the same letter can mean different things on different tabs without conflict.
  4. +
  5. A status footer updating once a second with connect uptime, peer count, send/receive activity, and heartbeat health.
  6. +
+ + + + + + +
TabWhat it’s for
ConnectivityConnected, discovered, and remembered peers. Add a peer by IP. Connection status read-out.
Audio inputs and outputsASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device-tick lists. Selecting a real driver in the picker brings up the ASIO device lists alongside the WASAPI ones; (none) hides them.
Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into Audio send parameters and Audio receive parameters sections so NVDA announces which group you’ve entered as you tab through.
+ + + +

File menu

+ +

Press Alt+F to open the menu, then the underlined letter for the item you want, or use the single-press shortcut from the table below.

+ + + + + + + + + + + +
ItemShortcutWhat it does
Open profile…Alt+F, OOpens a Windows file picker rooted at the profiles folder. Pick a JSON, RemSound reloads under that profile (window closes and reopens with all its device ticks, peers, and settings restored). To delete a profile from disk, right-click an entry inside the file picker and choose Delete — that hands the action off to Windows Explorer.
SaveCtrl+SOverwrites the active profile with the current state. If there’s no active profile (you’re on Blank template), this falls through to Save as automatically.
Save as…Alt+F, APrompts for a profile name and saves a copy. Use this to fork the current state under a new name, or to save the first time from Blank template.
Rename current profile…Alt+F, RRenames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename).
Minimise to trayAlt+F, MHides the window to the system tray. Reachable via the File menu chain (Alt+F to open the menu, then M for Minimise). To bring the window back, click the tray icon or use the configurable "Show or hide window" global hotkey (Keyboard shortcuts dialog, default Ctrl+Shift+F10).
Keyboard shortcuts…Ctrl+KOpens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends).
Preferences…Ctrl+POpens the Preferences dialog. Sticky machine-local choices live here: profile folder, mute connect/disconnect sounds, accept remote volume commands, startup behaviour, update-check frequency, manual check-for-updates button, silently install updates, enable logs, write logs now. Esc or the Close button dismisses.
ExitAlt+F, X (or Alt+F4)Closes RemSound. If there are unsaved profile changes you’ll be prompted first.
+ +

Help menu

+ +

Press Alt+H to open the menu, then the underlined letter for the item you want.

+ + + + + + +
ItemShortcutWhat it does
HelpF1 (anywhere), or Alt+H, HOpens this user manual in your default browser. F1 also works from inside every dialog (Preferences, Keyboard shortcuts, About, Startup behaviour) and from the startup profile picker before the main window has loaded.
Check for updatesAlt+H, CAsks the RemSound GitHub Releases page whether a newer build is available. If yes, you get a confirmation dialog with the release notes and a Yes / No to install. If you’re already on the latest version, an “you are running the latest version” popup confirms it. (For automatic background checks instead of pressing this button by hand, see Updating RemSound.)
About RemSoundAlt+H, ASmall modal dialog showing the current version and the latest release notes in a scrollable read-only box. Tab into the box and arrow up/down to read it under NVDA. Close (or Esc) dismisses.
+ +

6. Connectivity tab

+ +

This is where peers and logging live. Tab order on this tab:

+ + + + + + + + +
ControlShortcutWhat it does
Connected peersAlt+CPeers you currently have audio flowing with. Unticking a row disconnects that peer.
Discovered peersAlt+DPeers RemSound has heard from in the last few seconds — either via LAN broadcast or via direct unicast announcement (which works over Tailscale and any other VPN). Tick to connect.
Remembered peersAlt+RPeers you’ve previously connected to or manually added. Persists across sessions. Tick to reconnect.
Add peer by IPAlt+AOpens a small prompt for an IP address or hostname. Adds it to remembered and connects.
Connection statusAlt+SA read-only multi-line text box that summarises everything currently happening — connect uptime, peer count, send/receive packet rates, heartbeat health for each peer. Tab into it and arrow up/down to read it line by line under NVDA.
+ +

7. Audio inputs and outputs tab

+ +

This tab controls everything to do with which audio devices are involved. The ASIO driver picker at the top decides whether ASIO is in the pipeline at all; the Receive and Send sides each have their own master checkbox and device lists.

+ + + + + + + + + + + + +
ControlShortcutWhat it does
ASIO driverAlt+DListbox starting with (none). Pick (none) and the app runs in WASAPI-only mode; pick a real driver and the ASIO device lists below appear and the Audio profile tab gains a second ASIO-latency row. Arrowing between drivers swaps which interface RemSound talks to. On a machine with no ASIO drivers installed this control is hidden entirely.
Receive audioAlt+RMaster toggle for receiving. When off, no audio plays out regardless of which output devices are ticked.
WASAPI outputs for received soundAlt+3Tick which Windows audio outputs (speakers, headsets) should play received audio. Multiple ticks means received audio plays out of all of them simultaneously.
ASIO outputs for received soundAlt+1(Visible when an ASIO driver is selected.) Tick ASIO channel pairs to play received audio out of.
Set volume for all received audioAlt+VSlider: master volume for everything coming in. There is no per-device or per-source volume.
Send my audioAlt+SMaster toggle for sending.
WASAPI outputs to sendAlt+4Tick which Windows output devices to capture from (system audio loopback — what’s currently playing on those speakers gets sent).
WASAPI inputs to sendAlt+5Tick which Windows input devices to capture (microphones, line-ins).
ASIO inputs to sendAlt+2(Visible when an ASIO driver is selected.) Tick ASIO channel pairs to capture and send.
+ +

All device lists are checkable list boxes — arrow up and down to browse, spacebar to toggle a tick. Profiles save device ticks; Blank template starts with everything unticked.

+ +

Receiving

+ +

Receiving has two requirements: Receive audio ticked, and at least one output device ticked. Without an output device, even if packets arrive there’s nowhere for them to go.

+ +

Tick as many outputs as you like across both the WASAPI and ASIO output lists — the same received audio plays out of all of them. Common combinations:

+ + +

Sending

+ +

Sending requires Send my audio ticked, plus at least one capture source ticked across the three send lists.

+ + + + + + +
ListWhat it capturesTypical use
WASAPI outputs to sendSystem audio loopback — whatever Windows is currently sending out through that output. So picking your “Speakers” device captures whatever you’re hearing.Sharing music playback, sharing a video call’s audio, anything coming out of your own speakers.
WASAPI inputs to sendDirect capture from a microphone or line input.Your USB microphone, a headset mic, a line-in.
ASIO inputs to sendAn ASIO channel pair — typically a hardware input on a pro audio interface.Instrument input on an Audient EVO, microphone preamp on a Focusrite, etc.
+ +

Tick any combination across the three lists. RemSound mixes them into a single stream and sends that to all selected peers. So you can send a mic plus a guitar plus your system audio at once, mixed together, and your friends hear all three.

+ +
+System audio loopback can cause feedback loops. If you tick the same device for both “WASAPI outputs to send” (loopback) and as your “WASAPI outputs for received sound”, the received audio plays out of that device, gets captured by the loopback, and gets sent back. The other side hears their own voice on a delay. Don’t tick a device on both sides at once. +
+ +

8. Audio profile tab

+ +

Everything that shapes the audio quality / latency trade-off lives here. The tab is split into two NVDA-announced groups: Audio send parameters at the top, Audio receive parameters below. As you tab through, NVDA announces which group you’ve entered.

+ +

Audio send parameters

+ + + + + + +
ControlShortcutWhat it does
Audio codecAlt+CPCM 48k 24-bit, Opus high quality (20 ms), or Opus lower quality (10 ms). See codec choice.
Packet sizeAlt+PStandard (default) or Small (LAN only). Smaller packets save a couple of milliseconds of send-side accumulator latency at the cost of doubling the packet rate.
Lock to audio clockAlt+KSender-side timing tightener. Locks packet emission to the audio device’s hardware clock instead of a Stopwatch loop. Saves a few milliseconds of jitter; brief clicks possible if the link can’t keep up. The label changes depending on whether ASIO is in the pipeline so it tells you what it actually does in your current setup.
+ +

Audio receive parameters

+ +

The shape of this section depends on whether an ASIO driver is selected on the Audio inputs and outputs tab. With no ASIO driver, you see one latency row (the WASAPI one, labelled simply “Audio latency”). With an ASIO driver selected, you see two latency rows — one per lane — each with its own auto-tune toggle. The two lanes are independent: a WASAPI underrun doesn’t pull the ASIO target up, and vice versa.

+ + + + + + + + + +
ControlShortcutWhat it does
ASIO latency in millisecondsAlt+L(Only when an ASIO driver is selected.) Spinner. Target receive buffer for the ASIO lane. Default 10 ms. ASIO’s native pipeline can sustain very low targets, but values below the platform’s real-world jitter floor (typically 15–25 ms) cause constant micro-corrections that you can hear — pick 25 ms as a safe floor unless you’re on localhost or wired LAN.
Continuous auto-tune ASIO latencyAlt+T(Only when an ASIO driver is selected.) Checkbox. Nudges the ASIO latency target as ASIO-lane jitter changes. Independent of the WASAPI toggle.
WASAPI latency in milliseconds (called just “Audio latency” with no ASIO driver)Alt+W (Alt+L when no ASIO driver)Spinner. Target receive buffer for the WASAPI lane (or the only lane in WASAPI-only setups). Smaller = less delay, more clicks. Most people want 20–80 ms.
Continuous auto-tune WASAPI latency (called “Continuous auto-tune latency” with no ASIO driver)Alt+Y (Alt+T when no ASIO driver)Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying Auto-tune latency interval (Alt+I) combo sets how often it re-evaluates: 3, 5, 10, 15, or 30 seconds. The interval is shared between the WASAPI and ASIO lanes — one tick rate, one combo.
Buffer smoothnessAlt+BListbox, 1 to 10. Controls how patient the receiver is with late-arriving audio on either lane. Higher = more click protection, longer steady-state delay. Default 3.
Artefact sound typeAlt+AListbox. Noise burst (default) replaces an empty-buffer moment with a brief broadband shhh, which blends into music. Click uses raw zero-fill (no concealment) for diagnostic purposes — you hear an obvious click on every buffer underrun.
+ +

Most people only need to pick a codec and a smoothness level and let the rest sit on defaults.

+ +

9. ASIO and WASAPI

+ +

RemSound speaks two audio backends. Which one is in play depends on the ASIO driver (Alt+D) listbox at the top of the Audio inputs and outputs tab.

+ +

WASAPI (the default)

+

Windows’ standard audio engine. Every device in your Windows Sound control panel is reachable via WASAPI. Latency from a WASAPI capture or playback is typically 10–30 milliseconds. Anyone running RemSound has WASAPI; no special hardware needed.

+ +

ASIO (needs a driver)

+

A separate audio path used by professional audio interfaces. ASIO drivers bypass the Windows audio engine and talk directly to the hardware, giving sub-5-millisecond hardware latency.

+ +

The ASIO driver picker is omitted entirely on a machine with no ASIO drivers installed. Common drivers that do appear:

+ + +

How the driver picker chooses the pipeline

+ +

One control, two outcomes:

+ + + + + +
ASIO driver selectionPipelineLatency
(none)WASAPI captures and plays out directly. No ASIO code in the pipeline at all.~10–30 ms WASAPI buffer + WasapiOut. Lowest possible for users without an ASIO driver.
Any real driver nameWASAPI and ASIO run as two independent UDP streams alongside each other. Each lane keeps its own native latency — ASIO stays sub-5 ms even when WASAPI is also active.WASAPI at its WASAPI rate, ASIO at its ASIO rate. No cross-lane tee, no intermediate buffer; each lane has its own latency knob on the Audio profile tab (see Latency).
+ +

Default on a fresh install is (none). If you have an ASIO driver and want to use it, arrow down to it in the picker. To go back to WASAPI-only, arrow back up to (none).

+ +

ASIO channel pairs

+

ASIO doesn’t expose “devices” the way Windows does. Instead it gives you a list of channels (typically 2, 4, 6, 8, or more depending on the interface), grouped into stereo pairs. RemSound labels each pair with the driver name, the pair number, and the channel names the driver itself reports. For an Audient EVO 8 you’d see entries like:

+ +
+Audient USB Audio ASIO Driver — Pair 1 (channels 1/2): Mic | Line | Instrument 1 / Mic | Line 2
+Audient USB Audio ASIO Driver — Pair 2 (channels 3/4): Mic | Line 3 / Mic | Line 4
+Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / Loop-back 2 (R)
+
+ +

Buffer size for ASIO

+

RemSound doesn’t expose a buffer-size control of its own. To change ASIO buffer size, open the audio interface’s own control panel application (NI’s Komplete Audio Control Panel, Audient EVO Control software, etc.) and set it there. The driver remembers its buffer size between sessions; RemSound just uses whatever the driver is configured to do.

+ +
+About Realtek ASIO: if you see “Realtek ASIO” in the driver list, treat it with caution. Despite the name, it’s not bound to Realtek hardware specifically — it’s a generic wrapper that opens whatever Windows considers the default audio device. On a machine with a real audio interface (Audient, Komplete, etc.) selecting Realtek ASIO will often grab that interface via Windows’ WDM path, fighting both your real ASIO driver and your screen reader for the same hardware. Usually safe to ignore Realtek ASIO entirely. +
+ +

Same driver, sender and receiver, on one machine

+

RemSound supports this — you can capture from your audio interface and play received audio out of the same interface simultaneously, on the same machine. Most modern pro audio drivers handle this fine.

+ +

10. Peers — finding and connecting

+ +

“Peer” means another machine running RemSound that you want to talk to. Peers are managed on the Connectivity tab. The tab has three lists, all of them checkable:

+ + + + + + +
ListContentsWhat ticking does
Connected peersPeers you currently have audio flowing with.Unticking disconnects.
Discovered peersPeers RemSound has heard from in the last few seconds — either via LAN broadcast, or via direct unicast announcement (which works over Tailscale and any other VPN).Connects you to that peer. Audio starts flowing both ways.
Remembered peersPeers you’ve connected to before, plus any IPs you’ve manually typed in. Persists across sessions.Connects to that remembered peer if they’re online (and adds them as a manual connection if discovery hasn’t found them yet).
+ +

Plus the Add peer by IP (Alt+A) button which opens a small prompt for a hostname or IP. Useful for first-time connection over a VPN where discovery hasn’t reached them yet.

+ +

Audio only plays from peers you’ve ticked

+

Even if a peer is sending audio in your direction, you won’t hear it until you’ve ticked their checkbox. This is deliberate — connecting is a consent step. A peer’s name shows up in Discovered the moment they come online, but they can’t make sound on your speakers until you say yes.

+ +

Heartbeat indicator

+

For each connected peer, the status read-out at the bottom of the window shows a small status: their latest round-trip time in milliseconds, or pending, stale, or unreachable if heartbeat replies have stopped. RemSound plays a connect cue (a short sound) when a peer transitions to healthy and a disconnect cue when one becomes unreachable. Both cues can be silenced via Mute connect/disconnect sounds in the Preferences dialog (File → Preferences, or Ctrl+P).

+ +

11. How the network works (LAN, WAN, Tailscale)

+ +

RemSound uses two UDP ports:

+ + + + + +
PortPurposeDefault
AudioThe actual sound, sent peer-to-peer. Heartbeat packets share this port too — one socket, one firewall rule, one router pinhole.47830
Discovery“I’m here” announcements every 1.5 seconds, so peers can find each other.47831
+ +

One canonical audio port number is used for everything — Tailscale, LAN peer-to-peer, and any relay server. You never need to type a port after a hostname or IP; the default is implied. Both sides of a connection need to use the same audio port number.

+ +

Heartbeat packets ride on the same UDP port as audio, so if your audio reaches the peer, your heartbeat does too — one firewall rule covers both.

+ +

LAN — same Wi-Fi or Ethernet

+

On a normal home network, discovery and heartbeat both work without configuration. Launch RemSound on two machines and they’ll see each other within a second or two via the discovery broadcast. No firewall changes are usually needed because UDP broadcast is allowed by default.

+ +

WAN — different physical locations

+

Direct internet peer-to-peer connections require either:

+ + +

Discovery on Tailscale and other VPNs

+

UDP broadcast doesn’t traverse a VPN — Tailscale’s virtual interface ignores broadcast packets entirely. RemSound works around this by also sending discovery announcements directly (unicast) to every IP in your Remembered peers list. So:

+
    +
  1. One time only: each side adds the other’s Tailscale IP to its Remembered peers list (via the “Add peer by IP” button).
  2. +
  3. From then on, RemSound auto-unicasts announcements at those IPs every 1.5 seconds.
  4. +
  5. The receiving side’s discovery hears the announcement, adds the sender to its own unicast list, and announces back.
  6. +
  7. Within seconds, both sides see each other in Discovered peers without further typing.
  8. +
+ +

So the rule is: only one side has to type the other’s IP once. After that, discovery is bidirectional automatically.

+ +

Round-trip time and what it means

+ + + + + + + +
RTTWhat you’ll experience
0–2 msLocalhost (same machine talking to itself).
2–10 msSame LAN. Effectively instant.
15–40 msTypical Tailscale or modern broadband-to-broadband. Comfortable for conversation.
50–100 msTailscale via a relay, or one end on Wi-Fi from a long way off. Still usable but you start to feel it for music.
100 ms+Something is wrong, or you’re across the world. Music collaboration is challenging.
+ +

12. Latency and audio quality

+ +

Five controls together shape the latency / audio-quality trade-off, all on the Audio profile tab:

+ + + +

Plus the codec choice (PCM / Opus 20 ms / Opus 10 ms), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and let the rest sit on defaults.

+ +

Audio latency slider

+ +

The Audio latency spinner tells the receiver how much audio to keep buffered as a cushion against network jitter. Bigger buffer = more delay, fewer clicks. Smaller buffer = less delay, more clicks when the network wobbles.

+ + + + + + + +
SettingBest forTrade-off
5–10 msLAN, localhost.Crackles on any WAN with even modest jitter.
20–40 msStable Tailscale or wired internet.Good balance — usually inaudible delay added.
50–80 msWAN with some Wi-Fi or jitter.Noticeable delay; very robust to drops.
100 ms+Bad networks; voice-only.Definitely feels delayed.
+ +

Smaller is better when the network can handle it. If you don’t want to think about this number, switch on continuous auto-tune (below) and leave it.

+ +

Buffer smoothness

+ +

The Buffer smoothness listbox sets a 1-to-10 scale that controls how patient the receiver is when network jitter spikes. The default is 3.

+ + + + + + + +
SmoothnessBehaviourPick when
10 — smoothestReceiver tolerates the largest jitter spikes without dropping audio. Longest steady-state delay.Bad Wi-Fi, busy ISP, music collaboration where every click is unacceptable.
4–7Middle ground. Smooths most everyday internet jitter without much added delay.Most WAN sessions over Tailscale or direct internet.
3 — defaultModerate protection; brief clicks possible on jitter spikes.Stable internet or quiet LAN.
1 — tightest delayReceiver gives up immediately when audio is late. Frequent clicks; lowest delay.LAN testing, latency-critical experiments.
+ +

Smoothness and the Audio latency spinner work together — smoothness controls how the receiver responds when audio runs late; the latency value controls how big a head-start it builds. Real-world tip: if you can hear clicks, try raising smoothness by one or two before reaching for a bigger latency target.

+ +

Packet size — Standard or Small

+ +

Two options: Standard (default) and Small. Controls the frame size each network packet carries:

+ + + + + +
Packet sizeWhat changesPick when
Standard (5 ms PCM, 10/20 ms Opus)One audio packet every 5 ms (PCM) or 10/20 ms (Opus, depending on codec choice).WAN, Tailscale, internet — any time you don’t have a guaranteed-clean LAN.
Small (2.5 ms PCM, 5/10 ms Opus, LAN only)Halves the effective frame size. Saves up to 2.5 ms of accumulator latency at the sender.Same-house LAN over wired Ethernet, where the network simply isn’t going to drop or jitter.
+ +

The latency win is small — at most a few milliseconds end-to-end. Small packets are useful when you and your collaborator are on the same LAN and want to chase every last millisecond; for any internet path it’s a false economy because doubling the packet rate also doubles your odds of running into jitter at exactly the wrong moment, which translates into clicks.

+ +

Lock to audio clock

+ +

The Lock to audio clock checkbox locks RemSound’s send timing path to the audio device’s own hardware clock instead of letting Windows’ general thread scheduler set the pace. Off by default. The label tells you what it does in your current setup:

+ + + + + +
Current setupWhat “Lock to audio clock” does
No ASIO driver selected (WASAPI only)Sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens send-side delay.
ASIO driver selected (WASAPI + ASIO running as independent lanes)Both lanes tighten independently. WASAPI lane uses push-mode (when there is exactly one WASAPI capture source); ASIO lane emits one packet per ASIO callback with no accumulation. Brief clicks possible on either lane if the link can’t keep up.
+ +
+Why you’d use it: Windows’ thread scheduler can wake the audio loop with up to ~6 ms of jitter even at top priority. At target latencies under ~15 ms, that jitter shows up as clicks. Locking to the audio clock removes the Stopwatch loop from the timing path entirely — the audio device interrupts directly drive the encoder. +
+ +

Continuous auto-tune

+ +

The Continuous auto-tune latency checkbox hands the latency value over to RemSound itself. When on, RemSound observes incoming-packet jitter every few seconds and nudges the latency target up if it’s seeing late packets, or down if the network has been calm. The companion Auto-tune latency interval (Alt+I) combo sets how often it re-evaluates — 3, 5, 10, 15, or 30 seconds. Faster values respond quickly to a network change but can also feel “twitchy”. Treat continuous auto-tune as a hands-off way to keep the cushion right-sized as your network mood changes.

+ +

If you turn auto-tune off, the latency value stays wherever it last was.

+ +

Artefact sound type

+ +

When the playback buffer comes up empty for a moment (an underrun), RemSound has to fill the gap. The Artefact sound type listbox decides what that gap sounds like:

+ + + +

Opus packet-loss recovery (built in, no setting)

+ +

Both Opus modes ship with built-in forward error correction: each packet carries a small redundant copy of the previous packet’s audio, and the receiver uses it to reconstruct any single packet that goes missing in transit. The result is that single-packet losses become inaudible — no click, no glitch — instead of the small pop you’d otherwise hear. Two-packet-in-a-row losses still produce one click; that’s a fundamental Opus limitation, not something configurable.

+ +

This is automatic — there’s no switch. PCM mode does not have it.

+ +

Codec choice

+ + + + + +
CodecQualityBandwidthUse when
PCM 48k 24-bitBest~2.3 MbpsLAN, fast WAN connections.
Opus high quality (20 ms)Very good~96 kbpsMost WAN connections — the right default.
Opus lower quality (10 ms)Good~64 kbpsSlower or less reliable connections; tighter latency.
+ +

PCM gives the absolute best audio with no encoding artefacts but uses about 30× the bandwidth of Opus. Over the open internet, Opus is almost always the right call.

+ +

13. Keyboard shortcuts (within the main window)

+ +

Each tab has its own Alt+letter shortcuts. The same letter can mean different things on different tabs without conflict — mnemonics fire only on the active tab. Move between tabs with Ctrl+Tab and Ctrl+Shift+Tab.

+ +

Connectivity tab

+ + + + + + + +
KeyAction
Alt+CFocus Connected peers list
Alt+DFocus Discovered peers list
Alt+RFocus Remembered peers list
Alt+AAdd peer by IP
Alt+SFocus Connection status read-out
+ +

(Logging controls — Enable logs and Write logs now — live in the Preferences dialog; reach them via File → Preferences or Ctrl+P, then Alt+L / Alt+W within the dialog.)

+ +

Audio inputs and outputs tab

+ + + + + + + + + + + +
KeyAction
Alt+DFocus ASIO driver listbox (hidden if no ASIO drivers are installed)
Alt+RToggle Receive audio
Alt+1Focus ASIO outputs for received sound
Alt+2Focus ASIO inputs to send
Alt+3Focus WASAPI outputs for received sound
Alt+4Focus WASAPI outputs to send
Alt+5Focus WASAPI inputs to send
Alt+VFocus volume slider
Alt+SToggle Send my audio
+ +

Audio profile tab

+ +

Some of these shortcuts shift depending on whether an ASIO driver is selected. When one is selected the ASIO-lane controls take the simpler Alt+L / Alt+T mnemonics; the WASAPI-lane controls move to Alt+W / Alt+Y so they don’t collide.

+ + + + + + + + + + + + + +
KeyAction
Alt+CFocus Audio codec
Alt+PFocus Packet size
Alt+KToggle Lock to audio clock
Alt+LFocus latency spinner — ASIO lane when an ASIO driver is selected, otherwise the single Audio latency spinner
Alt+TToggle continuous auto-tune — ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle
Alt+W(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner
Alt+Y(Only when an ASIO driver is selected.) Toggle the WASAPI-lane continuous auto-tune
Alt+IFocus Auto-tune latency interval
Alt+BFocus Buffer smoothness
Alt+AFocus Artefact sound type
+ +

File menu shortcuts (work from any tab)

+ + + + + + + + + + +
KeyAction
Ctrl+SSave active profile (or Save as if on Blank template)
Ctrl+KOpen Keyboard shortcuts dialog
Ctrl+POpen Preferences dialog
Alt+F, OOpen profile
Alt+F, ASave profile as
Alt+F, RRename current profile
Alt+F, MMinimise to tray
Alt+F, XExit
+ +

Always-available

+ + + + + + + + +
KeyAction
F1Open this manual in your default browser. Works anywhere in RemSound — main window, every dialog, the profile picker on first launch.
Ctrl+Tab / Ctrl+Shift+TabMove to next / previous tab
Tab / Shift+TabMove between controls within the active tab
SpacebarToggle a tick in any device list, or toggle the focused checkbox
Up / DownMove between items in any list
Alt+F4Close (standard Windows)
+ +

14. Global hotkeys (work even when minimised)

+ +

Configurable in the Keyboard shortcuts dialog (Ctrl+K, or File → Keyboard shortcuts). The dialog is a single list of every bindable shortcut: arrow up and down to move between rows, press Enter to rebind the highlighted row, press Del to clear it (back to not set), press Escape (or Tab to the Close button) to close. Defaults:

+ + + + + + + + + + + + + +
HotkeyActionDefault
Receive muteMute / unmute incoming audio (this machine)Ctrl+Shift+Alt+R
Send muteMute / unmute outgoing audio (this machine)Ctrl+Shift+Alt+S
Tray toggleShow / hide the main windowCtrl+Shift+F10
Volume up / downAdjust this machine's received-audio volumeUnset
Send remote volume up to peersTell every connected peer to raise their RemSound app volume slider by 5 points (only honoured by peers that have ticked “Accept remote volume commands”). Doesn't change your own volume. See Remote control.Unset
Send remote volume down to peersMirror, lower direction.Unset
Send remote receive mute toggle to peersTell every connected peer to toggle their RemSound receive mute.Unset
Send Windows global volume up to peersTell every connected peer to nudge their Windows default-output-device volume up by one OS native step (~2%, same as their keyboard volume key). Affects every app on the receiving machine, not just RemSound. Hold the hotkey for bigger jumps. See Remote control.Unset
Send Windows global volume down to peersMirror, lower direction.Unset
Send Windows global mute toggle to peersTell every connected peer to toggle their Windows default-output-device mute.Unset
+ +

You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one regular key.

+ +

15. Remote control: adjusting a peer's listening volume from your end

+ +

The use case: you're on your laptop, listening to audio coming from your desktop, and you've got NVDA Remote open so you can drive the desktop with your laptop's keyboard. Every keystroke you make goes to the desktop — including any local volume hotkey on the laptop, which now never reaches the laptop. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.

+ +

RemSound's remote control feature gives you a way: configure a hotkey on the desktop (the machine your keyboard is talking to) that sends a command across the audio link telling the laptop's RemSound to raise / lower / mute its own listening volume. You stay in NVDA Remote; the laptop responds.

+ +

Two flavours of remote command

+ +

You get two independent sets of remote-control hotkeys, both controlled by the same opt-in toggle on the receiver. Pick whichever fits the situation, or bind both:

+ + + + + +
SetWhat the receiver doesBest for
RemSound app volumeAdjusts the receiving peer's RemSound in-app volume slider by 5 percentage points per press, or toggles the RemSound receive-mute. Only RemSound's audio is affected.Fine adjustments while RemSound's slider is in its useful range. Doesn't affect screen-reader volume or any other app.
Windows global volumeNudges the receiving peer's Windows default-output-device master volume up or down by one OS-native step (~2%, exactly the same as pressing the keyboard volume key on the receiver), or toggles its master mute. Affects every app on the receiver, including the screen reader.Real-world “I need this louder” cases, especially with hearing impairment or when RemSound's slider is already at the top. Hold the hotkey to ramp up over a longer range.
+ +

Both sets target the receiver. Neither changes anything on the sending machine.

+ +

How to set it up

+ +
    +
  1. On the machine that should respond to remote commands (the one you're listening on — the laptop in the example): open Preferences (Ctrl+P) and tick Accept remote volume commands from peers. Save the profile (Ctrl+S) so the choice sticks. (One toggle covers both flavours of remote command.)
  2. +
  3. On the machine that should send remote commands (the one your keyboard is driving — the desktop in the example): open the Keyboard shortcuts dialog (Ctrl+K). Bind whichever of the six remote-control rows you want: +
      +
    • Send remote volume up / down to peers — nudges the receiver's RemSound app slider.
    • +
    • Send remote receive mute toggle to peers — toggles the receiver's RemSound mute.
    • +
    • Send Windows global volume up / down to peers — nudges the receiver's Windows master volume.
    • +
    • Send Windows global mute toggle to peers — toggles the receiver's Windows mute.
    • +
    +Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down for one set, Ctrl+Alt+Up / Ctrl+Alt+Down for the other). These are global hotkeys: they fire as long as RemSound is running, no matter which app has focus.
  4. +
  5. That's it. Press the configured hotkey on the desktop — the laptop responds the way it would if you'd pressed the corresponding key on the laptop directly, and you hear the change without leaving the NVDA Remote session. Hold the Windows-volume hotkey down for a steady ramp; the OS auto-repeat fires the step over and over.
  6. +
+ +
+Heads-up about “Windows global volume”: system volume affects everything on the receiving machine — not just RemSound. NVDA's voice gets louder with it, browser audio gets louder, every notification gets louder. For a hearing-impaired listener that's usually exactly what you want (everything you hear gets a usable level), but it's a meaningfully different thing from the in-app slider, which only changes RemSound's audio. Use the right knob for the situation. +
+ +

Mutuality

+ +

The feature is symmetric: both machines can both send and accept. If you configure hotkeys on both ends and tick “Accept remote volume commands” on both ends, either side can drive the other's volume. There is no “controller” / “controllee” designation.

+ +

What it does not touch

+ + + +
+Diagnostic tip: the log file (Preferences dialog → Enable logs) records every remote-control send and receive, including IGNORED entries when an incoming command was rejected because the sender wasn't in your allow-list or because “Accept remote volume commands” was off. Useful for debugging “why isn't my hotkey working” without guessing. +
+ +

16. Startup behaviour

+ +

Open the Startup behaviour dialog from File → Preferences → Startup behaviour (or Ctrl+P, then Alt+S). It has three independent toggles, plus a profile picker that appears when the third one is on. Tab cycles between the three checkboxes, the profile list (when visible), and the Close button. Esc closes the dialog. Each tick is saved immediately — there's no OK/Apply button.

+ + + + + + +
ToggleWhat it does
Start minimised to tray (Alt+M)RemSound minimises to the system tray immediately after the main window finishes loading. The window stays accessible via the tray icon and the tray hotkey. Useful when paired with the auto-start option below for a fully unattended “boot the machine, start streaming” flow.
Start RemSound automatically when this user logs in (Alt+A)Adds (or removes) a per-user entry in the Windows registry under HKCU\Software\Microsoft\Windows\CurrentVersion\Run pointing at RemSound.exe. After ticking, Windows launches RemSound the next time you log in. The setting also shows up under Task Manager → Startup, where you can also disable it. Per-user only — doesn't need admin and doesn't affect anyone else who uses the same machine.
Start with a specific profile (Alt+P)When ticked, RemSound skips the startup profile picker and loads the profile you choose in the listbox below. When unticked, the profile picker shows as normal. If you have no saved profiles yet, ticking it shows a one-shot warning and stays unticked — save a profile first, then come back. To restore the picker temporarily without losing the choice, untick the box, launch normally, then re-tick after.
+ +

Profile to start with (Alt+L) — the listbox of saved profiles. Visible only when the third toggle is on. Arrow up/down to pick, the choice is saved as you move; double-click to pick and close the dialog at the same time.

+ +

Combining the three for a hands-off boot

+ +
    +
  1. Save a profile with the device ticks, peers, and audio settings you want for “always-on” use.
  2. +
  3. Open Startup behaviour. Tick all three: Start minimised, Start automatically when this user logs in, Start with a specific profile — and pick the profile you just saved.
  4. +
  5. Close the dialog. Reboot or log out and back in to test — RemSound launches itself, loads the profile, and goes straight to the tray. Audio starts flowing as soon as the peer is reachable.
  6. +
+ +
+Storage: the start-minimised choice and the start-with-profile name live in <exe>\remsound.config.json (machine-local, profile-independent). The auto-start toggle lives in the Windows registry only — deleting remsound.config.json will not turn off the auto-start; you toggle it from this dialog or from Task Manager → Startup. +
+ +

17. Updating RemSound

+ +

RemSound can check its own GitHub Releases page for a newer build on a schedule you choose, prompt you to install, and either ask first or do it silently. There’s also a one-press “check now” button so you don’t have to wait for the timer.

+ +

Settings in Preferences

+ +

Open File → Preferences (or Ctrl+P). The update settings sit above the logging row:

+ + + + + + +
SettingShortcutWhat it does
Check for updates (drop-down)Alt+UHow often RemSound polls GitHub in the background. Options: Never, Every hour, Every 6 hours, Every 24 hours. Default is Every 24 hours. The choice is remembered across launches; if you set it to Never, the only way an update arrives is via the manual button below.
Check for updates now (button)Alt+NRuns the GitHub check immediately. If you’re already on the latest version you get a small “you are running the latest version” popup. If there’s a newer release, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C).
Silently install updates when available (checkbox)Alt+IWhen ticked, the background timer’s tick installs any available update without prompting — RemSound downloads, exits briefly, swaps the files, and relaunches itself. Off by default. The manual “Check for updates now” path always prompts regardless of this checkbox.
+ +

What happens during an install

+ +

RemSound can’t overwrite its own .exe while it’s running, so an install spawns a small one-shot helper that finishes the job after RemSound exits:

+ +
    +
  1. RemSound downloads the new release’s RemSound-vX.Y.zip from GitHub into a staging folder next to the running .exe.
  2. +
  3. It writes a tiny detached _apply-update.cmd alongside it that watches the RemSound process.
  4. +
  5. RemSound exits.
  6. +
  7. The detached cmd notices the exit, copies the staged files over the install folder, deletes the staging folder, and relaunches RemSound.exe.
  8. +
  9. The cmd deletes itself.
  10. +
+ +

You’ll see the window close, then reopen on the new version within a second or two. Anything that was unsaved on the old session (a profile in mid-edit, for instance) is lost — RemSound will not auto-save before installing. Save first if you’ve been editing.

+ +

If install fails

+ +

The downloader is best-effort: a flaky network, a locked install folder, or a temporarily-unavailable release will pop a MessageBox saying it couldn’t complete and leave the running version untouched. The release page URL is in the popup so you can grab the zip in a browser and install by hand if needed. If you installed RemSound into Program Files without granting your user write permission to the install folder, the install helper’s file-copy step will fail too — either fix the permission or move RemSound to a writable folder (e.g. somewhere under your user profile).

+ +

The About dialog and release notes

+ +

To see what version you’re currently on without checking for updates, open Help → About RemSound (Alt+H, A). The dialog shows the version number and the release notes for the build you’re running, in a scrollable read-only text box you can tab into and arrow through under NVDA. Close (or Esc) dismisses.

+ +

18. Logs and diagnostics

+ +

If logging is enabled (the Enable logs checkbox in the Preferences dialog — File → Preferences, or Ctrl+P — default on), RemSound writes a tab-separated log file per session into publish\logs\ (relative to wherever RemSound.exe lives). One file per launch, named RemSound-<machine>-<PID>-<date>-<time>.log.

+ +

The file contains two kinds of rows:

+ + + + + +
KindContents
EVTEvent lines — startup, peer selected, capture started, errors, etc.
SNAPOne-second snapshots of running stats: codec, latency target, buffered audio milliseconds, packets sent, packets received, underruns, drops, peer heartbeat RTTs.
+ +

The Write logs now button in the Preferences dialog (Alt+W within that dialog) writes a "user requested write logs now" marker into the log so you can find the moment in the file later.

+ +

Logs are plain UTF-8 text and openable in any spreadsheet (paste-as-tab-separated) or text editor. Most useful columns when something feels wrong:

+ + +

19. Troubleshooting

+ +

I don’t hear my friend

+
    +
  1. Connectivity tab: is your friend in Connected peers with a green RTT (e.g. “192.168.1.5: 27 ms”), not unreachable or pending?
  2. +
  3. Audio inputs and outputs tab: is Receive audio ticked?
  4. +
  5. Same tab: is at least one output device ticked in the WASAPI or ASIO output list?
  6. +
  7. Same tab: is the volume slider above zero?
  8. +
+ +

My friend doesn’t hear me

+
    +
  1. Audio inputs and outputs tab: is Send my audio ticked?
  2. +
  3. Same tab: is at least one capture source ticked across the three send lists?
  4. +
  5. If you’re using a microphone: is Windows’ microphone privacy setting allowing apps to use it? (Settings → Privacy → Microphone.)
  6. +
  7. Have they ticked your name in their Discovered peers list?
  8. +
+ +

I can hear them but the audio crackles

+ + +

Audio is fine but the delay feels long

+ + +

The ASIO lane sounds grainy or constantly micro-clicks

+

Most likely your ASIO latency target is below the platform’s real-world jitter floor. The receiver fights to hold the buffer at the target by drift-dropping frames and click-trimming — both are audible. Raise ASIO latency in milliseconds (Alt+L) on the Audio profile tab to 25 ms or more and the grain should vanish. Even pure-ASIO setups don’t safely sustain sub-15-ms receive buffers over real networks; aim higher on Wi-Fi.

+ +

I can’t see my friend in Discovered peers

+ + +

One side says “unreachable” even though audio is flowing

+

Heartbeat shares the audio port, so if audio gets through, heartbeat should too. If one side is showing “unreachable” while audio plays, make sure both machines are running the same RemSound build — an older version on either end can speak a different heartbeat dialect.

+ +

NVDA went silent or crackly when I selected an ASIO driver

+

You probably picked Realtek ASIO. It’s a generic wrapper, not bound to Realtek hardware, and tends to grab whatever Windows’ default audio device is — which is usually the same one NVDA is using. Arrow back to (none) in the ASIO driver picker (or pick a different ASIO driver).

+ +

Audio device list shows old devices that are no longer plugged in

+

RemSound refreshes its lists every second. If a device has truly been unplugged it should disappear within a few seconds. If it’s lingering, restart RemSound — Windows’ device cache occasionally needs poking.

+ +

20. Glossary

+ + + + + + + + + + + + + + + + + + + + +
TermMeaning
WASAPIWindows Audio Session API. The standard Windows audio path. Every audio device in your Windows Sound control panel is reachable via WASAPI. Latency around 10–30 ms.
ASIOAudio Stream Input/Output. A different audio path, written by hardware vendors, that bypasses the Windows audio engine. Used by music production software for sub-5 ms latency. Requires a vendor-supplied driver.
Loopback captureCapturing what’s currently being played out of an output device (rather than what’s coming in from a microphone). The “WASAPI outputs to send” list is loopback capture.
Channel pairA stereo pair of channels on an ASIO driver. Pair 1 = channels 1+2, Pair 2 = channels 3+4, etc.
UDPThe network protocol RemSound uses for audio. Unlike TCP it doesn’t retransmit lost packets — at audio rates a retransmitted packet is too late to be useful, so we just skip it and keep going.
HeartbeatA small UDP packet exchanged between peers every second to verify they’re still reachable and measure round-trip time. Travels on the audio port (47830 by default).
DiscoveryThe mechanism by which RemSound peers find each other on the network without needing to know each other’s IP addresses up front.
TailscaleA user-friendly VPN that puts your machines on a private virtual network. The easiest way to connect RemSound across the internet without router configuration.
JitterVariation in the arrival time of network packets. The reason a receive-side buffer is needed.
Auto-tuneThe receiver’s automatic adjustment of the latency target based on observed network jitter. Off by default; turn on via the Continuous auto-tune checkbox on the Audio profile tab.
ProfileA saved snapshot of every RemSound setting and selection — device ticks, send / receive states, codec, latency, peers, hotkeys, ASIO driver choice, the lot. Stored as one JSON file per profile. Picked at startup; switchable mid-session via File → Open profile.
Blank templateA startup option in the profile picker that begins a session with all defaults — nothing ticked, no peers, no save name. The starting point for creating a new profile, or for one-off sessions you don’t intend to save.
Lock to audio clockSender-side timing mode that drives packet emission directly from the audio device’s hardware-clock interrupts instead of from a Windows scheduler tick. Eliminates a few milliseconds of jitter on tight latency targets. Off by default. Toggled via the checkbox of the same name on the Audio profile tab.
ConcealmentReceiver-side feature that fills brief audio-buffer underruns with a small noise burst (the default) or raw zero-fill / click. Choice lives on the Audio profile tab as the Artefact sound type. Opus has its own packet-loss recovery built into the codec on top of this.
AppConfigA small machine-local JSON file at <exe>\remsound.config.json that stores preferences which should be sticky regardless of which profile is active — today: the profiles folder location, the Startup behaviour choices, and the “do not show me this again” tick on the Save-profile confirmation popup.
Remote controlA small RemSound feature that lets one connected peer nudge another peer's listening volume (or toggle their receive mute) via configurable global hotkeys. Two sets of commands: one adjusts the receiver's RemSound app slider, the other adjusts the receiver's Windows default-output-device master volume. Off by default on both ends; the receiver opts in via “Accept remote volume commands from peers” in the Preferences dialog (Ctrl+P), the sender configures hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the “I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume” case. See section 15.
Control packetThe wire-level message used by the remote-control feature. A 14-byte UDP packet (12-byte header + 2-byte payload) carrying a kind enum (RemSound volume up / down / mute toggle, or Windows system volume up / down / mute toggle) and a signed delta. Travels on the same UDP audio port as everything else.
+ +
+ + + diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs new file mode 100644 index 0000000..6c73f66 --- /dev/null +++ b/src/RemSound.App/AboutDialog.cs @@ -0,0 +1,130 @@ +using System.Reflection; + +namespace RemSound.App; + +/// +/// About dialog. Shows the running version, a short blurb about RemSound and the latest +/// release notes (built in below — bumped per release alongside the project's +/// property). +/// +/// Layout follows the same NVDA-friendly conventions the rest of the app uses: a small +/// modal dialog with a heading label, a read-only multi-line text box for the notes that +/// the user can tab into and arrow through, and a Close button as the AcceptButton / +/// CancelButton. Escape dismisses. +/// +internal sealed class AboutDialog : Form +{ + /// Markdown-ish release notes shown in the About box's scrolling text area. + /// Bumped per release. Keep it short — the canonical release notes also live on the + /// GitHub Releases page, which the user can reach via the Help menu's "Check for + /// updates" path. + private const string ReleaseNotes = + """ + RemSound v1.0 + + Initial public release. + + Highlights: + * Low-latency peer-to-peer audio over UDP. WASAPI for any Windows audio device, + and a parallel ASIO lane for pro audio interfaces (Audient, Komplete Audio, + Focusrite, RME). Each lane keeps its own native callback latency. + * Pick an ASIO driver from the dropdown at the top of the Audio inputs and outputs + tab to bring ASIO into the pipeline; select "(none)" to run WASAPI-only. + * Profile system. Save your entire setup — device ticks, peers, codec, latency + targets, hotkeys, ASIO driver choice — into a JSON file. Pick which profile to + load at every launch. + * Continuous auto-tune on either lane. Watches receive jitter and nudges the + latency target up or down to stay click-free without forcing you to overshoot. + * Opus inband FEC. Single-packet losses recover transparently in both Opus modes; + you don't hear them at all. PCM is also available for clean LAN connections. + * Remote control. Configurable global hotkeys can nudge a peer's RemSound volume + or their Windows default-output-device master volume, opt-in on the receiver. + * Built-in self-updater. Optionally polls GitHub for newer releases on a schedule + you set; can install them silently if you want. + + See the user manual (Help menu, or F1 from anywhere in the app) for full details + on every control, the keyboard shortcuts, and the troubleshooting guide. + """; + + public AboutDialog() + { + Text = "About RemSound"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + KeyPreview = true; + ClientSize = new Size(560, 420); + + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"; + + var headingLabel = new Label + { + Text = $"RemSound version {version}", + AutoSize = true, + Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 11f, FontStyle.Bold), + AccessibleName = $"RemSound version {version}", + }; + + var notesBox = new TextBox + { + Multiline = true, + ReadOnly = true, + TabStop = true, + Dock = DockStyle.Fill, + ScrollBars = ScrollBars.Vertical, + BorderStyle = BorderStyle.FixedSingle, + Text = ReleaseNotes, + AccessibleName = "Release notes (tab into and arrow to read)", + }; + + var closeButton = new Button + { + Text = "Close", + AutoSize = true, + DialogResult = DialogResult.OK, + TabIndex = 1, + }; + closeButton.Click += (_, _) => Close(); + notesBox.TabIndex = 0; + + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 1, + RowCount = 3, + }; + root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + }; + buttons.Controls.Add(closeButton); + + root.Controls.Add(headingLabel, 0, 0); + root.Controls.Add(notesBox, 0, 1); + root.Controls.Add(buttons, 0, 2); + Controls.Add(root); + + AcceptButton = closeButton; + CancelButton = closeButton; + + KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + { + Close(); + e.SuppressKeyPress = true; + e.Handled = true; + } + }; + } +} diff --git a/src/RemSound.App/AccessibleCheckBox.cs b/src/RemSound.App/AccessibleCheckBox.cs new file mode 100644 index 0000000..52db1a5 --- /dev/null +++ b/src/RemSound.App/AccessibleCheckBox.cs @@ -0,0 +1,65 @@ +using System.Runtime.InteropServices; + +namespace RemSound.App; + +/// +/// Direct NotifyWinEvent shim. WinForms focus changes nominally fire MSAA EVENT_OBJECT_FOCUS, +/// but in some scenarios (focus moving from a key handler that runs synchronously in +/// ProcessCmdKey, focus into a control inside a wrapper container, etc.) NVDA's screen-reader +/// listener doesn't pick up the announcement. Re-firing the event explicitly forces it. +/// +/// Same pattern documented in claude-notes.md for the AccessibleCheckBox state-change fix — +/// "the load-bearing piece is the FOCUS re-fire." +/// +internal static class WinEventNotifier +{ + private const uint EVENT_OBJECT_FOCUS = 0x8005; + private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC); + private const int CHILDID_SELF = 0; + + [DllImport("user32.dll")] + private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild); + + public static void NotifyFocus(Control control) + { + if (control.IsHandleCreated) + { + NotifyWinEvent(EVENT_OBJECT_FOCUS, control.Handle, OBJID_CLIENT, CHILDID_SELF); + } + } +} + +/// +/// CheckBox variant that fires the right MSAA WinEvents on every state change so NVDA +/// reliably announces "checked" / "not checked" — including for spacebar toggles while the +/// checkbox already has focus, which is the failure mode plain WinForms CheckBox has on +/// .NET 10. The recipe (proven in the loxone desktop app): +/// 1. Fire EVENT_OBJECT_STATECHANGE so any listener knows the toggle state changed. +/// 2. If the checkbox is currently focused, ALSO re-fire EVENT_OBJECT_FOCUS — this is what +/// forces NVDA to re-announce the focused control, bringing the new state with it. +/// We call user32.NotifyWinEvent directly because the managed +/// path only fires the state event without +/// the focus re-fire, which leaves NVDA silent. +/// +internal sealed class AccessibleCheckBox : CheckBox +{ + private const uint EVENT_OBJECT_FOCUS = 0x8005; + private const uint EVENT_OBJECT_STATECHANGE = 0x800A; + private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC); + private const int CHILDID_SELF = 0; + + [DllImport("user32.dll")] + private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild); + + protected override void OnCheckedChanged(EventArgs e) + { + base.OnCheckedChanged(e); + if (!IsHandleCreated) return; + + NotifyWinEvent(EVENT_OBJECT_STATECHANGE, Handle, OBJID_CLIENT, CHILDID_SELF); + if (Focused) + { + NotifyWinEvent(EVENT_OBJECT_FOCUS, Handle, OBJID_CLIENT, CHILDID_SELF); + } + } +} diff --git a/src/RemSound.App/AudioDeviceCatalog.cs b/src/RemSound.App/AudioDeviceCatalog.cs new file mode 100644 index 0000000..5d8667a --- /dev/null +++ b/src/RemSound.App/AudioDeviceCatalog.cs @@ -0,0 +1,38 @@ +using NAudio.CoreAudioApi; +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Enumerates currently active Windows audio endpoints, separately for output (render — used +/// for loopback capture) and input (capture — mics, line-ins) devices. Used by the App to +/// populate the two send-device check-lists. +/// +/// Selection state is intentionally NOT persisted: every session starts with all checkboxes +/// unticked and nothing being sent. The user re-ticks once per session. Stops the +/// "wrong-device-still-checked" surprise after a card unplug, ID change, etc. +/// +internal static class AudioDeviceCatalog +{ + public static IReadOnlyList LoadOutputs() + { + using var enumerator = new MMDeviceEnumerator(); + var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active).ToList(); + var choices = devices + .Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Loopback)) + .ToList(); + foreach (var device in devices) device.Dispose(); + return choices; + } + + public static IReadOnlyList LoadInputs() + { + using var enumerator = new MMDeviceEnumerator(); + var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToList(); + var choices = devices + .Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Input)) + .ToList(); + foreach (var device in devices) device.Dispose(); + return choices; + } +} diff --git a/src/RemSound.App/FormLayoutRows.cs b/src/RemSound.App/FormLayoutRows.cs new file mode 100644 index 0000000..f93eb6e --- /dev/null +++ b/src/RemSound.App/FormLayoutRows.cs @@ -0,0 +1,115 @@ +namespace RemSound.App; + +internal static class FormLayoutRows +{ + public static void AddRow(TableLayoutPanel panel, int row, string labelText, Control control, Action focusControl) + { + var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = control }; + label.Click += (_, _) => focusControl(control); + panel.Controls.Add(label, 0, row); + panel.Controls.Add(control, 1, row); + } + + public static MnemonicLabel AddCheckedListRow(TableLayoutPanel panel, int row, string labelText, CheckedListBox list, Label statusLabel, Action focusList) + { + // Restored the FlowLayoutPanel wrapping (matches the legacy/working RSound layout). + // Removing it caused NVDA to mis-pair labels with controls (off-by-one shift across + // the form), so the keyboard-shortcut announcements went to the wrong controls. The + // wrapping isn't ideal for label.ProcessMnemonic forwarding, but ProcessCmdKey handles + // the actual Alt+letter activation explicitly so we don't need to rely on that path. + // Returns the label so callers can update its text on mode changes (e.g. ASIO ⇄ WASAPI). + var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = list }; + label.Click += (_, _) => focusList(list); + panel.Controls.Add(label, 0, row); + var container = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + TabStop = false, + }; + container.Controls.Add(list); + container.Controls.Add(statusLabel); + panel.Controls.Add(container, 1, row); + return label; + } +} + +/// +/// Form that exposes a delegate hook for ProcessCmdKey, so the dialog's Alt+letter activations +/// can be wired up from the closure-based dialog construction code without subclassing Form per +/// dialog type. Every Alt+key combo passes through the delegate first; if it returns true the +/// keystroke is consumed. +/// +internal sealed class CmdKeyForm : Form +{ + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public Func? CmdKeyHandler { get; set; } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (CmdKeyHandler is { } handler && handler(keyData)) return true; + return base.ProcessCmdKey(ref msg, keyData); + } +} + +/// +/// TabControl subclass that suppresses the parent role announcement NVDA picks up from the +/// modern .NET 10 UIA exposure. The base class reports itself as a UIA Tab control type; +/// NVDA reads "tab control" before each tab's name. By returning AccessibleRole.None from +/// our custom AccessibleObject we hide the parent's role from screen readers entirely. +/// +/// Andre's accessible-readout app reads cleanly because it's compiled against .NET Framework +/// 4.x, whose WinForms TabControl exposes less detail to MSAA/UIA. .NET 10 added more, and +/// Microsoft removed the opt-out — so this subclass is the only path. +/// +/// Risk: dotnet/winforms#11831 was filed against .NET 8 reporting that overriding +/// CreateAccessibilityInstance throws InvalidOperationException. Status uncertain on .NET 10. +/// If we hit that exception at runtime, the fallback is to remove the override and accept +/// the announcement. +/// +internal sealed class QuietTabControl : TabControl +{ + protected override AccessibleObject CreateAccessibilityInstance() + => new QuietAcc(this); + + private sealed class QuietAcc : ControlAccessibleObject + { + public QuietAcc(Control owner) : base(owner) { } + // None hides the role from screen readers. NVDA falls through to the focused TabItem + // child whose role is "tab" — and reads only that. No "tab control" prefix. + public override AccessibleRole Role => AccessibleRole.None; + // Empty name so NVDA doesn't read a parent name either. + public override string? Name { get => string.Empty; set { } } + } +} + +/// +/// Label that forwards its Alt+letter mnemonic activation to an explicit target control rather +/// than to "the next focusable control" (the default WinForms behaviour). Necessary because +/// SelectNextControl is unreliable across container boundaries — when a list is wrapped in a +/// FlowLayoutPanel for layout purposes, the default mnemonic walk skips past the panel and +/// focuses whatever comes after it in the parent panel. +/// +internal sealed class MnemonicLabel : Label +{ + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public Control? MnemonicTarget { get; set; } + + protected override bool ProcessMnemonic(char charCode) + { + if (!UseMnemonic || !IsMnemonic(charCode, Text)) return false; + if (MnemonicTarget is { } target && target.CanFocus) + { + target.Focus(); + // Force NVDA to re-announce. Same load-bearing pattern the AccessibleCheckBox uses + // for state changes — the WinForms-built-in focus event isn't always picked up by + // the screen reader, especially when focus moves into a control wrapped in a + // FlowLayoutPanel via a synchronous Focus() call. + WinEventNotifier.NotifyFocus(target); + return true; + } + return false; + } +} diff --git a/src/RemSound.App/HelpLauncher.cs b/src/RemSound.App/HelpLauncher.cs new file mode 100644 index 0000000..169349c --- /dev/null +++ b/src/RemSound.App/HelpLauncher.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +namespace RemSound.App; + +/// +/// Opens the bundled readme.html manual in the user's default browser. Wired to F1 +/// app-wide via , which is installed once at startup and +/// catches F1 (without modifiers) before the message reaches any control. Works in every +/// modal dialog and on the very first form the user sees (the profile picker), because the +/// filter is registered before the first ShowDialog/Application.Run call. +/// +/// File location: <exe>\readme.html (resolved via ). +/// The .csproj copies it from the project root via a Content/Link rule so a fresh +/// dotnet publish always lands a current copy next to the executable. +/// +internal static class HelpLauncher +{ + /// Open the manual via Windows' shell association (default browser). Shows a + /// MessageBox if the file is missing or shell-execute fails — better to surface an + /// explanation than silently swallow the F1. + public static void OpenManual() + { + var path = Path.Combine(AppContext.BaseDirectory, "readme.html"); + if (!File.Exists(path)) + { + MessageBox.Show( + $"Manual not found at:\n\n{path}\n\nThe readme.html file should sit next to RemSound.exe. Re-publishing the build will restore it.", + "Manual not found", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + try + { + // UseShellExecute=true is the load-bearing flag — it lets the OS pick the .html + // handler (Edge / Chrome / Firefox / whatever the user defaulted). Without it + // Process.Start would treat the .html as an executable and fail. + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show( + $"Could not open the manual:\n\n{ex.Message}", + "Manual open failed", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + + /// Install the F1-catches-help message filter on the current thread's message + /// loop. Call once from Program.Main before any form is shown. Idempotent — calling + /// it twice would register two filters which is wasteful but not harmful. + public static void Install() + { + Application.AddMessageFilter(new HelpKeyMessageFilter()); + } +} + +/// +/// Catches F1 keypresses anywhere in the application before they reach the focused control. +/// Modifier-aware: bare F1 only — Ctrl+F1, Shift+F1, Alt+F1 fall through unchanged so we +/// don't steal future combos. Single-instance state is fine because the filter chain is +/// per-thread and RemSound is a single-threaded WinForms app. +/// +internal sealed class HelpKeyMessageFilter : IMessageFilter +{ + private const int WM_KEYDOWN = 0x0100; + private const int WM_SYSKEYDOWN = 0x0104; + private const int VK_F1 = 0x70; + + public bool PreFilterMessage(ref Message m) + { + if (m.Msg != WM_KEYDOWN && m.Msg != WM_SYSKEYDOWN) return false; + if (m.WParam.ToInt32() != VK_F1) return false; + // Bare F1 only — modifier combos are passed through. Lets a future "Shift+F1" do + // something else (context help, etc.) without colliding with us. + if ((Control.ModifierKeys & (Keys.Control | Keys.Shift | Keys.Alt)) != Keys.None) return false; + HelpLauncher.OpenManual(); + return true; // consumed — no further dispatch + } +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs new file mode 100644 index 0000000..1593ffb --- /dev/null +++ b/src/RemSound.App/MainForm.cs @@ -0,0 +1,4667 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using NAudio.CoreAudioApi; +using RemSound.Core; +using RemSound.Receiver; +using RemSound.Sender; + +namespace RemSound.App; + +/// +/// Main RemSound window. Designed for keyboard / NVDA use. +/// +/// UX shape (matches the older RSound app the user asked us to preserve): +/// * Auto-connects on Shown — no Connect button. Discovery starts immediately. +/// * "Connectivity and transport" button opens a settings + peers dialog. +/// * Main form keeps just: mode (send/receive), receive device, volume, +/// send capture devices (CheckedListBox + status label), other actions, status. +/// * Every CheckedListBox has an adjacent status label that announces +/// the focused item, its checked state, position, and "Press Space to toggle". +/// * Knob changes flow live to the audio engine — no engine restarts. +/// +public sealed class MainForm : Form +{ + private const string AppName = "RemSound"; + + // Engines and helpers + private readonly PeerDiscoveryService discovery = new(); + private readonly AudioSender sender = new(); + private readonly AudioReceiver receiver = new(); + private readonly RemSoundSettingsStore settings = new(AppName); + private readonly RemSoundLog logFile = new(); + private readonly RemSoundUpdater updater = new(); + // Background timer that fires the periodic update-poll. Interval comes from + // AppConfig.UpdateCheckFrequency; "Never" stops the timer entirely. Re-armed by + // ApplyUpdateCheckTimer whenever the user changes the frequency in Preferences. + private readonly System.Windows.Forms.Timer updateCheckTimer = new(); + private readonly MainFormHotkeyController hotkeyController; + private readonly MainFormTrayController trayController; + + // --- Main form controls --- + // Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises + // UIA state-change notifications on CheckBox.Checked changes, so NVDA reliably announces + // "checked" / "not checked" for both spacebar toggles and programmatic toggles (hotkeys, + // tray menu). Replaced an earlier CheckedListBox-based approach that was used to work + // around older WinForms accessibility issues. + // Plain WinForms CheckBox configured exactly like the working RSound.old build: + // * Field initializer sets only AutoSize and the bare Text (no ampersand). + // * Text (with mnemonic) and AccessibleName are then re-assigned in the constructor body. + // This two-step pattern matches the old code byte-for-byte; setting them in the field + // initializer alone was enough to break NVDA state-change announcements. + // * Each box is wrapped in its own FlowLayoutPanel before being placed in the + // TableLayoutPanel cell — same as the old code. + private readonly AccessibleCheckBox receiveAudioCheckbox = new() { Text = "Receive audio", AutoSize = true }; + private readonly AccessibleCheckBox sendMyAudioCheckbox = new() { Text = "Send my audio", AutoSize = true }; + private readonly TrackBar volumeBar = new() { Minimum = 0, Maximum = 100, TickFrequency = 10, Value = 100, Width = 200 }; + // Receive output device. Pre-selected to the system default at startup; user can override + // for the session. Selection is NOT persisted — next session starts on default again. + private readonly CheckedListBox receiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label receiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." }; + // Capture devices the user has ticked for sending. Two lists — render-side outputs (loopback + // capture: system audio / soundcard playback) and capture-side inputs (mics, line-ins). Both + // are summed into one outgoing stream by the sender's MixingEngine. Intentionally NOT + // persisted: every session starts with everything unticked and no audio sent. The user + // re-ticks once per session. Stops any device-routing surprise (a card unplugged between + // runs, IDs changing, etc.). + private readonly CheckedListBox sendOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label sendOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." }; + private readonly CheckedListBox sendInputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label sendInputDevicesStatusLabel = new() { AutoSize = true, Text = "No input device selected." }; + // ASIO-side lists. Always present in the form but hidden when ASIO is disabled. The two + // lists are independent of the WASAPI ones — the user can tick any combination across all + // five lists. Sender mixes WASAPI capture + ASIO capture into one outgoing stream; + // receiver fans rendered audio to WASAPI outputs + ASIO outputs in parallel. This lets + // someone use a WASAPI mic and an ASIO instrument input together, or send out to a WASAPI + // headset alongside ASIO studio monitors. + private readonly CheckedListBox asioSendDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label asioSendDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO send channel selected." }; + private readonly CheckedListBox asioReceiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 }; + private readonly Label asioReceiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No ASIO receive channel selected." }; + // Labels paired with the ASIO lists; held as fields so the layout can show/hide them as a + // unit when the user toggles "Enable ASIO". + private MnemonicLabel? asioSendDevicesLabel; + private MnemonicLabel? asioReceiveOutputDevicesLabel; + // Mnemonic label for the driver picker, held as a field so we can show/hide it together + // with the driver listbox when the audio mode changes. Created in BuildAudioIOTab only + // when there is at least one ASIO driver installed; null on machines with no ASIO drivers + // (the driver picker is omitted entirely in that case). + private MnemonicLabel? asioDriverLabel; + // Tabbed UI scaffolding — 2026-05-06 refactor. The form's content panel is now a TabControl + // with four logical sections; status (healthLabel/statusLabel) sits in a footer below the + // tabs so the user always sees connection health regardless of which tab is active. + // + // Navigation (the standard Windows / NVDA-friendly pattern): + // * Arrow Left/Right when the tab strip has focus → cycle tabs (NVDA announces each). + // * Ctrl+Tab / Ctrl+Shift+Tab from anywhere on the form → cycle tabs. + // * Tab key from the strip → focus enters the active page's first control. + // * Tab past the last page control → focus moves to the status footer / form chrome. + // + // The TabControl is TabIndex=0 + TabStop=true so a fresh Tab from the form's chrome + // lands on the strip first. We deliberately do NOT auto-focus a control inside the + // active page on SelectedIndexChanged — that competes with arrow-key navigation (every + // arrow press would yank focus off the strip into a page control, and the next arrow + // would go to that control instead of cycling the next tab). Ed reported "bounces + // about" with the previous always-auto-focus design; removed the handler. + // + // Alt+letter shortcuts are gated per-tab inside ProcessCmdKey so a shortcut never + // auto-jumps the user across tabs. + // TabControl + TabPage accessibility: deliberately default everything (no AccessibleName, + // no AccessibleRole, no SelectedIndexChanged hook). Andre's working accessible-readout + // app uses just `new TabPage(text)` and that's it — NVDA reads the active tab name + // correctly via the framework's built-in MSAA exposure. Past attempts to "improve" this + // (custom AccessibleName, dynamic sync on tab change, AccessibleRole.None) all made it + // worse: extra "main sections", "tab control" double-reads, "pane" prefixes. The + // standard pattern wins. 2026-05-06. + // TabControl accessibility: the "tab control" prefix Ed kept hearing is from .NET 10 + // WinForms' UIA exposure — it deliberately reports TabControl as a Tab control type + // with TabItem children, and NVDA announces both. Microsoft removed the opt-out + // (Switch.UseLegacyAccessibilityFeatures) for .NET Core / 5+ / 10. Andre's app reads + // cleanly because it's .NET Framework 4.x where the older WinForms accessibility + // implementation exposes less detail. + // + // QuietTabControl below is a Hail Mary: subclass TabControl, override its + // AccessibleObject to return a non-Tab role so NVDA reads less context. Risk: + // dotnet/winforms#11831 throws InvalidOperationException on .NET 8/9 when overriding + // CreateAccessibilityInstance — may or may not be fixed in .NET 10. If it throws at + // runtime, fall back to the bare TabControl and accept the announcement. + private readonly TabControl mainTabControl = new QuietTabControl { Dock = DockStyle.Fill }; + private readonly TabPage connectivityTabPage = new("Connectivity"); + private readonly TabPage audioIOTabPage = new("Audio inputs and outputs"); + private readonly TabPage audioProfileTabPage = new("Audio profile"); + // profilesPrefsTabPage retired 2026-05-08 — its contents now live on the File menu. + + // connectivityTransportButton + ShowConnectivityTransportDialog removed in Phase 2/3 of + // the 2026-05-06 UI refactor. Connectivity and audio-profile controls now live inline on + // their respective tabs; there's nothing to bridge to. + // 2026-05-11 audio-mode listbox retired. The mode is now derived from the ASIO driver + // picker below: "(none)" → WasapiOnly, any driver → BothIndependent. The classic mixed-Both + // and AsioOnly modes are no longer reachable from the UI; their enum values survive in + // RemSound.Core.AudioMode for backward-compat deserialisation of old profile JSONs only. + // ListBox (not ComboBox) so the user can arrow up/down to change drivers without having to + // click or open a dropdown. Selecting a row immediately fires SelectedIndexChanged, which + // re-applies the backend and refreshes the channel-pair lists below. Both Andre (Komplete + // Audio) and Ed got confused by the combo's open/close interaction; a plain list with + // sticky selection is unambiguous for sighted users and screen-reader users alike. + // First item is always the "(none)" sentinel (NoAsioDriverSentinel below) — selecting it + // means "no ASIO driver, run WASAPI-only". Real driver names follow. + private readonly ListBox asioDriverBox = new() { Width = 280, Height = 80, IntegralHeight = false }; + /// Visible label of the "no ASIO driver" sentinel row in . + /// Equality against this string is how the code distinguishes "user has chosen WASAPI-only" + /// from "user has selected a real driver". Kept as a constant so the visible text and the + /// equality check can never drift apart. + private const string NoAsioDriverSentinel = "(none)"; + /// True when at least one ASIO driver was detected at startup. Set once in the + /// constructor; reads it to decide whether to render the + /// driver picker at all. On a machine with no ASIO drivers installed the picker (and its + /// "Driver (Alt+D):" label) are omitted entirely — there is nothing to switch to. + private bool hasAnyAsioDriverInstalled; + // Profile-management buttons retired 2026-05-08 — these actions live in File menu now. + // The methods (SaveProfileAs / UpdateExistingProfile) are still here; they're called from + // the menu item Click handlers in BuildFileMenu. + private readonly Label healthLabel = new() { Text = "Health: disconnected", AutoSize = true }; + private readonly Label statusLabel = new() { Text = "Disconnected", AutoSize = true }; + + // --- Audio profile tab controls (Phase 2 refactor: these were previously in the + // Connectivity & transport dialog as "dialog*" mirrors of hidden form-fields. Now they + // are the canonical UI live on the Audio profile tab, no mirrors required.) --- + private readonly ComboBox codecBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 360, AccessibleName = "Audio codec (Alt+C)" }; + private readonly ListBox sendRateBox = new() { Width = 240, Height = 40, IntegralHeight = false, AccessibleName = "Packet size (Alt+P)" }; + // Min 1 ms is intentionally aggressive — for LAN/localhost users who want to push it. + // Values below ~10 ms cause audible crackling on any network with real jitter. + private readonly NumericUpDown maxLatencyBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 80, Width = 90, AccessibleName = "Audio latency in milliseconds (Alt+L)" }; + // One-shot "Tune latency for best sound" button retired — continuous auto-tune covers + // the same job, and the manual button confused users by sitting next to the auto-tune + // checkbox doing almost the same thing in a less convenient one-shot shape. + private readonly AccessibleCheckBox continuousTuneBox = new() { Text = "Continuous auto-tune latency", AutoSize = true }; + private readonly ComboBox continuousIntervalBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 90, AccessibleName = "Auto-tune latency interval (Alt+I)" }; + // BothIndependent-mode companion controls. Created up front so SelectedIndexChanged + // handlers can be wired alongside the originals; they live in their own TableLayoutPanel + // row that toggles Visible=true only when the audio mode is BothIndependent. The labels + // and mnemonics on the *existing* controls are re-written at mode-switch time so they + // become the WASAPI-lane controls (Alt+W / Alt+Y) and these new ASIO controls take over + // the simpler Alt+L / Alt+T mnemonics — ASIO is the "headline" lane in the new mode + // (the reason a user picked it) so it gets the more memorable shortcuts. + private readonly NumericUpDown maxLatencyAsioBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 10, Width = 90, AccessibleName = "ASIO latency in milliseconds (Alt+L)" }; + private readonly AccessibleCheckBox continuousTuneAsioBox = new() { Text = "Continuous auto-tune ASIO latency", AutoSize = true }; + private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" }; + private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" }; + private readonly AccessibleCheckBox tightLatencyBox = new() { AutoSize = true }; + + // --- Connectivity tab controls (Phase 2 refactor) --- + private readonly LiveCheckedListBox connectedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Connected peers (Alt+C)" }; + private readonly Label connectedPeersStatus = new() { AutoSize = true, Text = "No peer connected." }; + private readonly CheckedListBox discoveredPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Discovered peers (Alt+D)" }; + private readonly Label discoveredPeersStatus = new() { AutoSize = true, Text = "No peer discovered." }; + private readonly CheckedListBox rememberedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Remembered peers (Alt+R)" }; + private readonly Label rememberedPeersStatus = new() { AutoSize = true, Text = "No remembered peer selected." }; + private readonly Button manualAddButton = new() { Text = "Add peer by IP (Alt+&A)", AutoSize = true, AccessibleName = "Add peer by IP" }; + // loggingBox + writeLogsNowButton field instances retired 2026-05-08 — both controls + // now live inside PreferencesDialog. The form-level logFile.Enabled gate is set + // directly from the settings store at startup (see ApplyLoggingEnabled). + // Read-only multiline TextBox at the end of the Connectivity tab. Tab into it to read + // a live snapshot of connection status (peers / pings / uptime / byte rates). Updates + // every status-tick (1 Hz) but ONLY when the user is NOT focused on the box — that way + // NVDA reads it once when the user lands, doesn't re-announce mid-read. Signature + // short-circuit so the actual Text setter only fires when content changes (NVDA pattern + // matches the peer-list refresh). + private readonly TextBox statusReadout = new() + { + Multiline = true, + ReadOnly = true, + TabStop = true, + Width = 460, + Height = 110, + BorderStyle = BorderStyle.FixedSingle, + ScrollBars = ScrollBars.Vertical, + AccessibleName = "Connection status (Alt+S)", + }; + private string lastStatusReadoutText = string.Empty; + // For computing byte-rate deltas. Sampled at each status tick; first tick has no + // prior baseline so the rate shows as 0. + private long lastStatusTxBytes; + private long lastStatusRxBytes; + private DateTime lastStatusSampleUtc = DateTime.MinValue; + // Tracks when the FIRST healthy-peer transition happened in the current "connected" + // span. Cleared when no peers are healthy. Used for the uptime line. + private DateTime? statusConnectedSinceUtc; + // Per-list state (used by sync helpers — was per-method in the old dialog). + private bool suppressConnectedCheck; + private bool suppressDiscoveredCheck; + private bool suppressRememberedCheck; + private string lastConnectedListSignature = string.Empty; + private string lastDiscoveredListSignature = string.Empty; + private string lastRememberedListSignature = string.Empty; + // Local audio bind port. Was a user-editable spinner; removed from the UI on 2026-05-01. + // Unified on 2026-05-05: receiver bind, LAN peer-to-peer dials, and the relay all use a + // single canonical port (RemPacket.DefaultPort = 47830). New manual peers without an + // explicit ":port" suffix default to that, so users never have to type a port for any + // common case — Tailscale, LAN, or a relay server. + private const int LocalAudioPort = RemPacket.DefaultPort; + // The Enable-logs UI is in PreferencesDialog now. Runtime state is logFile.Enabled. + + // --- Continuous auto-tune state (mirror controls live in the dialog) --- + private readonly System.Windows.Forms.Timer continuousTuneTimer = new(); + private readonly Queue recentMaxGaps = new(); + // Last observed value of receiver.SessionsOpenedCount. When this number increases between + // SNAP ticks, a new StreamSession has just opened — the recent-gap and render-callback + // queues contain measurements taken before the new session started (potentially including + // a multi-second cross-session arrival gap), so we flush them and bump + // lastSourceChangeUtc to defer the next auto-tune tick. Without this, the auto-tune would + // see the stale gap and recommend an absurd latency target that prevents the new session + // from ever arming. See the matching diagnostics.ResetGapMeasurements() inside + // AudioReceiver.HandleFormat. 2026-05-11 fix. + private long lastObservedSessionsOpenedCount; + // Parallel rolling window of measured render-callback gaps. Auto-tune previously assumed a + // hardcoded 10ms render period (sized for shared-mode WASAPI), which over-estimated the + // recommendation by 8ms+ on ASIO with small buffers (real callback period ~1ms). Tracking + // the actual measurement lets the formula reflect reality. Same window length as the gap + // queue so they share the lookback discipline. + private readonly Queue recentRenderCbGaps = new(); + private const int RecentMaxGapWindowSeconds = 60; + private DateTime lastUserSliderMoveUtc = DateTime.MinValue; + private bool suppressUserSliderMoveTracking; // true while continuous tune is changing the slider + private bool continuousTuneEnabled; + private int continuousTuneIntervalSec = 5; + private long lastObservedUnderrunCount; + private HeartbeatService? heartbeatService; + // Tracks the most recent PeerHealthState we observed for each peer endpoint, so we can + // detect transitions and play the appropriate cue. Connect: any state → Healthy. + // Disconnect: any state → Unreachable. Stale doesn't fire (it's a transient). + private readonly Dictionary previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase); + private System.Media.SoundPlayer? connectSound; + private System.Media.SoundPlayer? disconnectSound; + // Labels for the three send/receive device lists, captured at layout time so they can be + // re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode + // (driver channel pairs). null until BuildLayout has run. + private MnemonicLabel? sendOutputDevicesLabel; + private MnemonicLabel? sendInputDevicesLabel; + private MnemonicLabel? receiveOutputDevicesLabel; + // Set when the user ticks/unticks a source. Auto-tune skips for one interval afterward so the + // brief settling jitter on a newly-added capture doesn't bias the recommendation upward. + private DateTime lastSourceChangeUtc = DateTime.MinValue; + + // --- Peer state --- + private readonly Dictionary knownPeers = []; + private readonly Dictionary manualPeers = []; + private readonly Dictionary rememberedPeerInstanceIds = new(StringComparer.OrdinalIgnoreCase); + + // Endpoint targets the user has ticked. STICKY — once a peer is selected, its IP/port stays + // here regardless of whether discovery currently sees it. Discovery turnover (peer briefly + // offline, NIC blips, sleep, etc.) does NOT untick or stop the sender. UDP just keeps flowing + // toward the cached IP; if no one's home, packets disappear, and they resume the moment the + // peer comes back. Neither machine has to be online "first" or "in order". + // + // Key: peer instance Guid (or generated one for IP-only manual entries). + // Value: last-known endpoint. If discovery sees the same instance with a new address (DHCP + // renewal etc.) we update the value but keep the key. + private readonly Dictionary selectedPeerEndpoints = []; + // Display labels for selected peers so we can render them in the dialog list even when + // discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline"). + private readonly Dictionary selectedPeerLabels = []; + + private readonly Dictionary lastFocusedListIndices = []; + + private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 }; + // Periodic re-enumeration of WASAPI devices so USB hot-plug/unplug shows up in the lists + // within a second of plugging. Cost per tick in the no-change case is just two COM + // enumerations + a string compare — a few ms on the UI thread, no impact on the audio + // threads (which run on separate MMCSS-boosted threads). The listbox itself is only + // rebuilt when the (id, name) signature actually changes, so NVDA isn't pestered on every + // tick — only when a device truly came or went. + private readonly System.Windows.Forms.Timer deviceRefreshTimer = new() { Interval = 1000 }; + // Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler + // wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts + // (NVDA users typically press a few keys in quick succession to scan through items), + // short enough that a deliberate selection feels responsive. Auto-stop on Tick. + private readonly System.Windows.Forms.Timer asioDriverChangeDebounce = new() { Interval = 300 }; + private string sendOutputDevicesSignature = string.Empty; + private string sendInputDevicesSignature = string.Empty; + private string receiveOutputDevicesSignature = string.Empty; + private string asioSendDevicesSignature = string.Empty; + private string asioReceiveOutputDevicesSignature = string.Empty; + // True while we're rebuilding a CheckedListBox programmatically — suppresses the per-item + // ItemCheck handler so re-adding pre-checked items doesn't fire ApplyAudioRuntime per item. + private bool suppressDeviceCheckChange; + private bool connected; + private DateTime connectedSinceUtc = DateTime.MinValue; + private DateTime lastSnapshotUtc = DateTime.MinValue; + private DateTime lastCaptureZeroLogUtc = DateTime.MinValue; + private bool firstCaptureCallbackLogged; + private bool firstSenderPacketLogged; + private bool firstReceiverPacketLogged; + + // 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 + // below). Control-level state (device ticks, send/receive checkboxes, audio port, volume + // slider, ticked peers) is applied later in OnShown via ApplyPendingProfileToControls() + // because the device lists aren't populated until then. NextProfileTitleToLoad is read by + // Program.cs after the form closes; non-null means "user clicked Switch in Manage profiles — + // re-launch the form under that profile." + private ProfileStore? profileStore; + private string? currentProfileTitle; + /// Full filesystem path of the active profile's JSON file. Tracked separately + /// from because Save As (2026-05-10) lets the user + /// write a profile to an arbitrary path outside . + /// Save / Rename operate on this path so they update / rename the file the user is + /// actually editing — not whatever happens to be in BaseDirectory under the same name. + /// Null on Blank template. + private string? currentProfilePath; + private Profile? pendingProfile; + public string? NextProfileTitleToLoad { get; private set; } + /// Full path of the next profile to load, set when the user opens a file via + /// File → Open profile. Program.cs prefers this over + /// when non-null — it deserialises the JSON from this exact path, not from the active + /// store's base directory. Lets Open profile work for files saved outside that folder. + public string? NextProfilePathToLoad { get; private set; } + // Baseline JSON snapshot of "what the loaded profile was at open / after the last save". + // OnFormClosing compares the current state's JSON to this; if they differ, prompt the + // user. Captured ~3 s after profile-apply (or app start for blank template) so async + // peer-reconnects have settled into the baseline. Null until that timer fires; if it's + // null at close (e.g. user closed within 3 s of opening) we skip the prompt — treating + // very-fast-close as "user knew what they wanted". + private string? baselineProfileJson; + // Set true by MarkProfileDirty() when the user actively changes something. Used as a + // fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip + // the diff entirely when no user action has happened. Cleared on save and on profile load. + private bool unsavedChanges; + // Skip MarkProfileDirty calls while we're programmatically applying a loaded profile. + private bool applyingProfile; + /// Set when the user changed the profiles FOLDER (not just switched profile) + /// via the Manage Profiles dialog. Program.cs reads this after the form closes; if true, + /// it re-runs the entire profile selection flow under the new folder rather than the + /// cheap "switch within current folder" path. Mutually exclusive with + /// in practice. + public bool ReloadFromScratch { get; private set; } + + public MainForm() : this(null, null, null, null) { } + + public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null) + { + this.profileStore = profileStore; + currentProfileTitle = loadedTitle; + // Resolve the active profile's full path from whichever bit of info Program.cs + // passed in. If a path was explicitly given (Open-from-arbitrary-folder flow), + // honour it. Otherwise infer from the store's BaseDirectory + sanitised title. + // Null when on Blank template (no file to track). + if (!string.IsNullOrEmpty(loadedPath)) + { + currentProfilePath = loadedPath; + } + else if (profileStore is not null && !string.IsNullOrEmpty(loadedTitle)) + { + currentProfilePath = profileStore.PathFor(loadedTitle); + } + pendingProfile = profile; + // Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into + // the in-memory settings cache BEFORE the rest of the constructor body reads from it. + // Control states (device ticks, checkboxes, volume) come later in OnShown. + if (profile is not null) settings.ApplyProfile(profile); + + // BothModeWarningSuppressed migration removed 2026-05-11. The popup it suppressed + // (the classic-Both ~45 ms latency warning) is gone with the audio-mode listbox, so + // there's nothing to suppress any more. Old profile JSONs that still contain the + // field deserialise with it ignored. + + Text = FormatWindowTitle(loadedTitle); + Width = 640; + Height = 600; + MinimumSize = new Size(560, 520); + StartPosition = FormStartPosition.CenterScreen; + // No AccessibleName / AccessibleRole on the form. Andre's accessible app does not + // set these and NVDA reads cleanly there; setting them here was over-engineering. + + // Set the checkbox visible Text (with mnemonic) AND AccessibleName here in the + // constructor body. The working RSound.old build used this two-step pattern; setting + // these inline in the field initializer was enough to break NVDA state-change + // announcements on toggle. + // Explicit "(Alt+letter)" suffix on every shortcut-bearing label so both sighted users + // and NVDA see/hear the shortcut consistently. The previous WinForms `&letter` mnemonic + // auto-derivation was unreliable in our layout (FlowLayoutPanel-wrapped lists broke + // the framework's label-to-control association heuristic). ProcessCmdKey handles every + // activation explicitly. Keeping visible label and AccessibleName identical, per Ed's + // "labels are one phrase used twice" rule. + receiveAudioCheckbox.Text = "Receive audio (Alt+&R)"; + receiveAudioCheckbox.AccessibleName = "Receive audio"; + sendMyAudioCheckbox.Text = "Send my audio (Alt+&S)"; + sendMyAudioCheckbox.AccessibleName = "Send my audio"; + + hotkeyController = new MainFormHotkeyController( + settings, + () => sendMyAudioCheckbox.Checked = !sendMyAudioCheckbox.Checked, + () => receiveAudioCheckbox.Checked = !receiveAudioCheckbox.Checked, + ToggleTrayFromHotkey, + () => NudgeVolume(+5), + () => NudgeVolume(-5), + // Three remote-control hotkeys: each one transmits a Control packet to all + // currently-tracked peers via the audio sender's NAT pinhole. The receiving peer + // applies the change locally if it has Profile.AcceptRemoteVolumeCommands on. + // See SendRemoteControl for the dispatch detail. + () => SendRemoteControl(RemoteControlKind.VolumeUp, +5), + () => SendRemoteControl(RemoteControlKind.VolumeDown, -5), + () => SendRemoteControl(RemoteControlKind.MuteToggle, 0), + // Three Windows-global-volume hotkeys: each press makes connected peers nudge + // their Windows default-output-device master volume by one OS-native step (~2%). + // delta=0 — system commands ignore the delta byte, the per-press step size is + // fixed by Windows. Hold the hotkey for bigger jumps. + () => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0), + () => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0), + () => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0)); + // Pipe hotkey controller diagnostics into the main log so we can see, e.g., + // "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and + // "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409: + // another app or another RemSound process already registered this combo)". + // The user gets the regular MessageBox warning on registration failure; the log + // captures the cause so we can debug without guessing. + hotkeyController.Log = msg => logFile.Event($"hotkey: {msg}"); + // Hotkey edits via the Keyboard shortcuts dialog need to mark the profile dirty + // so the close-without-saving prompt fires. The dirty flag is only set by direct + // UI handlers in MainForm; the controller is its own object so it can't reach + // MarkProfileDirty without being told how. Without this hook the user would change + // a binding, close, get no prompt, launch again — and find their new binding + // wasn't in the profile JSON. (The settings cache holds it, but the cache is + // copied to the profile only on Save / Update, not on close.) + hotkeyController.OnHotkeyChanged = MarkProfileDirty; + trayController = new MainFormTrayController( + this, + () => sendMyAudioCheckbox.Checked = true, + () => receiveAudioCheckbox.Checked = true, + Close); + + // --- Set accessibility names --- + // For these four controls the keyboard shortcut is included explicitly in both the + // visible label (set in BuildLayout) and the AccessibleName, instead of relying on the + // WinForms `&letter` auto-derivation. The auto-derivation went wrong because the lists + // are wrapped in a FlowLayoutPanel, which breaks the framework's "label associated with + // the next focusable" heuristic. ProcessCmdKey is what actually performs the focus + // change. Per Ed's working rule "labels are one phrase used twice", visible text and + // AccessibleName here are kept identical. + // 2026-05-08 NVDA-announce fix — embed "(Alt+X)" in AccessibleName for non-CheckBox + // controls. The framework's auto-derivation of KeyboardShortcut from a labelled-by + // MnemonicLabel is unreliable inside FlowLayoutPanel-wrapped rows (sometimes picks + // up the wrong row's label, sometimes finds nothing). Putting the shortcut in the + // AccessibleName text guarantees NVDA announces it consistently right after the + // control name. CheckBoxes own their own &-mnemonic via their Text and don't need + // the suffix in AccessibleName — they're left as bare names. + volumeBar.AccessibleName = "Set volume for all received audio (Alt+V)"; + receiveOutputDevicesList.AccessibleName = "WASAPI outputs for received sound (Alt+3)"; + receiveOutputDevicesStatusLabel.AccessibleName = "Selected receive output device status"; + sendOutputDevicesList.AccessibleName = "WASAPI outputs to send (Alt+4)"; + sendOutputDevicesStatusLabel.AccessibleName = "Selected output device status"; + sendInputDevicesList.AccessibleName = "WASAPI inputs to send (Alt+5)"; + sendInputDevicesStatusLabel.AccessibleName = "Selected input device status"; + asioReceiveOutputDevicesList.AccessibleName = "ASIO outputs for received sound (Alt+1)"; + asioReceiveOutputDevicesStatusLabel.AccessibleName = "Selected ASIO receive channel status"; + asioSendDevicesList.AccessibleName = "ASIO inputs to send (Alt+2)"; + asioSendDevicesStatusLabel.AccessibleName = "Selected ASIO send channel status"; + // Keyboard shortcuts / Minimise to tray / Save / Save as buttons retired 2026-05-08 + // (now File menu items in BuildFileMenu). + asioDriverBox.AccessibleName = "ASIO driver (Alt+D)"; + + // Populate ASIO driver list at startup. Discovers all ASIO drivers via NAudio + a + // registry scan covering 32-bit + 64-bit + HKLM + HKCU views (some drivers register in + // unusual places). The "(none)" sentinel is always row 0 so the user can return to + // WASAPI-only without uninstalling drivers; if no real drivers are found at all, the + // driver picker is hidden entirely in BuildAudioIOTab and the form runs WASAPI-only. + var asioDriverNames = AsioDeviceProbe.EnumerateDriverNames(); + hasAnyAsioDriverInstalled = asioDriverNames.Count > 0; + logFile.Event($"asio drivers enumerated at startup: [{string.Join(", ", asioDriverNames.Select(n => $"\"{n}\""))}]"); + asioDriverBox.Items.Add(NoAsioDriverSentinel); + foreach (var name in asioDriverNames) asioDriverBox.Items.Add(name); + + // Restore the previously-chosen driver if it's still installed; otherwise land on the + // "(none)" sentinel. We deliberately do NOT auto-pick the first real driver — the user + // opts in by arrowing down to a driver row themselves. This is the "driver dropdown + // IS the mode switch" design (2026-05-11): default off, explicit user action turns + // ASIO on. + var savedDriver = settings.LoadAsioDriverName(); + if (!string.IsNullOrWhiteSpace(savedDriver) && asioDriverBox.Items.Contains(savedDriver!)) + { + asioDriverBox.SelectedItem = savedDriver; + } + else + { + asioDriverBox.SelectedIndex = 0; // "(none)" + } + + // Debounced driver-change. Each SelectedIndexChanged restarts the timer; the actual + // apply runs once 300 ms after the user stops moving. Reasons: + // 1. Arrowing through 5 drivers to read their names should not tear down + reopen + // the COM object 5 times — single-client drivers can get confused by rapid + // open/close churn. Timer collapses the burst into one apply at the end. + // 2. Each apply auto-unticks the ASIO send/receive channel rows (see comment in + // the timer Tick handler) — we don't want to thrash that on every arrow press. + asioDriverBox.SelectedIndexChanged += (_, _) => + { + asioDriverChangeDebounce.Stop(); + asioDriverChangeDebounce.Start(); + }; + asioDriverChangeDebounce.Tick += (_, _) => + { + asioDriverChangeDebounce.Stop(); + var selected = asioDriverBox.SelectedItem as string; + // Translate the "(none)" sentinel into a real null at the settings boundary so + // the rest of the app sees the legacy "no ASIO driver chosen" shape. + var newDriver = string.Equals(selected, NoAsioDriverSentinel, StringComparison.Ordinal) ? null : selected; + var previousDriver = settings.LoadAsioDriverName(); + settings.SaveAsioDriverName(newDriver); + var driverActuallyChanged = !string.Equals(previousDriver, newDriver, StringComparison.OrdinalIgnoreCase); + if (driverActuallyChanged) MarkProfileDirty(); + + // When the driver actually changes (including switching to/from "(none)"), clear + // ASIO ticks. The synthetic device-id "asio:N" is a pair-index into whichever + // driver is loaded; pair 2 of the Audient is a different physical channel from + // pair 2 of the Komplete. If we let the old ticks survive a driver swap, the + // wrong channels would be captured/rendered until the user noticed and re-ticked. + if (driverActuallyChanged) + { + try + { + suppressDeviceCheckChange = true; + for (var i = 0; i < asioSendDevicesList.Items.Count; i++) asioSendDevicesList.SetItemChecked(i, false); + for (var i = 0; i < asioReceiveOutputDevicesList.Items.Count; i++) asioReceiveOutputDevicesList.SetItemChecked(i, false); + } + finally { suppressDeviceCheckChange = false; } + } + + // The audio mode is now derived from whether a driver is selected — re-applying + // here switches sender/receiver between WasapiOnly and BothIndependent as needed. + // UpdateBothIndependentVisibility refreshes the ASIO-lane latency row, and + // ApplyContinuousTuneTimer re-evaluates which auto-tune lanes need ticking. + UpdateBothIndependentVisibility(); + ApplyContinuousTuneTimer(); + ApplyAsioMode(); + }; + healthLabel.AccessibleName = "Connection health"; + statusLabel.AccessibleName = "Status"; + codecBox.AccessibleName = "Audio codec (Alt+C)"; + maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)"; + + // --- Populate static choices --- + // Order: PCM first (LAN), Opus 20 ms (higher quality, more robust to loss), Opus 10 ms + // (lower latency at the cost of slightly less audio quality and loss tolerance). Labels + // intentionally avoid all numbers and ms jargon — the slider is the only place ms + // should appear in the UI. + codecBox.Items.AddRange(new object[] + { + new CodecChoice("PCM 48K 24 bit for very fast connections", AudioTransportCodec.Pcm, 0), + new CodecChoice("Opus high quality for fast connections", AudioTransportCodec.Opus, 20), + new CodecChoice("Opus lower quality for slower connections", AudioTransportCodec.Opus, 10), + }); + codecBox.SelectedIndex = ResolveCodecIndex(settings.LoadCodec(), settings.LoadOpusFrameMilliseconds()); + var initialCodec = (CodecChoice)codecBox.SelectedItem!; + sender.ConfigureCodec(initialCodec.Codec, EffectiveOpusFrameMs(initialCodec.Codec, initialCodec.OpusFrameMs, settings.LoadSendRate())); + sender.SetSendRate(settings.LoadSendRate()); + + // Relay-mode plumbing. The sender's UDP socket is always-receiving from form construction + // onwards: in LAN peer-to-peer no inbound traffic arrives at this socket (LAN peers send + // direct to the receiver's well-known port), but in relay mode this is where audio and + // heartbeat replies show up — they come back through the NAT pinhole opened by the first + // outbound packet from this socket. We dispatch by packet type to the right pipeline. + sender.OnInboundPacket = (buffer, length, remote) => + { + if (length < RemPacket.HeaderSize) return; + if (!RemPacket.TryReadHeader(buffer.AsSpan(0, length), out var type, out _, out _)) return; + if (type == RemPacketType.Heartbeat) + { + heartbeatService?.HandleInjectedPacket(buffer, length, remote); + } + else + { + // Format / Audio / KeepAlive — feed into the receiver's existing pipeline as if + // it had arrived on the well-known port. Allow-list, session creation, decoder, + // and playout all work unchanged — they don't know or care which socket the + // packet came in on. + receiver.InjectExternalPacket(buffer, length, remote); + } + }; + sender.StartReceiving(); + // Tight-latency mode is now sender-side only (per-callback PCM emission in ASIO mode). + // The receiver-side hook was removed in the 2026-05-06 cleanup since the resampler is + // no longer in the receive path. The dialog checkbox label still says "Lock to audio + // clock" but only affects the sender now. + var initialTightLatency = settings.LoadTightLatencyMode(); + sender.SetTightLatency(initialTightLatency); + // Log it so post-test analysis can correlate clicks with tight-latency state without + // having to infer from sender-engine restarts. Includes the audio mode because what + // "tight" means is mode-dependent (per-callback ASIO emission vs. WASAPI push-mode). + logFile.Event($"tight latency at startup: {(initialTightLatency ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); + // Native-rate passthrough is automatic now (driven by codec, not a user setting): + // PCM+single-source-WASAPI-push = pass capture-device rate through to the wire; + // Opus = always pre-resample to 48 kHz (encoder is locked at 48 k); MixingEngine / + // ASIO sender = always 48 kHz on the wire. Nothing for the user to toggle. + receiver.SetSmoothness(settings.LoadSmoothness()); + receiver.SetConcealmentArtifact(settings.LoadConcealmentArtifact()); + // Continuous auto-tune state — UI lives in the Connectivity & transport dialog. + continuousTuneEnabled = settings.LoadContinuousAutoTuneEnabled(); + continuousTuneIntervalSec = settings.LoadContinuousAutoTuneIntervalSec(); + + maxLatencyBox.Value = Math.Clamp(settings.LoadMaxLatencyMs(), (int)maxLatencyBox.Minimum, (int)maxLatencyBox.Maximum); + // Select-all-on-focus for the numeric spinners. Fixes the WinForms default where typing + // a new value into a NumericUpDown that already shows "80" produces "8010" instead of + // "10". The Enter event fires when the control receives focus (keyboard or click); we + // post a select-all to it so the cursor lands on a fully-selected value, and any + // typed digits replace the selection. Applies to both the form and dialog instances. + SelectAllOnFocus(maxLatencyBox); + // Push the slider's value to the receiver. In classic modes that's the Mixed route + // (legacy behaviour); in BothIndependent the slider drives the WasapiLane route. The + // ASIO-lane initial push happens later in WireBothIndependentControls once the + // companion control has been created and its loaded value applied. + receiver.SetMaxLatencyMsFor(MaxLatencyBoxRoute, (int)maxLatencyBox.Value); + + // Apply the user's "enable logs" preference to the log gate. Logging is a + // machine-local debug knob stored in AppConfig (default off) — switching profiles + // doesn't change it. RemSoundLog defers actually creating the file in + // \logs\ until the first write arrives while Enabled is true, so an idle "off" + // setting produces zero filesystem traffic. The Preferences dialog's Enable-logs + // checkbox writes through to both AppConfig.LoggingEnabled and logFile.Enabled when + // the user toggles it. + logFile.Enabled = AppConfig.Load().LoggingEnabled; + // DiagnosticsGate gates the engine's hot-path instrumentation (sender/receiver + // max-time probes, spike detector, callback-gap timers) so the audio threads pay + // zero cost when nobody is going to read the numbers. It's ON whenever either the + // Enable-logs checkbox is on OR continuous auto-tune is on (auto-tune needs the + // same per-second diag data the log emits). Real initial value is set after the + // settings cache has finished loading; see the call further down. We seed it false + // here so any early probe fires before the settings load are a no-op. + DiagnosticsGate.Enabled = false; + if (logFile.Enabled) AppendLogEntry("logging enabled at startup"); + + // Sender diagnostic events (capture started, errors, etc.) get written to the log file. + sender.Diagnostic = msg => logFile.Event($"sender: {msg}"); + receiver.Diagnostic = msg => logFile.Event($"receiver: {msg}"); + + // Pre-load peer-state cue sounds so the first playback isn't delayed by file I/O. + // Files are deployed alongside the .exe (see RemSound.App.csproj Content rules). + TryLoadCueSound("connect.wav", out connectSound); + TryLoadCueSound("disconnect.wav", out disconnectSound); + + LoadAudioDevices(); + // Apply persisted ASIO mode from settings — switches sender/receiver backends so the + // device-list refresh below populates with the right kind of entries (WASAPI endpoints + // or ASIO channel pairs). + ApplyAsioMode(); + + // --- Wire main-form events --- + receiveAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); }; + sendMyAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); }; + volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); }; + WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device"); + receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } }; + WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device"); + WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device"); + sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; + sendInputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; + // ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones. + WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel"); + WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel"); + asioReceiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } }; + asioSendDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; + // Profile-management button click wirings retired 2026-05-08 — File menu items now + // call SaveProfileAs() / UpdateExistingProfile() / hotkeyController.ShowKeyboardShortcutsDialog + // / trayController.Minimize() directly. See BuildFileMenu. + + // --- Settings shared with dialog --- + codecBox.SelectedIndexChanged += (_, _) => + { + if (codecBox.SelectedItem is CodecChoice item) + { + settings.SaveCodec(item.Codec); + if (item.Codec == AudioTransportCodec.Opus) settings.SaveOpusFrameMilliseconds(item.OpusFrameMs); + var effectiveFrameMs = EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, settings.LoadSendRate()); + sender.ConfigureCodec(item.Codec, effectiveFrameMs); + logFile.Event($"codec changed to {item.Codec}{(item.Codec == AudioTransportCodec.Opus ? $" {effectiveFrameMs}ms" : "")}"); + MarkProfileDirty(); + } + }; + maxLatencyBox.ValueChanged += (_, _) => + { + // Track when the user (vs continuous auto-tune) moved the slider, so the auto-tune + // can defer to the user's intent for a few seconds before adjusting again. + // suppressUserSliderMoveTracking is set by both continuous auto-tune AND the manual + // one-shot tune button while they're driving the slider — anything where the user + // didn't physically move the control. We use the same flag to take the soft path + // through the receiver: auto-tune lowers don't drain (drift corrector handles it), + // so the slider can drift down silently when conditions improve. Manual user + // lowers still drain, since the user is asking for an immediate, responsive change. + var fromAutoTune = suppressUserSliderMoveTracking; + if (!fromAutoTune) + { + lastUserSliderMoveUtc = DateTime.UtcNow; + // When continuous auto-tune is currently enabled, the latency value is + // effectively runtime state (auto-tune will overwrite whatever the user sets + // anyway), so don't dirty the profile on latency changes — matches the user's + // mental model that "auto-tune on = latency is automatic, not a saved setting". + // Toggling the auto-tune checkbox itself still dirties (handled separately on + // the checkbox CheckedChanged), so a profile that goes from auto-tune-off to + // auto-tune-on is still flagged as needing a save. 2026-05-06. + if (!continuousTuneEnabled) MarkProfileDirty(); + } + settings.SaveMaxLatencyMs((int)maxLatencyBox.Value); + // Route the value to whichever route this slider is currently driving. In every + // classic mode that's Mixed (the legacy behaviour — single-knob world). In + // BothIndependent it's WasapiLane: the slider has been re-labeled "WASAPI + // latency" and the user is adjusting only the WASAPI side of the wire. + var sliderRoute = MaxLatencyBoxRoute; + if (fromAutoTune) + { + receiver.SetMaxLatencyMsSoftFor(sliderRoute, (int)maxLatencyBox.Value); + } + else + { + receiver.SetMaxLatencyMsFor(sliderRoute, (int)maxLatencyBox.Value); + } + }; + // Logging-enabled toggle wiring lives in PreferencesDialog now (it constructs its + // own Enable-logs checkbox and writes through via the applyLoggingEnabled callback + // we pass it from OpenPreferencesDialog). + + // --- Discovery --- + discovery.PeersChanged += () => BeginInvoke(RefreshKnownPeers); + + // Continuous auto-tune timer — checkbox/combo live in the dialog and update our state + // fields directly. The timer reads from those fields; we just (re)apply it here. + continuousTuneTimer.Tick += (_, _) => ContinuousTuneTick(); + ApplyContinuousTuneTimer(); + + // Self-updater background poll. Frequency lives in AppConfig.UpdateCheckFrequency + // (the user picks Never / hourly / 6-hour / 24-hour in Preferences). The updater + // logs its activity through the same RemSoundLog gate as everything else. + updater.Log = msg => logFile.Event($"updater: {msg}"); + updateCheckTimer.Tick += (_, _) => CheckForUpdatesInBackground(); + ApplyUpdateCheckTimer(); + + // --- Status / health ticker --- + statusTimer.Tick += (_, _) => + { + UpdateStatus(); + SnapshotLogIfDue(); + EnsureRequestedAudioRunning(); + // Refresh the Connectivity tab's peer lists from the same 1 Hz tick — replaces + // the dialog's old 1.5 s dedicated refresh timer. Each Sync* helper short-circuits + // when its signature is unchanged so NVDA isn't spammed with re-announcements. + SyncAllPeerLists(); + }; + + // --- Hot-swap device watcher --- + deviceRefreshTimer.Tick += (_, _) => RefreshAudioDeviceLists(); + + BuildLayout(); + LoadRememberedPeersFromSettings(); + // Seed the discovery service's unicast hint list with any remembered peer IPs so that, + // the moment we start announcing, those addresses get directly contacted (bridges + // Tailscale/VPN where broadcast doesn't traverse). + PushDiscoveryUnicastHints(); + hotkeyController.Initialize(this); + + FormClosing += (_, _) => + { + statusTimer.Stop(); + deviceRefreshTimer.Stop(); + continuousTuneTimer.Stop(); + updateCheckTimer.Stop(); + asioDriverChangeDebounce.Stop(); + try { discovery.Dispose(); } catch { } + try { heartbeatService?.Dispose(); } catch { } + + // Audio dispose can hang for many seconds on certain ASIO drivers (Audient is the + // confirmed offender — it takes 10–20 s to release on close in test logs). Run + // sender.Dispose() and receiver.Dispose() on a background thread with a hard + // timeout. If they don't finish in 2 seconds we stop waiting and let the rest of + // the form-close path run; the OS reclaims any audio resources on process exit. + // Worst case the user sees a brief tray-icon stutter; before this they saw a + // ~16 s frozen window before the form went away. + var audioDispose = Task.Run(() => + { + try { sender.Dispose(); } catch { /* ignore */ } + try { receiver.Dispose(); } catch { /* ignore */ } + }); + if (!audioDispose.Wait(TimeSpan.FromSeconds(2))) + { + try { logFile.Event("close: audio dispose taking >2s; letting process exit reclaim"); } catch { } + } + + hotkeyController.Dispose(); + trayController.Dispose(); + logFile.Dispose(); + }; + + Shown += (_, _) => + { + if (!connected) Connect(); + // Apply control-state portion of the loaded profile (device ticks, send/receive + // checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are + // populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.) + // were already pushed into the in-memory settings cache in the constructor. + // ApplyPendingProfileToControls() schedules its own baseline capture; for the + // blank-template case (no pendingProfile) we schedule it here. + if (pendingProfile is null) ScheduleBaselineCapture(); + ApplyPendingProfileToControls(); + // Show/hide the Update vs Save-as buttons based on whether we're on a loaded + // profile or the blank template. + UpdateProfileButtonsVisibility(); + // Andre's app gets focus inside the active tab page for free because his form is + // a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the + // chain TabControl → active TabPage → first child. Our form is the main window, + // not a modal dialog, and that walk doesn't always reach a child — focus can rest + // on the TabControl itself, which makes NVDA announce "tab control" before + // anything else. One explicit Focus() call here mimics Andre's effective behaviour + // without otherwise changing the tab control. NOT a tab-change handler — no + // auto-jumping when the user arrows between tabs, only on first show. + BeginInvoke(() => FocusListControl(connectedPeersList)); + + // Honour AppConfig.StartMinimised — drop straight to the tray after the + // window finishes loading. Wrapped in BeginInvoke so the minimise happens + // *after* Shown completes (otherwise the form-show + form-hide collide and + // some virtual-machine drivers throw a redraw exception). The pending-profile + // apply path above is unaffected — settings/devices/peers are already wired + // up before we hide the window. + if (AppConfig.Load().StartMinimised) + { + BeginInvoke(() => trayController.Minimize()); + } + }; + + statusTimer.Start(); + deviceRefreshTimer.Start(); + } + + // ===================== UI layout ===================== + + private void BuildLayout() + { + // === Menu bar + tabbed root layout === + // Top: MenuStrip with the File menu (replaces the old Profiles & preferences tab — + // profile-management actions and the cross-cutting preferences live here now). + // Middle: TabControl with 3 pages (Connectivity, Audio I/O, Audio profile). + // Bottom: status footer (healthLabel + statusLabel), always visible. + // + // 2026-05-08 refactor: dropped the fourth tab. Save / Save as / Open / Rename / + // Min-to-tray / Keyboard shortcuts / Preferences / Exit now live in the menu bar + // with single-press accelerators (Ctrl+S / Ctrl+K / Ctrl+P / Alt+M) instead of + // requiring a Tab-stop journey to a dedicated tab. Mute cues + Accept remote vol + + // Startup behaviour are now under File → Preferences (Ctrl+P). + var rootLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 3, + }; + rootLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // menu + rootLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // tabs + rootLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // status footer + + BuildConnectivityTab(); + BuildAudioIOTab(); + BuildAudioProfileTab(); + + mainTabControl.TabPages.Add(connectivityTabPage); + mainTabControl.TabPages.Add(audioIOTabPage); + mainTabControl.TabPages.Add(audioProfileTabPage); + // No SelectedIndexChanged handler. No focus management on tab change. Andre's + // accessible app does ZERO event hooking on TabControl — relies entirely on + // default WinForms + NVDA behaviour. Per Ed's repeated request: arrow keys cycle + // tabs (focus on strip), NVDA announces the tab name as the active selection + // changes, no auto-jumping into the page contents. + + var menu = BuildFileMenu(); + rootLayout.Controls.Add(menu, 0, 0); + rootLayout.Controls.Add(mainTabControl, 0, 1); + + // Status footer — always visible. + var statusPanel = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(8, 4, 8, 4), + }; + statusPanel.Controls.Add(healthLabel); + statusPanel.Controls.Add(new Label { Text = " ", AutoSize = true }); + statusPanel.Controls.Add(statusLabel); + rootLayout.Controls.Add(statusPanel, 0, 2); + + SetTabOrder(); + Controls.Add(rootLayout); + // The MenuStrip is added LAST so it claims the form's MainMenuStrip property. Without + // this, the form may not auto-handle Alt-keystroke focus into the menu bar. + MainMenuStrip = menu; + } + + /// Build the File menu and wire each item to its action. Single-press + /// accelerators are set via ShortcutKeys on the menu items so they fire from anywhere + /// in the form. Alt+M (Minimise) is NOT set as a ShortcutKeys binding — it goes through + /// ProcessCmdKey instead, gated per-tab so the Audio I/O tab's Alt+M (Audio mode) wins + /// when that tab is active. + private MenuStrip BuildFileMenu() + { + var menu = new MenuStrip { Dock = DockStyle.Top }; + var fileMenu = new ToolStripMenuItem("&File") { AccessibleName = "File menu" }; + var helpMenu = new ToolStripMenuItem("&Help") { AccessibleName = "Help menu" }; + + var openItem = new ToolStripMenuItem("&Open profile...") + { + AccessibleName = "Open profile", + }; + openItem.Click += (_, _) => OpenProfileFromPicker(); + + var saveItem = new ToolStripMenuItem("&Save") + { + ShortcutKeys = Keys.Control | Keys.S, + AccessibleName = "Save profile", + }; + saveItem.Click += (_, _) => SaveOrSaveAs(); + + var saveAsItem = new ToolStripMenuItem("Save &as...") + { + AccessibleName = "Save profile as", + }; + saveAsItem.Click += (_, _) => SaveProfileAs(); + + var renameItem = new ToolStripMenuItem("&Rename current profile...") + { + AccessibleName = "Rename current profile", + }; + renameItem.Click += (_, _) => RenameCurrentProfile(); + + var minimiseItem = new ToolStripMenuItem("&Minimise to tray") + { + // No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → M) plus the + // configurable "Show or hide window" hotkey cover this. Pre-2026-05-11 Alt+M was + // gated per-tab via ProcessCmdKey because the Audio I/O tab had an "Audio mode" + // listbox that used Alt+M; that listbox is gone now so the gating was retired. + AccessibleName = "Minimise to tray", + }; + minimiseItem.Click += (_, _) => trayController.Minimize(); + + var keyboardItem = new ToolStripMenuItem("&Keyboard shortcuts...") + { + ShortcutKeys = Keys.Control | Keys.K, + AccessibleName = "Keyboard shortcuts", + }; + keyboardItem.Click += (_, _) => hotkeyController.ShowKeyboardShortcutsDialog(this); + + var prefsItem = new ToolStripMenuItem("&Preferences...") + { + ShortcutKeys = Keys.Control | Keys.P, + AccessibleName = "Preferences", + }; + prefsItem.Click += (_, _) => OpenPreferencesDialog(); + + var exitItem = new ToolStripMenuItem("E&xit") + { + AccessibleName = "Exit RemSound", + }; + exitItem.Click += (_, _) => Close(); + + fileMenu.DropDownItems.AddRange(new ToolStripItem[] + { + openItem, + saveItem, + saveAsItem, + renameItem, + new ToolStripSeparator(), + minimiseItem, + keyboardItem, + prefsItem, + new ToolStripSeparator(), + exitItem, + }); + + // Help menu — separate from File so users with their hand on Alt + arrow keys can + // walk straight to it. F1 is the global "open the manual" key; the menu mirrors it + // for users who prefer mouse / arrow navigation. + var helpItem = new ToolStripMenuItem("&Help") + { + ShortcutKeys = Keys.F1, + AccessibleName = "Open user manual", + }; + helpItem.Click += (_, _) => HelpLauncher.OpenManual(); + + var checkForUpdatesItem = new ToolStripMenuItem("&Check for updates") + { + AccessibleName = "Check for updates", + }; + checkForUpdatesItem.Click += (_, _) => CheckForUpdatesManually(); + + var aboutItem = new ToolStripMenuItem("&About RemSound") + { + AccessibleName = "About RemSound", + }; + aboutItem.Click += (_, _) => + { + using var dialog = new AboutDialog(); + dialog.ShowDialog(this); + }; + + helpMenu.DropDownItems.AddRange(new ToolStripItem[] + { + helpItem, + checkForUpdatesItem, + aboutItem, + }); + + menu.Items.Add(fileMenu); + menu.Items.Add(helpMenu); + return menu; + } + + /// Show a file-picker rooted at the profiles folder; on selection, schedule a + /// switch to that profile (same close-and-relaunch flow as the old Switch button). + private void OpenProfileFromPicker() + { + if (profileStore is null) return; + using var dialog = new OpenFileDialog + { + Title = "Open profile", + Filter = "RemSound profiles (*.json)|*.json", + InitialDirectory = profileStore.BaseDirectory, + CheckFileExists = true, + Multiselect = false, + }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + var pickedPath = dialog.FileName; + var picked = Path.GetFileNameWithoutExtension(pickedPath); + if (string.IsNullOrEmpty(picked)) return; + if (string.Equals(pickedPath, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded + // Always pass the full path through. Program.cs deserialises directly from this + // path, so profiles saved outside the active BaseDirectory still load correctly. + NextProfilePathToLoad = pickedPath; + NextProfileTitleToLoad = picked; + AppendLogEntry($"profile open requested: \"{picked}\" from {pickedPath}"); + Close(); + } + + /// Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite + /// it; if we're on the blank template (no current profile), fall through to Save as. + private void SaveOrSaveAs() + { + if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs(); + else UpdateExistingProfile(); + } + + /// Rename the currently-active profile JSON on disk. No-op on the blank + /// template (nothing to rename). Renames update window title + active-profile state + /// in place — no reload required. + private void RenameCurrentProfile() + { + if (profileStore is null) return; + if (string.IsNullOrEmpty(currentProfileTitle)) + { + MessageBox.Show(this, "There is no active profile to rename. Use File → Save as to save the current state under a name first.", + AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var oldTitle = currentProfileTitle; + // Rename uses the simple text-prompt dialog (no overwrite check — pass store=null — + // because rename has its own conflict path: profileStore.Rename returns false when + // the new name already exists, and we surface a popup below). + var newTitle = ProfileSaveAsPrompt.Show( + this, + store: null, + defaultName: oldTitle, + dialogTitle: "Rename profile", + promptLabel: "Please enter a new name for your profile:"); + if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, oldTitle, StringComparison.Ordinal)) return; + + // Rename in the directory the profile actually lives in, NOT in BaseDirectory. The + // active profile may have been Save-As'd to an arbitrary path on a previous step, + // and Rename has to follow it. Falls back to BaseDirectory only when we somehow + // don't have a path tracked (shouldn't happen if currentProfileTitle is non-empty). + var oldPath = currentProfilePath ?? profileStore.PathFor(oldTitle); + var directory = Path.GetDirectoryName(oldPath) ?? profileStore.BaseDirectory; + // Re-encode the new title via PathFor's sanitiser so file-invalid characters get + // stripped consistently with how every other save path names files. + var sanitisedNewName = Path.GetFileName(profileStore.PathFor(newTitle)); + var newPath = Path.Combine(directory, sanitisedNewName); + + if (string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + // Same filename after sanitisation — nothing to do. + return; + } + if (File.Exists(newPath)) + { + MessageBox.Show(this, + $"A profile file named \"{sanitisedNewName}\" already exists in:\n\n{directory}\n\nChoose a different name.", + AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + try + { + if (File.Exists(oldPath)) + { + File.Move(oldPath, newPath); + } + else + { + // Old file is gone (someone deleted it externally). Just write a fresh copy + // under the new name so the active profile still has a backing file. + var profile = BuildCurrentProfile(newTitle); + File.WriteAllText(newPath, JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true })); + } + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not rename \"{oldTitle}\" to \"{newTitle}\":\n\n{ex.Message}", + AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + currentProfileTitle = newTitle; + currentProfilePath = newPath; + Text = FormatWindowTitle(newTitle); + AccessibleName = Text; + AppendLogEntry($"renamed profile \"{oldTitle}\" → \"{newTitle}\" (path: {newPath})"); + } + + /// Show the Preferences dialog. After it closes, mark the profile dirty if + /// the user toggled either of the two profile-bound preferences (mute cues / accept + /// remote vol). Startup behaviour persists outside of the profile so it doesn't + /// trigger the dirty flag. + private void OpenPreferencesDialog() + { + using var dialog = new PreferencesDialog( + settings, + profileStore, + getLoggingEnabled: () => logFile.Enabled, + applyLoggingEnabled: enabled => + { + // Persist the user's choice to AppConfig — it's machine-local, not part of + // the profile, so switching profiles doesn't change it. + var cfg = AppConfig.Load(); + cfg.LoggingEnabled = enabled; + try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ } + // Flip the gate live so the user's tick takes effect immediately. No need to + // restart the app or reopen the log file — writes simply stop / resume mid-flight. + logFile.Enabled = enabled; + // Engine instrumentation rides on logging OR auto-tune — auto-tune needs the + // same per-second diag data the log line emits, so disabling logs alone must + // not starve auto-tune. + UpdateDiagnosticsGate(); + }, + writeLogsNow: () => logFile.Event("user requested write logs now"), + checkForUpdatesNow: () => CheckForUpdatesManually(), + onUpdateFrequencyChanged: ApplyUpdateCheckTimer); + dialog.ShowDialog(this); + if (dialog.ChangedAnyProfileSetting) MarkProfileDirty(); + } + + /// User pressed "Check for updates" (Help menu or Preferences button). Always + /// runs the check noisily — i.e. surfaces "you're up to date" / "v1.x available" via a + /// MessageBox, regardless of the Silently-install setting. Silent install only applies + /// to background polls. Caller is on the UI thread. + private async void CheckForUpdatesManually() + { + var info = await updater.CheckForUpdateAsync().ConfigureAwait(true); + if (info is null) + { + MessageBox.Show(this, + $"You are running the latest version (v{updater.CurrentVersion}).", + "Check for updates", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) + ? $"RemSound {info.Tag} is available. Install now?" + : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; + var choice = MessageBox.Show(this, summary, "Update available", + MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); + if (choice != DialogResult.Yes) return; + await InstallUpdateAsync(info).ConfigureAwait(true); + } + + /// Background-poll path. Runs on a timer tick; surfaces nothing unless an update + /// is available, then either silently installs (per ) + /// or pops the same confirmation dialog the manual path uses. "No update available" is a + /// silent no-op — the user already chose to delegate scheduling to the timer. + private async void CheckForUpdatesInBackground() + { + var info = await updater.CheckForUpdateAsync().ConfigureAwait(true); + // Persist the timestamp so cross-launch scheduling can space the next poll out. + try + { + var cfg = AppConfig.Load(); + cfg.LastUpdateCheckUtc = DateTime.UtcNow; + cfg.Save(); + } + catch { /* timestamp persistence is best-effort */ } + if (info is null) return; + if (AppConfig.Load().SilentlyInstallUpdates) + { + await InstallUpdateAsync(info).ConfigureAwait(true); + return; + } + var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) + ? $"RemSound {info.Tag} is available. Install now?" + : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; + var choice = MessageBox.Show(this, summary, "Update available", + MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); + if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); + } + + /// Download the new release, stage it, spawn the install helper and exit. On + /// any failure shows a MessageBox and stays running — partial installs leave the app + /// untouched. + private async Task InstallUpdateAsync(UpdateInfo info) + { + var ok = await updater.DownloadAndStageInstallAsync(info).ConfigureAwait(true); + if (!ok) + { + MessageBox.Show(this, + $"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}", + "Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + logFile.Event($"updater: install helper launched for {info.Tag}, exiting"); + Application.Exit(); + } + + /// Clamp the release notes to a reasonable dialog-friendly length so the + /// MessageBox doesn't push off-screen. Full notes always live in About and on the + /// GitHub release page. + private static string TruncateForDialog(string s) + { + const int max = 600; + if (s.Length <= max) return s; + return s[..max] + "\n…"; + } + + /// Apply (or stop) the background update-poll timer based on + /// . Called at startup and whenever the user + /// changes the dropdown in Preferences. The first tick fires after one interval — we + /// don't immediately probe GitHub on every app launch because that's both rude and + /// would race with the Profile-load + audio-engine startup the user actually cares + /// about. + private void ApplyUpdateCheckTimer() + { + updateCheckTimer.Stop(); + var freq = AppConfig.Load().UpdateCheckFrequency; + var intervalMs = freq switch + { + UpdateCheckFrequency.EveryHour => 60 * 60 * 1000, + UpdateCheckFrequency.Every6Hours => 6 * 60 * 60 * 1000, + UpdateCheckFrequency.Every24Hours => 24 * 60 * 60 * 1000, + _ => 0, + }; + if (intervalMs <= 0) return; + updateCheckTimer.Interval = intervalMs; + updateCheckTimer.Start(); + } + + /// Connectivity tab — peer lists (connected/discovered/remembered), manual-add, + /// logging toggle and write-logs-now. Wires per-list ItemCheck/KeyDown handlers, status + /// labels, and binds the lists to the existing peer-state dictionaries via the Sync* + /// helpers below. Phase 2 of the 2026-05-06 refactor; previously these controls lived + /// inside ShowConnectivityTransportDialog and the form had a "Connectivity and transport" + /// bridge button. + private void BuildConnectivityTab() + { + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 2, + RowCount = 5, + AutoScroll = true, + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + // === Peer lists wiring === + WireCheckedListAccessibility(connectedPeersList, connectedPeersStatus, "connected peer"); + WireCheckedListAccessibility(discoveredPeersList, discoveredPeersStatus, "discovered peer"); + WireCheckedListAccessibility(rememberedPeersList, rememberedPeersStatus, "remembered peer"); + + // Connected list: items are always checked. Unchecking disconnects. + connectedPeersList.ItemCheck += (_, args) => + { + if (suppressConnectedCheck) return; + BeginInvoke(() => + { + if (args.NewValue == CheckState.Unchecked + && args.Index >= 0 && args.Index < connectedPeersList.Items.Count + && connectedPeersList.Items[args.Index] is PeerListItem item) + { + DeselectPeer(item.Peer.InstanceId); + } + SyncAllPeerLists(); + ApplyAudioRuntime(); + }); + }; + connectedPeersList.KeyDown += (_, args) => + { + if (args.KeyCode == Keys.Delete && connectedPeersList.SelectedItem is PeerListItem selected) + { + var prevIndex = connectedPeersList.SelectedIndex; + DeselectPeer(selected.Peer.InstanceId); + SyncAllPeerLists(); + FocusListItemAfterDelete(connectedPeersList, prevIndex); + ApplyAudioRuntime(); + args.Handled = true; + args.SuppressKeyPress = true; + } + }; + + // Discovered list: items are unchecked. Checking connects + auto-remembers. Delete + // suppressed (discovered peers go away when their broadcaster does). + discoveredPeersList.ItemCheck += (_, args) => + { + if (suppressDiscoveredCheck) return; + if (args.NewValue != CheckState.Checked) return; + BeginInvoke(() => + { + if (args.Index >= 0 && args.Index < discoveredPeersList.Items.Count + && discoveredPeersList.Items[args.Index] is PeerListItem item) + { + SelectPeer(item.Peer); + EnsurePeerRemembered(item.Peer); + } + SyncAllPeerLists(); + ApplyAudioRuntime(); + }); + }; + discoveredPeersList.KeyDown += (_, args) => + { + if (args.KeyCode == Keys.Delete) { args.Handled = true; args.SuppressKeyPress = true; } + }; + + // Remembered list: items are unchecked (connected ones hide). Check reconnects, Delete forgets. + rememberedPeersList.ItemCheck += (_, args) => + { + if (suppressRememberedCheck) return; + if (args.NewValue != CheckState.Checked) return; + BeginInvoke(async () => + { + if (args.Index >= 0 && args.Index < rememberedPeersList.Items.Count + && rememberedPeersList.Items[args.Index] is RememberedPeerItem item) + { + PeerAnnouncement? toSelect = null; + if (rememberedPeerInstanceIds.TryGetValue(item.Entry, out var existingId) + && knownPeers.TryGetValue(existingId, out var known)) + { + toSelect = known; + } + else + { + var address = await ResolvePeerAddressAsync(item.Entry); + if (address is not null) + { + var peer = CreateManualPeer(item.Entry, address); + manualPeers[peer.InstanceId] = peer; + rememberedPeerInstanceIds[item.Entry] = peer.InstanceId; + toSelect = peer; + } + } + if (toSelect is not null) SelectPeer(toSelect); + } + RefreshKnownPeers(); + SyncAllPeerLists(); + ApplyAudioRuntime(); + }); + }; + rememberedPeersList.KeyDown += (_, args) => + { + if (args.KeyCode == Keys.Delete) + { + var prevIndex = rememberedPeersList.SelectedIndex; + RemoveSelectedRememberedPeer(rememberedPeersList); + SyncAllPeerLists(); + FocusListItemAfterDelete(rememberedPeersList, prevIndex); + args.Handled = true; + args.SuppressKeyPress = true; + } + }; + + // === Manual add + Write logs now === + manualAddButton.Click += async (_, _) => + { + var entry = ManualPeerPrompt.Show(this); + if (string.IsNullOrWhiteSpace(entry)) return; + await AddManualPeerAsync(entry); + SyncAllPeerLists(); + BeginInvoke(() => FocusListControl(connectedPeersList)); + }; + // Logging controls retired from this tab 2026-05-08 — they now live in the + // Preferences dialog (File → Preferences, Ctrl+P) as the last two items. + + // === Layout === + // 5 rows: 0–2 the three peer lists, 3 manual-add, 4 connection-status readout. + panel.RowCount = 5; + FormLayoutRows.AddCheckedListRow(panel, 0, "Connected peers (Alt+&C)", connectedPeersList, connectedPeersStatus, FocusListControl); + FormLayoutRows.AddCheckedListRow(panel, 1, "Discovered peers (Alt+&D)", discoveredPeersList, discoveredPeersStatus, FocusListControl); + FormLayoutRows.AddCheckedListRow(panel, 2, "Remembered peers (Alt+&R)", rememberedPeersList, rememberedPeersStatus, FocusListControl); + panel.Controls.Add(new Label { Text = "Manual peer", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 3); + panel.Controls.Add(manualAddButton, 1, 3); + + // Connection status readout — last row, tab-into-able. + var statusLabel = new MnemonicLabel { Text = "Connection status (Alt+&S)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = statusReadout }; + statusLabel.Click += (_, _) => statusReadout.Focus(); + panel.Controls.Add(statusLabel, 0, 4); + panel.Controls.Add(statusReadout, 1, 4); + + // Initial render so the box has content the moment the user tabs into it. + RefreshStatusReadout(); + + connectivityTabPage.Controls.Add(panel); + // Initial population so screen readers see something on first open. + SyncAllPeerLists(); + } + + /// Audio I/O tab — full content. All the existing main-form audio controls + /// (mode, ASIO driver, send/receive checkboxes, device lists, volume) live here. + private void BuildAudioIOTab() + { + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 2, + RowCount = 10, + AutoScroll = true, + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + // 2026-05-11 mnemonic refresh — Ed's spec for the Audio I/O tab: + // ASIO driver → Alt+D (drives audio mode: "(none)" = WASAPI-only, + // any real driver = WASAPI + ASIO) + // Set volume → Alt+V (unchanged) + // ASIO outputs (receive) → Alt+1 + // ASIO inputs (send) → Alt+2 + // WASAPI outputs (receive) → Alt+3 + // WASAPI outputs (send) → Alt+4 + // WASAPI inputs (send) → Alt+5 + // Receive Alt+R, Send Alt+S — unchanged. + // + // The pre-2026-05-11 "Audio mode" listbox (Alt+M) is gone — selecting a driver here + // brings the ASIO half of the form to life; selecting "(none)" hides it again. On + // machines with no ASIO drivers installed the driver picker is hidden entirely (there + // is nothing to switch to) and the form runs WASAPI-only. + if (hasAnyAsioDriverInstalled) + { + asioDriverLabel = new MnemonicLabel { Text = "ASIO driver (Alt+&D)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = asioDriverBox }; + asioDriverLabel.Click += (_, _) => asioDriverBox.Focus(); + panel.Controls.Add(asioDriverLabel, 0, 0); + panel.Controls.Add(asioDriverBox, 1, 0); + } + else + { + // Reserve the row but keep both cells empty. We could collapse the row entirely, + // but leaving it as a no-op AutoSize row keeps the rest of the row indices stable + // with the original layout (each subsequent control still lives in row N). + } + + // Each checkbox wrapped in its own FlowLayoutPanel — required for NVDA state-change + // announcements to fire reliably (a CheckBox directly in a TableLayoutPanel cell + // suppresses them; the FlowLayoutPanel wrapper restores the announcement chain). + var receiveCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + receiveCheckboxPanel.Controls.Add(receiveAudioCheckbox); + panel.Controls.Add(receiveCheckboxPanel, 1, 1); + receiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 2, "WASAPI outputs for received sound (Alt+&3)", receiveOutputDevicesList, receiveOutputDevicesStatusLabel, FocusListControl); + asioReceiveOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 3, "ASIO outputs for received sound (Alt+&1)", asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, FocusListControl); + FormLayoutRows.AddRow(panel, 4, "Set volume for all received audio (Alt+&V)", volumeBar, FocusControl); + var sendCheckboxPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + sendCheckboxPanel.Controls.Add(sendMyAudioCheckbox); + panel.Controls.Add(sendCheckboxPanel, 1, 5); + sendOutputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 6, "WASAPI outputs to send (Alt+&4)", sendOutputDevicesList, sendOutputDevicesStatusLabel, FocusListControl); + sendInputDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 7, "WASAPI inputs to send (Alt+&5)", sendInputDevicesList, sendInputDevicesStatusLabel, FocusListControl); + asioSendDevicesLabel = FormLayoutRows.AddCheckedListRow(panel, 8, "ASIO inputs to send (Alt+&2)", asioSendDevicesList, asioSendDevicesStatusLabel, FocusListControl); + + audioIOTabPage.Controls.Add(panel); + } + + /// Audio profile tab — split into two GroupBox sections so NVDA announces the + /// section name when focus first crosses into it. Send-side group: codec, packet size, + /// lock to audio clock. Receive-side group: latency + auto-tune controls, buffer + /// smoothness, artefact. Inside each group, focus traversal is the natural top-to-bottom + /// order; crossing the boundary triggers NVDA's grouping-name announcement on the first + /// child of the entered group. GroupBox `Text` is also the accessible name (single-source + /// label rule); no `&` mnemonic since GroupBox isn't focusable. Phase 3 of the refactor; + /// previously these controls lived inside ShowConnectivityTransportDialog as "dialog*" + /// mirrors of hidden form-fields. + private void BuildAudioProfileTab() + { + // Outer layout: one column, two rows — one row per GroupBox. AutoScroll on so the + // tab page handles overflow rather than the inner groups clipping their contents. + var outerPanel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 1, + RowCount = 2, + AutoScroll = true, + }; + outerPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + outerPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var sendGroup = new GroupBox + { + Text = "Audio send parameters", + AutoSize = true, + Dock = DockStyle.Top, + Padding = new Padding(8, 4, 8, 8), + }; + var receiveGroup = new GroupBox + { + Text = "Audio receive parameters", + AutoSize = true, + Dock = DockStyle.Top, + Padding = new Padding(8, 4, 8, 8), + }; + + BuildAudioSendGroupContents(sendGroup); + BuildAudioReceiveGroupContents(receiveGroup); + + outerPanel.Controls.Add(sendGroup, 0, 0); + outerPanel.Controls.Add(receiveGroup, 0, 1); + audioProfileTabPage.Controls.Add(outerPanel); + } + + /// Send-side controls: codec + packet size on row 0, lock-to-audio-clock on + /// row 1. The codec and packet-size combo share a row because they're tightly coupled + /// (changing the codec resets the meaningful packet sizes). Lock-to-clock is a sender- + /// side toggle whose label varies by audio mode (WASAPI vs ASIO vs Both). + private void BuildAudioSendGroupContents(GroupBox group) + { + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 2, + AutoSize = true, + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + // === Row 0: codec + packet size === + // Packet size: per-packet audio frame the sender chops into. Smaller = lower send-side + // accumulator latency at the cost of doubling packet rate (more sensitive to USB / + // network hiccups). Renamed from "Send rate" 2026-05-02 — the label confused users + // into thinking it was a bandwidth knob. + sendRateBox.Items.Clear(); + sendRateBox.Items.Add("Standard (5 ms PCM, 10/20 ms Opus)"); + sendRateBox.Items.Add("Small (2.5 ms PCM, 5/10 ms Opus, LAN only)"); + sendRateBox.SelectedIndex = (int)settings.LoadSendRate(); + sendRateBox.SelectedIndexChanged += (_, _) => + { + var newRate = (SendRate)sendRateBox.SelectedIndex; + settings.SaveSendRate(newRate); + sender.SetSendRate(newRate); + ApplySendRateToOpus(newRate); + MarkProfileDirty(); + }; + // 2026-05-08 mnemonic refresh per Ed's spec: + // Audio codec (renamed from "Transport codec") → Alt+C (was Alt+T) + // Packet size → Alt+P (was Alt+S) + var codecAndSendLabel = new Label { Text = "Audio codec (Alt+&C) / Packet size (Alt+&P)", AutoSize = true, Anchor = AnchorStyles.Left }; + codecAndSendLabel.Click += (_, _) => FocusControl(codecBox); + var codecRowPanel = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; + codecRowPanel.Controls.Add(codecBox); + codecRowPanel.Controls.Add(new Label { Text = " Packet size: ", AutoSize = true, Padding = new Padding(8, 6, 0, 0) }); + codecRowPanel.Controls.Add(sendRateBox); + panel.Controls.Add(codecAndSendLabel, 0, 0); + panel.Controls.Add(codecRowPanel, 1, 0); + + // === Row 1: Tight latency (sender-side, mode-dependent label) === + // Mnemonic moved from G to K (2026-05-08). The label varies per current audio mode + // but every variant starts with "Lock to audio clock" — putting "&k" in "Loc&k" gives + // the user a stable Alt+K regardless of which mode-dependent suffix is shown. + // Only WasapiOnly and BothIndependent are reachable through the UI after the + // 2026-05-11 cleanup (an ASIO driver is either selected or it isn't); the AsioOnly / + // classic-Both branches survive only to make pre-2026-05-11 profile JSONs that hold + // those enum values render with sensible labels until the user nudges the driver. + var currentAudioModeForLabel = settings.LoadAudioMode(); + var tightLatencyText = currentAudioModeForLabel switch + { + AudioMode.WasapiOnly => "Lock to audio clock, WASAPI sender (Alt+&K)", + AudioMode.BothIndependent => "Lock to audio clock, WASAPI + ASIO senders (Alt+&K)", + _ => "Lock to audio clock (Alt+&K)", + }; + var tightLatencyAccessible = currentAudioModeForLabel switch + { + AudioMode.WasapiOnly => "Lock to audio clock (Alt+K) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up.", + AudioMode.BothIndependent => "Lock to audio clock (Alt+K) — both lanes tighten independently. WASAPI lane uses push-mode (single source); ASIO lane emits per callback. Brief clicks possible on either if the link can't keep up.", + _ => "Lock to audio clock (Alt+K) — sender-side timing tighten.", + }; + tightLatencyBox.Text = tightLatencyText; + tightLatencyBox.AccessibleName = tightLatencyAccessible; + tightLatencyBox.Checked = settings.LoadTightLatencyMode(); + tightLatencyBox.CheckedChanged += (_, _) => + { + settings.SaveTightLatencyMode(tightLatencyBox.Checked); + sender.SetTightLatency(tightLatencyBox.Checked); + logFile.Event($"tight latency changed to {(tightLatencyBox.Checked ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); + MarkProfileDirty(); + }; + var tightLatencyLabel = new Label { Text = tightLatencyText, AutoSize = true, Anchor = AnchorStyles.Left }; + tightLatencyLabel.Click += (_, _) => tightLatencyBox.Focus(); + var tightLatencyContainer = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + tightLatencyContainer.Controls.Add(tightLatencyBox); + panel.Controls.Add(tightLatencyLabel, 0, 1); + panel.Controls.Add(tightLatencyContainer, 1, 1); + + group.Controls.Add(panel); + } + + /// Receive-side controls: latency spinner + tune button + continuous-tune toggle + /// + interval combo on row 0; smoothness list on row 1; artefact combo (with hint) on + /// row 2. Tab order within the group flows naturally top-down. The tune-button hookup + /// uses TuneLatencyAsync via the cancellation token field. + private void BuildAudioReceiveGroupContents(GroupBox group) + { + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 4, + AutoSize = true, + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + // === Row 0: ASIO latency row — VISIBLE ONLY IN BOTHINDEPENDENT MODE === + // In BothIndependent the WASAPI and ASIO lanes have independent targets. The ASIO row + // sits above the WASAPI row so it's first in tab order (ASIO is the "headline" lane + // a user picks the new mode for) and takes the simpler Alt+L / Alt+T mnemonics — when + // the user enters BothIndependent the WASAPI row's labels mutate to "WASAPI latency + // (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)", surrendering L/T to ASIO. In every + // classic mode this row is hidden via UpdateBothIndependentVisibility and the WASAPI + // row keeps the original "Audio latency (Alt+L)" labels. + asioLatencyLabel = new Label { Text = "ASIO latency in milliseconds (Alt+&L)", AutoSize = true, Anchor = AnchorStyles.Left }; + asioLatencyLabel.Click += (_, _) => FocusControl(maxLatencyAsioBox); + SelectAllOnFocus(maxLatencyAsioBox); + maxLatencyAsioBox.Value = Math.Clamp(settings.LoadMaxLatencyMsAsio(), (int)maxLatencyAsioBox.Minimum, (int)maxLatencyAsioBox.Maximum); + continuousTuneAsioBox.Text = "Continuous auto-tune ASIO latency (Alt+&T)"; + continuousTuneAsioBox.AccessibleName = "Continuous auto-tune ASIO latency"; + continuousTuneAsioBox.Checked = settings.LoadContinuousAutoTuneAsioEnabled(); + asioDelayContainer = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + }; + asioDelayContainer.Controls.Add(maxLatencyAsioBox); + asioDelayContainer.Controls.Add(continuousTuneAsioBox); + panel.Controls.Add(asioLatencyLabel, 0, 0); + panel.Controls.Add(asioDelayContainer, 1, 0); + + // === Row 1: WASAPI / classic latency row === + // Labels and mnemonics mutate based on audio mode — see UpdateBothIndependentVisibility. + // Classic modes: "Audio latency (Alt+L)" / "Continuous auto-tune latency (Alt+T)" + // BothIndependent: "WASAPI latency (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)" + // The interval dropdown stays attached to this row in both modes; one interval setting + // governs both lanes' auto-tune ticks (separate intervals would be more knobs than + // value). + wasapiLatencyLabel = new Label { Text = "Audio latency in milliseconds (Alt+&L)", AutoSize = true, Anchor = AnchorStyles.Left }; + wasapiLatencyLabel.Click += (_, _) => FocusControl(maxLatencyBox); + SelectAllOnFocus(maxLatencyBox); + continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)"; + continuousTuneBox.AccessibleName = "Continuous auto-tune latency"; + continuousTuneBox.Checked = continuousTuneEnabled; + // 3 seconds added 2026-05-06 alongside the lookback shortening — the new combination + // lets users dial in tighter latency on calm networks much faster (each tick samples + // then potentially lowers, so 3s ticks × 5ms/tick = 1.7ms/sec descent). + continuousIntervalBox.Items.Clear(); + continuousIntervalBox.Items.AddRange(new object[] { "3 seconds", "5 seconds", "10 seconds", "15 seconds", "30 seconds" }); + continuousIntervalBox.SelectedIndex = continuousTuneIntervalSec switch { 3 => 0, 5 => 1, 15 => 3, 30 => 4, _ => 2 }; + continuousIntervalBox.Enabled = continuousTuneEnabled; + var continuousIntervalLabel = new Label { Text = "Auto-tune latency interval (Alt+&I)", AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(8, 6, 0, 0) }; + var delayContainer = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + }; + delayContainer.Controls.Add(maxLatencyBox); + delayContainer.Controls.Add(continuousTuneBox); + delayContainer.Controls.Add(continuousIntervalLabel); + delayContainer.Controls.Add(continuousIntervalBox); + panel.Controls.Add(wasapiLatencyLabel, 0, 1); + panel.Controls.Add(delayContainer, 1, 1); + + continuousTuneBox.CheckedChanged += (_, _) => + { + continuousTuneEnabled = continuousTuneBox.Checked; + settings.SaveContinuousAutoTuneEnabled(continuousTuneEnabled); + continuousIntervalBox.Enabled = continuousTuneEnabled; + ApplyContinuousTuneTimer(); + MarkProfileDirty(); + }; + continuousIntervalBox.SelectedIndexChanged += (_, _) => + { + continuousTuneIntervalSec = continuousIntervalBox.SelectedIndex switch { 0 => 3, 1 => 5, 3 => 15, 4 => 30, _ => 10 }; + settings.SaveContinuousAutoTuneIntervalSec(continuousTuneIntervalSec); + ApplyContinuousTuneTimer(); + MarkProfileDirty(); + }; + + // === Row 1: Buffer smoothness === + smoothnessBox.Items.Clear(); + smoothnessBox.Items.Add("10 — smoothest, no clicks, longest delay"); + smoothnessBox.Items.Add("9"); + smoothnessBox.Items.Add("8"); + smoothnessBox.Items.Add("7"); + smoothnessBox.Items.Add("6"); + smoothnessBox.Items.Add("5"); + smoothnessBox.Items.Add("4"); + smoothnessBox.Items.Add("3 — default, brief clicks"); + smoothnessBox.Items.Add("2"); + smoothnessBox.Items.Add("1 — tightest delay, frequent clicks"); + // Map int smoothness ↔ list index: index 0 = 10, index 9 = 1. + smoothnessBox.SelectedIndex = Math.Clamp(10 - settings.LoadSmoothness(), 0, 9); + smoothnessBox.SelectedIndexChanged += (_, _) => + { + if (smoothnessBox.SelectedIndex < 0) return; + var newSmoothness = 10 - smoothnessBox.SelectedIndex; + settings.SaveSmoothness(newSmoothness); + receiver.SetSmoothness(newSmoothness); + logFile.Event($"buffer smoothness changed to {newSmoothness}"); + MarkProfileDirty(); + }; + var smoothnessLabel = new Label { Text = "Buffer smoothness (Alt+&B)", AutoSize = true, Anchor = AnchorStyles.Left }; + smoothnessLabel.Click += (_, _) => FocusControl(smoothnessBox); + panel.Controls.Add(smoothnessLabel, 0, 2); + panel.Controls.Add(smoothnessBox, 1, 2); + + // === Row 2: Artefact === + artefactBox.Items.Clear(); + artefactBox.Items.Add("Noise burst (default) — broadband shhh, blends into music"); + artefactBox.Items.Add("Click — no concealment, raw zero-fill click"); + var loadedArtifact = settings.LoadConcealmentArtifact(); + artefactBox.SelectedIndex = loadedArtifact == ConcealmentArtifact.Click ? 1 : 0; + artefactBox.SelectedIndexChanged += (_, _) => + { + if (artefactBox.SelectedIndex < 0) return; + var newArtifact = artefactBox.SelectedIndex == 1 + ? ConcealmentArtifact.Click + : ConcealmentArtifact.NoiseBurst; + settings.SaveConcealmentArtifact(newArtifact); + receiver.SetConcealmentArtifact(newArtifact); + logFile.Event($"concealment artifact changed to {newArtifact}"); + MarkProfileDirty(); + }; + var artefactLabel = new Label { Text = "Artefact sound type (Alt+&A)", AutoSize = true, Anchor = AnchorStyles.Left }; + artefactLabel.Click += (_, _) => FocusControl(artefactBox); + var artefactHint = new Label + { + Text = "Use this to change the way audio artefacts sound when they appear (e.g. on brief network or buffer hiccups). Changes take effect immediately.", + AutoSize = false, + Width = 420, + Height = 36, + Anchor = AnchorStyles.Left, + }; + var artefactContainer = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill }; + artefactContainer.Controls.Add(artefactHint); + artefactContainer.Controls.Add(artefactBox); + panel.Controls.Add(artefactLabel, 0, 3); + panel.Controls.Add(artefactContainer, 1, 3); + + // Wire ASIO companion control event handlers and apply initial visibility now that + // every element exists. After this method returns the panel is ready to dock into + // its parent groupbox. + WireBothIndependentControls(); + UpdateBothIndependentVisibility(); + + group.Controls.Add(panel); + } + + /// Calls all three peer-list sync helpers in one go. Wired into the existing + /// status timer (1 Hz) so the Connectivity tab stays current with discovery / heartbeat + /// state without needing its own dedicated timer. + private void SyncAllPeerLists() + { + SyncConnectedList(); + SyncDiscoveredList(); + SyncRememberedList(); + RefreshStatusReadout(); + } + + /// Updates the Connection-status read-only TextBox at the bottom of the + /// Connectivity tab. Skips the actual Text-set when (a) the user is currently focused on + /// the box (so NVDA isn't disrupted while reading), or (b) the freshly-computed text + /// matches the last-rendered text (avoids redundant work and any chance of NVDA stutter). + /// 2026-05-06. + private void RefreshStatusReadout() + { + var text = ComputeStatusText(); + if (text == lastStatusReadoutText) return; + lastStatusReadoutText = text; + // Don't disrupt the user mid-read. The text we computed is already cached so the + // next tick will pick it up if the user moves focus away. + if (statusReadout.Focused) return; + statusReadout.Text = text; + } + + private string ComputeStatusText() + { + // Compute byte-rates from delta since last sample. First call has no baseline so + // the rate shows as 0; second and subsequent calls produce a real number. + var nowUtc = DateTime.UtcNow; + var txBytes = sender.BytesSent; + var rxBytes = receiver.BytesReceived; + double txKbs = 0, rxKbs = 0; + if (lastStatusSampleUtc != DateTime.MinValue) + { + var elapsed = (nowUtc - lastStatusSampleUtc).TotalSeconds; + if (elapsed > 0) + { + txKbs = (txBytes - lastStatusTxBytes) / 1024.0 / elapsed; + rxKbs = (rxBytes - lastStatusRxBytes) / 1024.0 / elapsed; + } + } + lastStatusSampleUtc = nowUtc; + lastStatusTxBytes = txBytes; + lastStatusRxBytes = rxBytes; + + // Healthy peers from heartbeat. Map each to its display label (the user-friendly + // name from selectedPeerLabels, falling back to the address). + var healthy = new List<(string Label, int? RttMs)>(); + if (heartbeatService is { } hb) + { + foreach (var ph in hb.GetAllPeerHealth()) + { + if (ph.State != PeerHealthState.Healthy) continue; + // Find a label by walking selectedPeerEndpoints for a matching address+port. + string? label = null; + foreach (var (id, ep) in selectedPeerEndpoints) + { + if (ep.Address.Equals(ph.AudioEndpoint.Address) && ep.Port == ph.AudioEndpoint.Port) + { + label = selectedPeerLabels.GetValueOrDefault(id); + break; + } + } + label ??= ph.AudioEndpoint.ToString(); + int? rtt = ph.RttMs is { } r ? RoundToFive(r) : null; + healthy.Add((label, rtt)); + } + } + + // Update the connected-since timestamp based on whether we have any healthy peers. + if (healthy.Count > 0) + { + statusConnectedSinceUtc ??= nowUtc; + } + else + { + statusConnectedSinceUtc = null; + } + + // Build the readout, one line per piece of information. Uses CRLF so the TextBox + // multiline rendering is correct on Windows + readable to NVDA. + var sb = new System.Text.StringBuilder(); + if (healthy.Count == 0) + { + sb.AppendLine("Not connected to any peer."); + } + else + { + sb.AppendLine($"Connected to {healthy.Count} peer{(healthy.Count == 1 ? "" : "s")}."); + foreach (var (label, rtt) in healthy) + { + var rttStr = rtt is { } r ? $"{r} ms" : "unknown"; + sb.AppendLine($" {label}: ping {rttStr}"); + } + } + + if (statusConnectedSinceUtc is { } since) + { + var span = nowUtc - since; + sb.AppendLine($"Uptime: {FormatUptime(span)}."); + } + else + { + sb.AppendLine("Uptime: 0 seconds."); + } + + sb.Append($"Receiving {rxKbs:0.0} kB/s; sending {txKbs:0.0} kB/s."); + return sb.ToString(); + } + + private static string FormatUptime(TimeSpan span) + { + if (span.TotalSeconds < 1) return "0 seconds"; + if (span.TotalMinutes < 1) return $"{(int)span.TotalSeconds} second{((int)span.TotalSeconds == 1 ? "" : "s")}"; + if (span.TotalHours < 1) return $"{(int)span.TotalMinutes} minute{((int)span.TotalMinutes == 1 ? "" : "s")} {span.Seconds} second{(span.Seconds == 1 ? "" : "s")}"; + return $"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? "" : "s")} {span.Minutes} minute{(span.Minutes == 1 ? "" : "s")}"; + } + + private void SyncConnectedList() + { + var desired = new List<(PeerListItem Item, Guid Id)>(); + foreach (var (id, ep) in selectedPeerEndpoints) + { + if (knownPeers.TryGetValue(id, out var known)) + { + desired.Add((new PeerListItem(known), id)); + } + else + { + var label = selectedPeerLabels.GetValueOrDefault(id, ep.Address.ToString()); + var ghost = new PeerAnnouncement(id, $"{label} (offline)", ep.Port, true, true, DateTime.UtcNow, ep.Address); + desired.Add((new PeerListItem(ghost), id)); + } + } + desired = desired.OrderBy(d => d.Item.Peer.Name).ThenBy(d => d.Item.Peer.Address.ToString()).ToList(); + + // Signature is stable identity only (peer id + name + address + port). Live status + // (connected, codec, direction, RTT) is NOT in the signature — it gets updated in + // place via RefreshItem so NVDA focus on a row survives tick updates. + var signature = string.Join("|", desired.Select(d => d.Item.StableKey())); + if (signature != lastConnectedListSignature) + { + lastConnectedListSignature = signature; + var selectedId = connectedPeersList.SelectedItem is PeerListItem si ? si.Peer.InstanceId : Guid.Empty; + suppressConnectedCheck = true; + try + { + connectedPeersList.BeginUpdate(); + connectedPeersList.Items.Clear(); + var idx = -1; + foreach (var d in desired) + { + var i = connectedPeersList.Items.Add(d.Item, isChecked: true); + if (selectedId == d.Id) idx = i; + } + if (idx >= 0) connectedPeersList.SelectedIndex = idx; + connectedPeersList.EndUpdate(); + } + finally { suppressConnectedCheck = false; } + } + + UpdateConnectedListLiveStatus(); + } + + private void UpdateConnectedListLiveStatus() + { + var healthByAddress = new Dictionary(); + if (heartbeatService is not null) + { + foreach (var ph in heartbeatService.GetAllPeerHealth()) + { + healthByAddress[ph.AudioEndpoint.Address.ToString()] = ph; + } + } + var sendingNow = connected && IsSendEnabled && sender.IsRunning; + var codecLabel = FormatCodecLabel(sender.Codec, sender.OpusFrameMilliseconds); + + for (int i = 0; i < connectedPeersList.Items.Count; i++) + { + if (connectedPeersList.Items[i] is not PeerListItem item) continue; + var s = item.Status; + var prevText = item.ToString(); + + var addrKey = item.Peer.Address.ToString(); + var ph = healthByAddress.GetValueOrDefault(addrKey); + var isHealthy = ph is { State: PeerHealthState.Healthy }; + + s.Connected = isHealthy; + s.Sending = isHealthy && sendingNow; + s.Receiving = isHealthy && receiver.IsRunning && receiver.IsReceivingFromAddress(item.Peer.Address); + s.CodecLabel = isHealthy ? codecLabel : null; + s.RttMs = isHealthy && ph is { RttMs: { } rtt } + ? RoundToFive(rtt) + : null; + + if (item.ToString() != prevText) + { + connectedPeersList.RefreshItemPublic(i); + } + } + } + + private void SyncDiscoveredList() + { + // Discovered = peers seen by discovery NOT currently connected, AND NOT manual peers + // (manual peers were added by user typing an IP — they aren't really "discovered"). + var desired = new List<(PeerListItem Item, Guid Id)>(); + foreach (var peer in knownPeers.Values + .Where(p => !selectedPeerEndpoints.ContainsKey(p.InstanceId)) + .Where(p => !manualPeers.ContainsKey(p.InstanceId)) + .OrderBy(p => p.Name).ThenBy(p => p.Address.ToString())) + { + desired.Add((new PeerListItem(peer), peer.InstanceId)); + } + + var signature = string.Join("|", desired.Select(d => d.Item.ToString())); + if (signature == lastDiscoveredListSignature) return; + lastDiscoveredListSignature = signature; + + var selectedId = discoveredPeersList.SelectedItem is PeerListItem si ? si.Peer.InstanceId : Guid.Empty; + suppressDiscoveredCheck = true; + try + { + discoveredPeersList.BeginUpdate(); + discoveredPeersList.Items.Clear(); + var idx = -1; + foreach (var d in desired) + { + var i = discoveredPeersList.Items.Add(d.Item, isChecked: false); + if (selectedId == d.Id) idx = i; + } + if (idx >= 0) discoveredPeersList.SelectedIndex = idx; + discoveredPeersList.EndUpdate(); + } + finally { suppressDiscoveredCheck = false; } + } + + private void SyncRememberedList() + { + // Hide entries whose mapped peer is currently connected — they live in Connected + // until disconnection, then reappear here. + var hiddenEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (entry, id) in rememberedPeerInstanceIds) + { + if (selectedPeerEndpoints.ContainsKey(id)) hiddenEntries.Add(entry); + } + + var entries = settings.LoadRememberedPeers() + .Where(e => !hiddenEntries.Contains(e)) + .ToList(); + + var signature = string.Join("|", entries); + if (signature == lastRememberedListSignature) return; + lastRememberedListSignature = signature; + + var selectedEntry = rememberedPeersList.SelectedItem is RememberedPeerItem si ? si.Entry : null; + suppressRememberedCheck = true; + try + { + rememberedPeersList.BeginUpdate(); + rememberedPeersList.Items.Clear(); + var idx = -1; + foreach (var entry in entries) + { + var item = new RememberedPeerItem(entry); + var i = rememberedPeersList.Items.Add(item, isChecked: false); + if (entry == selectedEntry) idx = i; + } + if (idx >= 0) rememberedPeersList.SelectedIndex = idx; + rememberedPeersList.EndUpdate(); + } + finally { suppressRememberedCheck = false; } + } + + /// Profiles & preferences tab — list of saved profiles with inline Switch / + /// Rename / Delete buttons + Save / Save-as + the mute-cues checkbox + remote-volume + /// opt-in + Keyboard-shortcuts + Minimise-to-tray. Phase 4 of the refactor; the old + /// "Manage profiles" dialog (and the ProfileManagementDialog.cs file) is gone. + // BuildProfilesPrefsTab and its companion UI methods (UpdateCurrentProfileLabel, + // RefreshProfilesList, SwitchSelectedProfile, RenameSelectedProfile, + // DeleteSelectedProfile) were deleted on 2026-05-08 when the fourth tab was retired. + // The same actions now live on the File menu: + // * Switch profile → File → Open profile (OpenProfileFromPicker) + // * Rename profile → File → Rename current profile (RenameCurrentProfile) + // * Save / Save as → File → Save / Save as + // * Delete profile → removed from app UI; users can delete via the OS file + // picker's right-click menu (File → Open profile shows the + // folder; right-click any entry → Delete). + // * Mute cues / Accept remote / Startup behaviour → File → Preferences (Ctrl+P). + // * Keyboard shortcuts → File → Keyboard shortcuts (Ctrl+K). + // * Minimise to tray → File → Minimise to tray (Alt+M, gated per-tab so the + // Audio I/O tab's Audio mode mnemonic wins on that tab). + + // FocusFirstControlOnActiveTab was removed in the arrow-key fix. The original intent — + // landing on something useful after a tab change — turned out to defeat the standard + // tab-strip navigation: every SelectedIndexChanged would yank focus off the strip, + // breaking arrow-key cycling and NVDA's tab-announcement chain. WinForms' built-in + // behaviour (focus stays on strip until user presses Tab) is what we want. + + // FocusFirstChildOnActiveTab removed — caused unwanted "jumping into the box" on tab + // change. Andre's app doesn't do this; we shouldn't either. Arrow keys cycle tabs with + // focus staying on the strip; user presses Tab once to enter the active page. + + private void SetTabOrder() + { + // Andre's accessible app sets no TabIndex on the TabControl itself — defaults work. + // Tab order is set per-tab now (each TabPage has its own focus traversal). Keeping + // the existing relative order from the pre-tab single-form layout so the user's + // muscle memory is preserved. + // + // Connectivity tab. + connectedPeersList.TabIndex = 0; + discoveredPeersList.TabIndex = 1; + rememberedPeersList.TabIndex = 2; + manualAddButton.TabIndex = 3; + statusReadout.TabIndex = 4; + // Audio I/O tab. The driver picker is row 0 when present (a real driver chosen here + // is what enables ASIO). Audio-mode listbox retired 2026-05-11. + asioDriverBox.TabIndex = 0; + receiveAudioCheckbox.TabIndex = 1; + receiveOutputDevicesList.TabIndex = 2; + asioReceiveOutputDevicesList.TabIndex = 3; + volumeBar.TabIndex = 4; + sendMyAudioCheckbox.TabIndex = 5; + sendOutputDevicesList.TabIndex = 6; + sendInputDevicesList.TabIndex = 7; + asioSendDevicesList.TabIndex = 8; + // Profiles & preferences tab retired 2026-05-08 — the controls that used to live + // there have moved to the File menu (Open/Save/Save as/Rename/etc.) and the + // Preferences dialog (Mute cues / Accept remote vol / Startup behaviour). + } + + /// True if the given control is on the currently-selected tab. Used by + /// to gate Alt+letter shortcuts so they only fire when the + /// target is on the visible tab — pressing Alt+L on the Connectivity tab does NOT auto- + /// switch to the Audio profile tab and focus the latency spinner. The user has to first + /// Ctrl+Tab to the right tab. This is the explicit per-tab shortcut isolation rule. + private bool IsControlOnActiveTab(Control? c) + { + if (c is null) return false; + var active = mainTabControl.SelectedTab; + if (active is null) return false; + for (var p = c.Parent; p is not null; p = p.Parent) + { + if (ReferenceEquals(p, active)) return true; + } + return false; + } + + private bool IsSendEnabled => sendMyAudioCheckbox.Checked; + private bool IsReceiveEnabled => receiveAudioCheckbox.Checked; + + // ===================== Connectivity / lifecycle ===================== + + private void Connect() + { + if (connected) return; + connected = true; + connectedSinceUtc = DateTime.UtcNow; + try + { + discovery.Start(LocalAudioPort, IsSendEnabled, IsReceiveEnabled); + logFile.Event("discovery started"); + } + catch (Exception ex) + { + AppendLogEntry($"discovery failed: {ex.Message}"); + logFile.Event($"discovery failed: {ex.Message}"); + } + + // Heartbeat starts as soon as we connect, regardless of send/receive state. That way + // RTT and reachability are measured even when the user has both audio toggles off, + // and the moment they tick a peer the heartbeat picks them up. + // + // Single-port mode (2026-05-06): the heartbeat service no longer binds a UDP socket. + // Outbound pings/pongs route through the audio sender's socket (sender.SendVia, sharing + // the audio NAT pinhole on the audio port). Inbound heartbeats arrive on either of two + // App-owned sockets and are forwarded into HandleInjectedPacket: + // * The audio receiver's listener (LAN — peers send heartbeat to our audio port). + // * The audio sender's recv-side via OnInboundPacket (relay-return path). + // Because the receiver's listener is bound for the duration of the connection (split + // from the playback gate, see AudioReceiver.SetPlaybackEnabled), heartbeat works even + // when "Receive audio" is off — no separate +2 port needed any more. + try + { + heartbeatService = new HeartbeatService(msg => logFile.Event($"heartbeat: {msg}")); + heartbeatService.SendTransport = sender.SendVia; + receiver.OnHeartbeatReceived = (buffer, length, remote) => + heartbeatService.HandleInjectedPacket(buffer, length, remote); + // Remote-control handler (volume up/down, mute toggle from a connected peer). + // Hooks into the same single-port receive path: the audio receiver's listener + // sees the Control packet, parses it, and fires this delegate. We marshal back + // onto the UI thread to mutate volumeBar / mute state. + receiver.OnRemoteControlReceived = HandleRemoteControlPacket; + heartbeatService.Start(); + } + catch (Exception ex) + { + AppendLogEntry($"heartbeat failed to start: {ex.Message}"); + logFile.Event($"heartbeat failed to start: {ex.Message}"); + } + + // Single-port mode: bind the audio receiver's listener socket immediately on connect, + // independent of the user's "Receive audio" tick. The listener carries heartbeat + // packets even when audio playback is off; ApplyAudioRuntime below toggles playback + // separately via SetPlaybackEnabled. Without this, heartbeats sent to our audio port + // would hit a closed socket and the peer would see us as unreachable until the user + // ticked Receive. + try + { + receiver.Start(LocalAudioPort); + logFile.Event($"receiver listener started port={LocalAudioPort}"); + } + catch (Exception ex) + { + AppendLogEntry($"receiver listener failed to start: {ex.Message}"); + logFile.Event($"receiver listener failed to start: {ex.Message}"); + } + + RefreshKnownPeers(); + ApplyAudioRuntime(); + UpdateStatus(); + } + + private void HandleCapabilityChange() + { + if (!connected) return; + discovery.UpdateCapabilities(LocalAudioPort, IsSendEnabled, IsReceiveEnabled); + ApplyAudioRuntime(); + } + + private void EnsureRequestedAudioRunning() + { + if (!connected) return; + + var wantSend = IsSendEnabled; + var wantReceive = IsReceiveEnabled; + if ((wantSend && !sender.IsRunning) || (wantReceive && !receiver.IsRunning)) + { + ApplyAudioRuntime(); + } + } + + private void ApplyAudioRuntime() + { + if (!connected) return; + + var endpoints = SelectedSendEndpoints(); + sender.SetReceivers(endpoints); + // Single-port heartbeat: tracked peers' audio endpoints ARE the heartbeat target. + // HeartbeatService sends via sender.SendVia (wired in Connect) so heartbeat shares + // the audio NAT pinhole on the audio port — no separate socket, no +2 port. + heartbeatService?.SetTrackedPeers(endpoints); + + // Sender does NOT depend on a peer being currently online. As long as the user has ticked + // "Send my audio" AND a capture device, we keep capturing and emitting UDP. If no peer is + // selected, packets just go nowhere; the moment a peer is ticked, packets start flowing. + // Either machine can start first; either machine can disappear and reappear; nothing + // teardowns. UDP doesn't care. + // + // No fallback to the system default capture device — if the user hasn't ticked anything, + // we send nothing. Avoids the "wrong source captured silently" failure mode. + var wantReceive = IsReceiveEnabled; + // Note: wantSend is driven by IsSendEnabled alone, NOT by HasCheckedSendDevice. If the + // user has the "send my audio" toggle on but has unticked all devices for a moment + // (typical mid-edit state), we keep the sender RUNNING with empty specs rather than + // tearing it down and rebuilding. The reason: tearing the engine down closes the ASIO + // driver, and Audient's driver (plus a couple of others) hangs for ~5 seconds when + // closed and reopened in quick succession, which freezes RemSound and previously took + // the laptop process down with it. Empty specs are handled gracefully — MixingEngine + // keeps its mix task running over zero sources (produces silence), AsioCaptureBackend + // keeps the driver open with zero active channel pairs (callbacks fire harmlessly). + // The sender only actually stops when the user toggles off "send my audio" itself. + var wantSend = IsSendEnabled; + + try + { + // Single-port model: the receiver's listener socket is bound at Connect time and + // stays bound for the connection's lifetime (so heartbeats keep flowing regardless + // of the playback toggle). The "Receive audio" checkbox now only gates playback. + // Push the device list and allow-list before enabling playback, so the very first + // packets after enable have correct routing. + if (wantReceive && !receiver.IsRunning) + { + ApplyReceiveDevices(); + PushAllowedReceiveSenders(); + receiver.SetPlaybackEnabled(true); + logFile.Event("receiver playback enabled"); + } + else if (!wantReceive && receiver.IsRunning) + { + receiver.SetPlaybackEnabled(false); + logFile.Event("receiver playback disabled"); + } + + if (wantSend && !sender.IsRunning) + { + ApplySendSources(); + sender.Start(); + logFile.Event($"sender started codec={sender.Codec} sources=[{sender.CaptureDeviceName}] peers=[{string.Join(",", endpoints.Select(e => e.ToString()))}]"); + } + else if (wantSend && sender.IsRunning) + { + // Already running — user may have ticked/unticked devices in either list. Push + // the new spec list down; sender restarts the mixer transparently if the set changed. + ApplySendSources(); + } + else if (!wantSend && sender.IsRunning) + { + sender.Stop(); + logFile.Event("sender stopped"); + } + } + catch (Exception ex) + { + AppendLogEntry($"audio runtime error: {ex.Message}"); + logFile.Event($"audio runtime error: {ex.Message}"); + } + } + + private bool HasCheckedSendDevice() => + sendOutputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) + || sendInputDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null) + || asioSendDevicesList.CheckedItems.OfType().Any(c => c.DeviceId is not null); + + private void ApplySendSources() + { + // Build the unified spec list from all three send-side lists. The CompositeCaptureBackend + // splits this set internally into WASAPI specs (sent to MixingEngine) and ASIO specs + // (sent to AsioCaptureBackend). Both run in parallel and their outputs are summed. + var specs = new List(); + foreach (var item in sendOutputDevicesList.CheckedItems.OfType()) + { + if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, item.Name)); + } + foreach (var item in sendInputDevicesList.CheckedItems.OfType()) + { + if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, item.Name)); + } + foreach (var item in asioSendDevicesList.CheckedItems.OfType()) + { + // ASIO channels have no Loopback/Input distinction — Kind is irrelevant for ASIO + // (AsioDeviceId.TryParse routes by id format, not by Kind). Use Input for symmetry. + if (item.DeviceId is { } id) specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, item.Name)); + } + sender.Configure(specs); + // Tell the auto-tune to ignore the next tick AND throw away the rolling window — newly- + // added captures take a moment to fill their first ring buffer, and that initial-fill + // jitter shouldn't bias the recommendation. The window-clear is the load-bearing piece; + // without it a single big-gap entry keeps the recommendation pinned for ~30 s. + InvalidateAutoTuneHistory(); + } + + // ===================== Devices ===================== + + private void LoadAudioDevices() + { + try + { + var outputs = AudioDeviceCatalog.LoadOutputs(); + var inputs = AudioDeviceCatalog.LoadInputs(); + + // All three lists start UNCHECKED every session. No persisted selection — by design. + // The user re-ticks once per session, avoiding the "wrong-device-still-selected" + // failure mode after a card unplug or ID change. + sendOutputDevicesSignature = SyncDeviceCheckedListBox(sendOutputDevicesList, outputs); + sendInputDevicesSignature = SyncDeviceCheckedListBox(sendInputDevicesList, inputs); + receiveOutputDevicesSignature = SyncDeviceCheckedListBox(receiveOutputDevicesList, outputs); + + // Ground-truth log so we can definitively see the device list and initial check state + // each launch — diagnoses any "device was checked at startup" mystery. + var outputList = string.Join(", ", outputs.Select(d => $"\"{d.Name}\"")); + var inputList = string.Join(", ", inputs.Select(d => $"\"{d.Name}\"")); + logFile.Event($"device load: {outputs.Count} active render devices [{outputList}]; {inputs.Count} active capture devices [{inputList}]; all lists initial check state: unchecked"); + } + catch (Exception ex) + { + AppendLogEntry($"could not enumerate devices: {ex.Message}"); + } + } + + /// + /// Re-enumerates active audio endpoints and rebuilds any list whose set of devices changed. + /// Driven by at 3 s intervals so USB hot-plug / unplug + /// shows up without an app restart. Each list is rebuilt only when its (id, name) signature + /// changes — the no-op fast path leaves NVDA's focus and the listbox state untouched. + /// Check state is preserved by DeviceId across rebuilds; if a checked device disappeared, + /// the relevant runtime Apply* is called so the engine sees the change. + /// + private void RefreshAudioDeviceLists() + { + // WASAPI lists are always populated from the Windows audio device catalogue — they're + // visible regardless of ASIO state. ASIO lists are populated from the chosen driver's + // channel-pair info, but only if ASIO is enabled with a valid driver; otherwise empty. + IReadOnlyList wasapiOutputs; + IReadOnlyList wasapiInputs; + IReadOnlyList asioInputChoices = []; + IReadOnlyList asioOutputChoices = []; + try + { + wasapiOutputs = AudioDeviceCatalog.LoadOutputs(); + wasapiInputs = AudioDeviceCatalog.LoadInputs(); + + var currentMode = settings.LoadAudioMode(); + if (ModeUsesAsio(currentMode) && settings.LoadAsioDriverName() is { } asioDriver && !string.IsNullOrWhiteSpace(asioDriver)) + { + var info = AsioDeviceProbe.ProbeDriverInfo(asioDriver); + if (info.InputChannelCount >= 0 && info.OutputChannelCount >= 0) + { + LogAsioChannelNamesIfChanged(asioDriver, info); + asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames); + asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames); + } + } + } + catch (Exception ex) + { + logFile.Event($"device refresh failed: {ex.GetType().Name}: {ex.Message}"); + return; + } + + var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature); + var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature); + var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature); + var asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature); + var asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature); + + if (sendOutputChanged || sendInputChanged || asioSendChanged) + { + ApplyAudioRuntime(); + } + if (receiveOutputChanged || asioReceiveChanged) + { + ApplyReceiveDevices(); + } + } + + /// + /// Builds entries for ASIO channel pairs (stereo) using the + /// driver's own per-channel names, prefixed with the driver name. The + /// uses the synthetic "asio:<pair>" + /// format that and parse. + /// + /// Label format: "<driverName> — Pair N (channels A/B): <lname> / <rname>". + /// Driver name first so NVDA announces "Audient EVO 8 — …" up front and there's no + /// ambiguity about which card's channels you're picking. Pair number gives anchor context + /// when the per-channel names are terse. If the left and right names share a common stem + /// ending in L/R or 1/2 we collapse them ("Main Output L"/"Main Output R" → "Main Output L/R"). + /// + private static IReadOnlyList BuildAsioChannelPairChoices(string driverName, IReadOnlyList channelNames) + { + var pairCount = channelNames.Count / 2; + var choices = new List(pairCount); + for (var i = 0; i < pairCount; i++) + { + var lName = channelNames[i * 2]; + var rName = channelNames[i * 2 + 1]; + var combined = TryCollapsePairLabel(lName, rName) ?? $"{lName} / {rName}"; + var label = $"{driverName} — Pair {i + 1} (channels {i * 2 + 1}/{i * 2 + 2}): {combined}"; + choices.Add(new AudioDeviceChoice(label, AsioDeviceId.Format(i), CaptureKind.Loopback)); + } + return choices; + } + + /// + /// Try to collapse "Main Output L" / "Main Output R" → "Main Output L/R", and similar + /// patterns ending in "1"/"2" or "Left"/"Right". Returns null if the names don't share a + /// common stem we can collapse cleanly — caller falls back to "Left / Right" form. + /// + private static string? TryCollapsePairLabel(string left, string right) + { + if (string.Equals(left, right, StringComparison.Ordinal)) return left; + + // Walk back from the end to find the divergence point — if the only difference is the + // last character (and it's a known L/R pattern), collapse. Otherwise null. + var commonLen = 0; + var min = Math.Min(left.Length, right.Length); + while (commonLen < min && left[commonLen] == right[commonLen]) commonLen++; + if (commonLen == 0) return null; + var stem = left[..commonLen].TrimEnd(); + var ldiff = left[commonLen..]; + var rdiff = right[commonLen..]; + if ((ldiff == "L" && rdiff == "R") || (ldiff == "1" && rdiff == "2") || + (ldiff == "Left" && rdiff == "Right") || (ldiff == "left" && rdiff == "right")) + { + return $"{stem} {ldiff}/{rdiff}"; + } + return null; + } + + private string lastLoggedAsioChannelSignature = string.Empty; + + /// + /// Logs ASIO channel names once (and re-logs if they change because the driver was swapped). + /// Helpful for diagnosing "the names don't look like the WASAPI ones" issues — we can see + /// exactly what the ASIO driver is reporting and decide if our label-building is at fault + /// or the driver is just terse. + /// + private void LogAsioChannelNamesIfChanged(string driverName, AsioDriverProbeResult info) + { + var sig = $"{driverName}|in:{string.Join(",", info.InputChannelNames)}|out:{string.Join(",", info.OutputChannelNames)}"; + if (sig == lastLoggedAsioChannelSignature) return; + lastLoggedAsioChannelSignature = sig; + logFile.Event($"asio channel names for \"{driverName}\": inputs=[{string.Join(", ", info.InputChannelNames.Select(n => $"\"{n}\""))}] outputs=[{string.Join(", ", info.OutputChannelNames.Select(n => $"\"{n}\""))}]"); + } + + /// + /// Sync wrapper around that compares against the + /// stored signature and only rebuilds on change. Returns true when the list was rebuilt. + /// + private bool MaybeSyncList(CheckedListBox list, IReadOnlyList devices, ref string lastSignature) + { + var signature = ComputeDeviceSignature(devices); + if (signature == lastSignature) return false; + SyncDeviceCheckedListBox(list, devices); + lastSignature = signature; + return true; + } + + /// + /// Rebuilds the list of devices in a CheckedListBox, preserving check state by DeviceId + /// and SelectedIndex by DeviceId where possible. Returns the (newly-computed) signature + /// of the device set so callers can stash it. Suppresses the per-item ItemCheck handler + /// during the rebuild so existing handlers don't fire spuriously while we re-add items. + /// + private string SyncDeviceCheckedListBox(CheckedListBox list, IReadOnlyList devices) + { + var signature = ComputeDeviceSignature(devices); + var checkedIds = new HashSet( + list.CheckedItems.OfType().Where(c => c.DeviceId is not null).Select(c => c.DeviceId!), + StringComparer.OrdinalIgnoreCase); + var selectedId = (list.SelectedItem as AudioDeviceChoice)?.DeviceId; + + suppressDeviceCheckChange = true; + try + { + list.BeginUpdate(); + list.Items.Clear(); + var idx = -1; + for (var i = 0; i < devices.Count; i++) + { + var d = devices[i]; + var isChecked = d.DeviceId is not null && checkedIds.Contains(d.DeviceId); + list.Items.Add(d, isChecked); + if (selectedId is not null && d.DeviceId == selectedId) idx = i; + } + if (idx >= 0) list.SelectedIndex = idx; + list.EndUpdate(); + } + finally + { + suppressDeviceCheckChange = false; + } + return signature; + } + + private static string ComputeDeviceSignature(IReadOnlyList devices) => + string.Join(";", devices.Select(d => $"{d.DeviceId}|{d.Name}")); + + private void ApplyReceiveDevices() + { + // Combine WASAPI device-ids and ASIO synthetic-ids into one list. The + // CompositeRenderBackend splits them internally and feeds each child the right subset. + var ids = new List(); + foreach (var c in receiveOutputDevicesList.CheckedItems.OfType()) + { + if (!string.IsNullOrEmpty(c.DeviceId)) ids.Add(c.DeviceId); + } + foreach (var c in asioReceiveOutputDevicesList.CheckedItems.OfType()) + { + if (!string.IsNullOrEmpty(c.DeviceId)) ids.Add(c.DeviceId); + } + receiver.SetOutputDevices(ids); + } + + /// + /// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective + /// modes after the 2026-05-11 cleanup: + /// * WasapiOnly: no ASIO driver selected. WASAPI lists shown, ASIO lists hidden, + /// fast path active. + /// * BothIndependent: an ASIO driver is selected. All five lists shown; WASAPI and ASIO + /// run as two parallel lanes each at their own native latency. + /// On every call, list visibility is refreshed and any ticks in now-hidden lists are wiped + /// so they don't contribute ghost specs to the next ApplyAudioRuntime push. + /// + /// True if this audio-mode runs an ASIO backend. BothIndependent does; WasapiOnly + /// does not. The legacy AudioMode.Both and AudioMode.AsioOnly values can only arrive here + /// from an old persisted profile JSON; they're treated as ASIO-using so deserialisation + /// stays graceful but no UI path can produce them any more. + private static bool ModeUsesAsio(AudioMode mode) => + mode == AudioMode.AsioOnly || mode == AudioMode.Both || mode == AudioMode.BothIndependent; + + // ===================== BothIndependent companion controls ===================== + // + // The ASIO-lane latency row created in BuildAudioReceiveGroupContents. These four refs + // live at class scope so UpdateBothIndependentVisibility can hide/show the row whenever + // the audio mode changes, and so WireBothIndependentControls can attach event handlers + // once the form is built. + private Label? asioLatencyLabel; + private Label? wasapiLatencyLabel; + private FlowLayoutPanel? asioDelayContainer; + + /// + /// Attaches the ValueChanged / CheckedChanged handlers for the ASIO-lane companion + /// controls. Called once from BuildAudioReceiveGroupContents after both rows exist. + /// + private void WireBothIndependentControls() + { + // ASIO latency spinner. Persists to settings + pushes to the receiver's per-route + // setter so the audio thread sees the new target on the next Read. Soft-set on the + // receiver (no drain) — drift correction will shrink the buffer naturally on a + // lower; raising is silent by definition. + maxLatencyAsioBox.ValueChanged += (_, _) => + { + var value = (int)maxLatencyAsioBox.Value; + var fromAutoTune = suppressUserAsioSliderMoveTracking; + if (!fromAutoTune) + { + lastUserAsioSliderMoveUtc = DateTime.UtcNow; + // When auto-tune is on, the slider value is runtime state (auto-tune will + // overwrite it). Don't dirty the profile for those changes — matches the + // user's mental model of "auto-tune on = latency is automatic, not saved". + if (!settings.LoadContinuousAutoTuneAsioEnabled()) MarkProfileDirty(); + } + settings.SaveMaxLatencyMsAsio(value); + // Soft path on auto-tune (no drain, drift corrector handles the lower); hard + // path on a user-initiated change (immediate, responsive). + if (fromAutoTune) + { + receiver.SetMaxLatencyMsSoftFor(RenderRoute.AsioLane, value); + } + else + { + receiver.SetMaxLatencyMsFor(RenderRoute.AsioLane, value); + } + }; + + continuousTuneAsioBox.CheckedChanged += (_, _) => + { + settings.SaveContinuousAutoTuneAsioEnabled(continuousTuneAsioBox.Checked); + ApplyContinuousTuneTimer(); + MarkProfileDirty(); + }; + + // Push initial value to the receiver so the per-route state matches the persisted + // slider value even before any audio flows. + receiver.SetMaxLatencyMsSoftFor(RenderRoute.AsioLane, (int)maxLatencyAsioBox.Value); + } + + /// + /// Toggles visibility of the BothIndependent-only ASIO row and rewrites the WASAPI row's + /// labels and mnemonics based on the current audio mode. In classic modes the WASAPI row + /// reverts to its legacy "Audio latency (Alt+L)" / "Continuous auto-tune latency (Alt+T)" + /// shape and the ASIO row is hidden. In BothIndependent the ASIO row is shown above the + /// WASAPI row (first in tab order) and the WASAPI row's labels become "WASAPI latency + /// (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)" so the two sets of mnemonics don't + /// collide. Idempotent — call from anywhere the audio mode might have changed. + /// + private void UpdateBothIndependentVisibility() + { + if (asioLatencyLabel is null || wasapiLatencyLabel is null || asioDelayContainer is null) return; + var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; + asioLatencyLabel.Visible = inBothIndependent; + asioDelayContainer.Visible = inBothIndependent; + maxLatencyAsioBox.Visible = inBothIndependent; + continuousTuneAsioBox.Visible = inBothIndependent; + if (inBothIndependent) + { + wasapiLatencyLabel.Text = "WASAPI latency in milliseconds (Alt+&W)"; + maxLatencyBox.AccessibleName = "WASAPI latency in milliseconds (Alt+W)"; + continuousTuneBox.Text = "Continuous auto-tune WASAPI latency (Alt+&Y)"; + continuousTuneBox.AccessibleName = "Continuous auto-tune WASAPI latency"; + } + else + { + wasapiLatencyLabel.Text = "Audio latency in milliseconds (Alt+&L)"; + maxLatencyBox.AccessibleName = "Audio latency in milliseconds (Alt+L)"; + continuousTuneBox.Text = "Continuous auto-tune latency (Alt+&T)"; + continuousTuneBox.AccessibleName = "Continuous auto-tune latency"; + } + } + + // Tracks the last time the user moved the ASIO slider — auto-tune defers tuning for one + // tick afterward so the user's deliberate change isn't immediately overridden. Parallels + // lastUserSliderMoveUtc which serves the same role for the WASAPI / classic slider. + private DateTime lastUserAsioSliderMoveUtc = DateTime.MinValue; + + /// True if this audio-mode runs a WASAPI backend. Today only AsioOnly excludes + /// it; everything else (WasapiOnly, BothIndependent, the legacy Both) shows the WASAPI + /// device lists. Kept as a predicate so a future mode addition just needs to update the + /// expression rather than every call site. + private static bool ModeUsesWasapi(AudioMode mode) => mode != AudioMode.AsioOnly; + + // ModeFromListIndex / ListIndexFromMode retired 2026-05-11 — there is no audio-mode + // listbox any more, so there are no indices to translate. The audio mode is derived + // directly from settings.LoadAudioMode(), which itself reads back the ASIO driver name + // ("none" → WasapiOnly, anything else → BothIndependent). + + private void ApplyAsioMode() + { + var requestedMode = settings.LoadAudioMode(); + var driver = settings.LoadAsioDriverName(); + var resolvedMode = requestedMode; + // Sanity: an ASIO mode without a driver demotes to WasapiOnly. Should be unreachable + // through normal UI flow (the listbox is disabled when there are no drivers). + if (ModeUsesAsio(requestedMode) && string.IsNullOrWhiteSpace(driver)) + { + resolvedMode = AudioMode.WasapiOnly; + } + + var asioDriverArg = ModeUsesAsio(resolvedMode) ? driver : null; + try + { + sender.SetAudioMode(resolvedMode, asioDriverArg); + receiver.SetAudioMode(resolvedMode, asioDriverArg); + logFile.Event(resolvedMode == AudioMode.WasapiOnly + ? "audio backend: WASAPI only (fast path)" + : $"audio backend: WASAPI + ASIO driver \"{asioDriverArg}\" (independent lanes, no mix)"); + } + catch (Exception ex) + { + logFile.Event($"backend switch failed: {ex.GetType().Name}: {ex.Message}"); + } + + // List visibility per mode. BothIndependent shows both WASAPI and ASIO lists — user + // needs to assign devices to each lane. WasapiOnly hides the ASIO lists. + var wasapiListsVisible = ModeUsesWasapi(resolvedMode); + var asioListsVisible = ModeUsesAsio(resolvedMode); + // Driver picker stays visible whenever at least one ASIO driver is installed — that + // way the user can turn ASIO on (by picking a driver) or off (by selecting "(none)") + // without it disappearing on them. BuildAudioIOTab already omits the picker entirely + // on machines with zero ASIO drivers (hasAnyAsioDriverInstalled false), in which case + // both the listbox and its label are null-or-hidden and these lines are no-ops. + asioDriverBox.Visible = hasAnyAsioDriverInstalled; + if (asioDriverLabel is not null) asioDriverLabel.Visible = hasAnyAsioDriverInstalled; + receiveOutputDevicesList.Visible = wasapiListsVisible; + receiveOutputDevicesStatusLabel.Visible = wasapiListsVisible; + if (receiveOutputDevicesLabel is not null) receiveOutputDevicesLabel.Visible = wasapiListsVisible; + sendOutputDevicesList.Visible = wasapiListsVisible; + sendOutputDevicesStatusLabel.Visible = wasapiListsVisible; + if (sendOutputDevicesLabel is not null) sendOutputDevicesLabel.Visible = wasapiListsVisible; + sendInputDevicesList.Visible = wasapiListsVisible; + sendInputDevicesStatusLabel.Visible = wasapiListsVisible; + if (sendInputDevicesLabel is not null) sendInputDevicesLabel.Visible = wasapiListsVisible; + asioReceiveOutputDevicesList.Visible = asioListsVisible; + asioReceiveOutputDevicesStatusLabel.Visible = asioListsVisible; + if (asioReceiveOutputDevicesLabel is not null) asioReceiveOutputDevicesLabel.Visible = asioListsVisible; + asioSendDevicesList.Visible = asioListsVisible; + asioSendDevicesStatusLabel.Visible = asioListsVisible; + if (asioSendDevicesLabel is not null) asioSendDevicesLabel.Visible = asioListsVisible; + + // Force list refresh — ASIO list content depends on which driver is loaded. + asioSendDevicesSignature = string.Empty; + asioReceiveOutputDevicesSignature = string.Empty; + RefreshAudioDeviceLists(); + + // Clear ticks in hidden lists so they don't contribute ghost specs. Track whether we + // actually wiped anything for the log line; the re-apply below runs unconditionally + // because the new backend instance has no source/output state regardless. + var wipedSomething = false; + try + { + suppressDeviceCheckChange = true; + if (!wasapiListsVisible) + { + for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++) + if (receiveOutputDevicesList.GetItemChecked(i)) { receiveOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } + for (var i = 0; i < sendOutputDevicesList.Items.Count; i++) + if (sendOutputDevicesList.GetItemChecked(i)) { sendOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } + for (var i = 0; i < sendInputDevicesList.Items.Count; i++) + if (sendInputDevicesList.GetItemChecked(i)) { sendInputDevicesList.SetItemChecked(i, false); wipedSomething = true; } + } + if (!asioListsVisible) + { + for (var i = 0; i < asioSendDevicesList.Items.Count; i++) + if (asioSendDevicesList.GetItemChecked(i)) { asioSendDevicesList.SetItemChecked(i, false); wipedSomething = true; } + for (var i = 0; i < asioReceiveOutputDevicesList.Items.Count; i++) + if (asioReceiveOutputDevicesList.GetItemChecked(i)) { asioReceiveOutputDevicesList.SetItemChecked(i, false); wipedSomething = true; } + } + } + finally { suppressDeviceCheckChange = false; } + // Always re-apply send sources and receive outputs after a mode change. The new + // composite instance was built fresh — even if no ticks got wiped (e.g. WasapiOnly → + // Both, where existing WASAPI ticks survive), the new backend has empty internal state + // and needs the current spec/device list pushed to it. Without this, a user mid-session + // who picks a different audio mode would silently lose their receive output and have + // to re-tick to get audio back. + ApplyAudioRuntime(); + ApplyReceiveDevices(); + if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks"); + } + + // ===================== Peers ===================== + + private void RefreshKnownPeers() + { + knownPeers.Clear(); + // Discovered peers go in first so manual peers added by IP don't shadow them. + foreach (var peer in discovery.Peers) knownPeers[peer.InstanceId] = peer; + foreach (var peer in manualPeers.Values) knownPeers[peer.InstanceId] = peer; + + // Dedupe by endpoint (address:port). When a manual peer (typed by IP) and a discovered + // peer (broadcasting hostname) point to the same machine, drop the manual entry and + // forward any active selection to the discovered peer so the user doesn't lose it. + // Prefer entries whose Name is NOT just the IP — those are real hostnames. + var byEndpoint = new Dictionary(StringComparer.OrdinalIgnoreCase); + var redirectedSelections = new List<(Guid From, Guid To)>(); + foreach (var peer in knownPeers.Values.ToList()) + { + var key = $"{peer.Address}:{peer.AudioPort}"; + if (!byEndpoint.TryGetValue(key, out var existing)) + { + byEndpoint[key] = peer; + continue; + } + // Prefer the one with a real hostname (Name != IP-as-string). + var existingIsIp = existing.Name == existing.Address.ToString(); + var peerIsIp = peer.Name == peer.Address.ToString(); + var winner = existingIsIp && !peerIsIp ? peer : existing; + var loser = winner == existing ? peer : existing; + byEndpoint[key] = winner; + // Move loser's selection (if any) to winner so the checkbox state survives. + if (selectedPeerEndpoints.ContainsKey(loser.InstanceId)) + { + redirectedSelections.Add((loser.InstanceId, winner.InstanceId)); + } + } + + foreach (var (from, to) in redirectedSelections) + { + if (selectedPeerEndpoints.Remove(from, out var endpoint)) + { + selectedPeerEndpoints[to] = endpoint; + if (selectedPeerLabels.Remove(from, out var label)) + { + selectedPeerLabels[to] = label; + } + // The "manual peer" that lost out should be removed from manualPeers too, + // otherwise the next discovery refresh re-creates the duplicate. + manualPeers.Remove(from); + } + } + + knownPeers.Clear(); + foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer; + + // If a selected peer's announced address changed (DHCP renewal, network switch), + // update the cached endpoint so the sender follows the new IP. + foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList()) + { + if (!knownPeers.TryGetValue(id, out var peer)) continue; + var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort); + if (!newEndpoint.Equals(oldEndpoint)) + { + selectedPeerEndpoints[id] = newEndpoint; + logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint}"); + } + selectedPeerLabels[id] = peer.Name; + } + + // Endpoints may have moved (DHCP/announcement-update path above) or selections may have + // been redirected (manual-peer-merged-into-discovered above). Push the latest set down + // to the receiver's allow-list so we don't keep accepting from a stale endpoint we no + // longer recognise as a selected peer. + PushAllowedReceiveSenders(); + } + + private void SelectPeer(PeerAnnouncement peer) => SelectPeer(peer, fromProfileRestore: false); + + private void SelectPeer(PeerAnnouncement peer, bool fromProfileRestore) + { + selectedPeerEndpoints[peer.InstanceId] = new IPEndPoint(peer.Address, peer.AudioPort); + selectedPeerLabels[peer.InstanceId] = peer.Name; + logFile.Event($"peer selected: {peer.Name} {peer.Address}:{peer.AudioPort}"); + InvalidateAutoTuneHistory(); + PushAllowedReceiveSenders(); + // fromProfileRestore=true means the call originated from auto-reconnect at startup; + // we don't want that to flag the profile as dirty. User-initiated selects do. + if (!fromProfileRestore) MarkProfileDirty(); + } + + private void DeselectPeer(Guid instanceId) + { + if (selectedPeerEndpoints.Remove(instanceId)) + { + selectedPeerLabels.TryGetValue(instanceId, out var label); + selectedPeerLabels.Remove(instanceId); + logFile.Event($"peer deselected: {label ?? instanceId.ToString()}"); + InvalidateAutoTuneHistory(); + PushAllowedReceiveSenders(); + MarkProfileDirty(); + } + } + + /// + /// Tells the receiver which sender endpoints are allowed to play audio. Same set as the + /// peers we're sending to (the checkbox controls both directions). Called whenever the + /// user selects/deselects a peer, and once at startup so the receiver is in a known state. + /// Without this, anyone who can reach our UDP port (e.g. a peer who chose us first) would + /// auto-play to our speakers — we want explicit consent via the checkbox. + /// + private void PushAllowedReceiveSenders() + { + receiver.SetAllowedSenders(SelectedSendEndpoints()); + } + + /// + /// Wipes the rolling max-gap window and pushes forward, + /// so the next continuous auto-tune tick has nothing to react to. Called whenever a user + /// action (peer (de)selection, source list toggle, manually moving the latency slider) is + /// likely to produce a measured "gap" that doesn't reflect the network — e.g. the user + /// reselecting localhost after a 5 s pause records a 5 s inter-arrival gap, which would + /// otherwise pin the auto-tune to its 200 ms cap for half a minute. + /// + private void InvalidateAutoTuneHistory() + { + recentMaxGaps.Clear(); + recentRenderCbGaps.Clear(); + lastSourceChangeUtc = DateTime.UtcNow; + } + + private IPEndPoint[] SelectedSendEndpoints() + { + // Collapse duplicates by ip:port so the same address isn't targeted twice. + return selectedPeerEndpoints.Values + .GroupBy(ep => $"{ep.Address}:{ep.Port}") + .Select(g => g.First()) + .ToArray(); + } + + private async Task ResolvePeerAddressAsync(string text) + { + // Strip any host:port suffix before resolving; the port is parsed separately by the + // caller via TrySplitHostPort. + var (hostOnly, _) = TrySplitHostPort(text); + if (IPAddress.TryParse(hostOnly, out var direct)) return direct; + try + { + var addresses = await Dns.GetHostAddressesAsync(hostOnly); + return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? addresses.FirstOrDefault(); + } + catch + { + return null; + } + } + + /// + /// Parse "host:port" or just "host" / "ipv4:port" / IPv4. Returns (host, port?) where port + /// is null when the user didn't include one. IPv6 literals are not supported in the manual + /// peer field today; if/when they are, they'll need bracket syntax. Bare numeric strings are + /// treated as hosts (no port). + /// + internal static (string host, int? port) TrySplitHostPort(string text) + { + if (string.IsNullOrWhiteSpace(text)) return (text ?? string.Empty, null); + text = text.Trim(); + var colon = text.LastIndexOf(':'); + if (colon <= 0 || colon == text.Length - 1) return (text, null); + var maybeHost = text[..colon]; + var maybePort = text[(colon + 1)..]; + // If there's another colon earlier, it's likely an IPv6 literal — leave the whole thing + // as the host. (Manual peer entry doesn't formally support IPv6 today, but don't + // misinterpret one as host:port and resolve garbage.) + if (maybeHost.Contains(':')) return (text, null); + if (!int.TryParse(maybePort, out var port)) return (text, null); + if (port < 1 || port > 65535) return (text, null); + return (maybeHost, port); + } + + private PeerAnnouncement CreateManualPeer(string entry, IPAddress address) + { + var (_, parsedPort) = TrySplitHostPort(entry); + var label = string.IsNullOrWhiteSpace(entry) ? address.ToString() : entry.Trim(); + return new PeerAnnouncement( + Guid.NewGuid(), + label, + parsedPort ?? RemPacket.DefaultPeerDialPort, + CanSend: true, + CanReceive: true, + DateTime.UtcNow, + address); + } + + private async Task AddManualPeerAsync(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + MessageBox.Show(this, "Enter an IP address or hostname for the other computer.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var address = await ResolvePeerAddressAsync(text); + if (address is null) + { + MessageBox.Show(this, "Could not resolve that IP address or hostname.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var rememberedEntries = settings.LoadRememberedPeers() + .Select(static value => value.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + rememberedEntries.Add(text.Trim()); + settings.SaveRememberedPeers(rememberedEntries); + + var peer = CreateManualPeer(text, address); + manualPeers[peer.InstanceId] = peer; + rememberedPeerInstanceIds[text.Trim()] = peer.InstanceId; + SelectPeer(peer); + // New peer in remembered/manual list → tell discovery to start unicasting announcements + // at this address so they discover us back across VPN/WAN. + PushDiscoveryUnicastHints(); + logFile.Event($"manual peer added {address}:{peer.AudioPort} ({text.Trim()})"); + + RefreshKnownPeers(); + ApplyAudioRuntime(); + } + + private void LoadRememberedPeersFromSettings() + { + // No checkboxes on the form for these — they live in the dialog. We just remember them. + rememberedPeerInstanceIds.Clear(); + } + + /// + /// Adds a peer's identity to the persisted Remembered list (if not already present), and + /// records the entry → instance-id mapping so the Remembered dialog can display it. Used + /// when connecting via the Discovered list — per Ed's spec, "Remembered" is the long + /// history of every peer ever connected to, not just manually-added ones. + /// + private void EnsurePeerRemembered(PeerAnnouncement peer) + { + var entry = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString() + ? peer.Address.ToString() + : peer.Name; + var existing = settings.LoadRememberedPeers().ToList(); + if (existing.Any(e => string.Equals(e, entry, StringComparison.OrdinalIgnoreCase))) + { + // Already remembered — make sure the id mapping is current so + // SyncDialogRememberedPeerList correctly hides this entry while the peer is connected. + rememberedPeerInstanceIds[entry] = peer.InstanceId; + PushDiscoveryUnicastHints(); + return; + } + existing.Add(entry); + settings.SaveRememberedPeers(existing); + rememberedPeerInstanceIds[entry] = peer.InstanceId; + PushDiscoveryUnicastHints(); + } + + /// + /// Tells the discovery service which IPs to send unicast announcements to. LAN broadcast + /// alone doesn't reach peers across a VPN (Tailscale, WireGuard, etc.) — so we explicitly + /// announce to every remembered + manual peer IP on top of broadcast. Anyone in our + /// remembered list who's running RemSound and reachable will then appear in Discovered, + /// regardless of physical network. Sending to an offline peer is a no-op. + /// + private void PushDiscoveryUnicastHints() + { + var hints = new HashSet(); + + // Manual peers store IPEndPoint already. + foreach (var peer in manualPeers.Values) + { + hints.Add(peer.Address); + } + // Remembered peers are stored as string entries (IP or hostname). Try to parse as IP; + // for hostnames try a quick non-blocking DNS lookup. We do this synchronously here + // because the remembered list is small (typically 1–10 entries) and Dns.GetHostAddresses + // returns near-instantly for either a parsed IP or a cached hostname. + foreach (var entry in settings.LoadRememberedPeers()) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + if (IPAddress.TryParse(entry, out var direct)) + { + hints.Add(direct); + continue; + } + try + { + foreach (var addr in Dns.GetHostAddresses(entry)) + { + if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + hints.Add(addr); + } + } + } + catch + { + // Hostname not resolvable right now — skip silently. Will retry next time + // PushDiscoveryUnicastHints is called. + } + } + + discovery.SetUnicastPeerAddresses(hints); + } + + + /// + /// After deleting an item from a CheckedListBox, focus the next sensible item so NVDA + /// announces the new selection. If something exists at the same index that the deleted + /// item occupied, focus that (it's the next-down). Otherwise drop back to the last item. + /// Empty list = no focus change. + /// + private static void FocusListItemAfterDelete(CheckedListBox list, int prevIndex) + { + if (list.IsDisposed) return; + var count = list.Items.Count; + if (count == 0) return; + var target = Math.Clamp(prevIndex, 0, count - 1); + list.SelectedIndex = target; + if (!list.Focused) list.Focus(); + } + + private void RemoveSelectedManualPeer(CheckedListBox list) + { + if (list.SelectedItem is not PeerListItem selected) return; + manualPeers.Remove(selected.Peer.InstanceId); + DeselectPeer(selected.Peer.InstanceId); + foreach (var pair in rememberedPeerInstanceIds.Where(kv => kv.Value == selected.Peer.InstanceId).ToList()) + { + rememberedPeerInstanceIds.Remove(pair.Key); + } + RefreshKnownPeers(); + ApplyAudioRuntime(); + } + + private void RemoveSelectedRememberedPeer(CheckedListBox list) + { + if (list.SelectedItem is not RememberedPeerItem selected) return; + if (rememberedPeerInstanceIds.TryGetValue(selected.Entry, out var pid)) + { + manualPeers.Remove(pid); + DeselectPeer(pid); + rememberedPeerInstanceIds.Remove(selected.Entry); + } + var remaining = settings.LoadRememberedPeers().Where(e => !string.Equals(e, selected.Entry, StringComparison.OrdinalIgnoreCase)); + settings.SaveRememberedPeers(remaining); + RefreshKnownPeers(); + ApplyAudioRuntime(); + PushDiscoveryUnicastHints(); + } + + // ===================== Mode-change warnings ===================== + + // ShowBothModeWarning + its TaskDialog retired 2026-05-11. The popup warned about the + // ~45 ms latency penalty of classic mixed-Both mode. Classic Both is no longer reachable + // from the UI (only WasapiOnly and BothIndependent are produced now, both fast-path), so + // the warning has nothing to fire on. AppConfig.BothModeWarningSuppressed is kept on disk + // for backward-compat — old config files still deserialise, new code just ignores it. + + /// + /// Confirmation popup after Save (Ctrl+S / File → Save) overwrites the current profile. + /// Native TaskDialog, NVDA reads + /// the heading + body automatically, verification checkbox is part of the tab order so + /// "Do not show me this again" is reachable without a mouse. Once ticked the preference + /// lives in remsound.config.json as + /// ; it's only consulted from + /// the in-place Save path — Save As never reaches here (its own dialog is the + /// confirmation). + /// + private void ShowSaveConfirmationDialog(string title) + { + var verification = new TaskDialogVerificationCheckBox("Do not show me this message again"); + var page = new TaskDialogPage + { + Caption = AppName, + Heading = "Profile saved", + Text = $"\"{title}\" has been saved.", + Icon = TaskDialogIcon.Information, + Verification = verification, + Buttons = { TaskDialogButton.OK }, + DefaultButton = TaskDialogButton.OK, + AllowCancel = true, + }; + + TaskDialog.ShowDialog(this, page); + + if (verification.Checked) + { + var cfg = AppConfig.Load(); + cfg.SaveProfileConfirmationSuppressed = true; + cfg.Save(); + logFile.Event("save-profile confirmation suppressed by user (saved to remsound.config.json)"); + } + } + + // ===================== Status / log ===================== + + private void UpdateStatus() + { + var since = connected ? (DateTime.UtcNow - connectedSinceUtc).ToString(@"h\:mm\:ss") : "0:00:00"; + var sendText = sender.IsRunning + ? $"sending {sender.PacketsSent} packets ({sender.BytesSent / 1024} KB) codec={sender.Codec} from \"{sender.CaptureDeviceName}\"" + : (IsSendEnabled && !HasCheckedSendDevice() ? "not sending — tick a capture device" : "not sending"); + var receiveText = receiver.IsRunning + ? $"receiving {receiver.PacketsReceived} packets, buffer {receiver.CurrentBufferMs} ms (target {receiver.TargetLatencyMs} ms), underruns {receiver.Underruns}, drops {receiver.Drops} on \"{receiver.OutputDeviceName}\"" + : "not receiving"; + var peerCount = knownPeers.Count; + var hbSummary = heartbeatService?.GetHealthSummary() ?? "no peers"; + statusLabel.Text = $"Connected for {since}. {peerCount} peer(s) known. {sendText}. {receiveText}. Heartbeat: {hbSummary}."; + healthLabel.Text = connected + ? sender.IsRunning || receiver.IsRunning ? "Health: streaming" : "Health: idle" + : "Health: disconnected"; + } + + private void SnapshotLogIfDue() + { + if (DateTime.UtcNow - lastSnapshotUtc < TimeSpan.FromMilliseconds(950)) return; + lastSnapshotUtc = DateTime.UtcNow; + // Prune any sessions on the receiver that haven't received packets in a while. This is + // serialised on the network-thread lock inside the receiver, so doing it from the UI + // tick is safe. + receiver.PruneIdleSessions(); + // Detect peer health transitions and play connect/disconnect cues. + DetectAndAnnouncePeerHealthTransitions(); + // If neither logs nor auto-tune is active we have nothing to do — neither audience + // wants the diag work. When logs are off but auto-tune is on (the gate is on for + // auto-tune), we fall through and run the snapshot+drain so the auto-tune feed + // (recentMaxGaps / recentRenderCbGaps at the bottom of this method) gets fresh + // data. The logFile.Snapshot and logFile.Event calls below are themselves cheap + // no-ops when logFile.Enabled is false, so we don't need to wrap individual writes. + if (!DiagnosticsGate.Enabled) return; + // SNAP latency columns: in classic modes the legacy MaxLatencyMs / TargetLatencyMs + // pair holds the only route's value (Mixed). In BothIndependent we map them to the + // WASAPI lane (= the lane the existing slider drives) and emit the ASIO lane in the + // appended ASIO columns. That keeps the existing columns meaningful — they still + // represent "what the main slider shows" — and the appended columns expose the + // second lane to anyone reading the log file. + var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; + var primaryMaxMs = inBothIndependent ? receiver.MaxLatencyMsFor(RenderRoute.WasapiLane) : receiver.MaxLatencyMs; + var primaryTargetMs = inBothIndependent ? receiver.TargetLatencyMsFor(RenderRoute.WasapiLane) : receiver.TargetLatencyMs; + var asioMaxMs = inBothIndependent ? receiver.MaxLatencyMsFor(RenderRoute.AsioLane) : 0; + var asioTargetMs = inBothIndependent ? receiver.TargetLatencyMsFor(RenderRoute.AsioLane) : 0; + logFile.Snapshot( + connected: connected, + sendRunning: sender.IsRunning, + receiveRunning: receiver.IsRunning, + codec: sender.Codec.ToString(), + maxLatencyMs: primaryMaxMs, + targetLatencyMs: primaryTargetMs, + bufferMs: receiver.CurrentBufferMs, + senderPackets: sender.PacketsSent, + senderBytes: sender.BytesSent, + senderDevice: sender.CaptureDeviceName, + receiverPackets: receiver.PacketsReceived, + receiverBytes: receiver.BytesReceived, + underruns: receiver.Underruns, + drops: receiver.Drops, + receiveDevice: receiver.OutputDeviceName, + heartbeat: heartbeatService?.GetHealthSummary() ?? "no peers", + opusFecRecoveries: receiver.OpusFecRecoveries, + opusUnrecoveredGaps: receiver.OpusUnrecoveredGaps, + maxLatencyMsAsio: asioMaxMs, + targetLatencyMsAsio: asioTargetMs); + + // First-of-kind events make it easy to see in the log where the chain breaks. + if (sender.IsRunning) + { + if (!firstCaptureCallbackLogged && sender.CaptureCallbacks > 0) + { + firstCaptureCallbackLogged = true; + logFile.Event($"first capture callback received ({sender.CaptureBytes} bytes, format {sender.CaptureFormatDescription ?? "?"})"); + } + if (!firstSenderPacketLogged && sender.PacketsSent > 0) + { + firstSenderPacketLogged = true; + logFile.Event($"first packet sent ({sender.BytesSent} bytes total)"); + } + // If capture isn't producing samples, repeat the warning every 5 s so it's visible. + if (sender.CaptureCallbacks == 0 && DateTime.UtcNow - lastCaptureZeroLogUtc > TimeSpan.FromSeconds(5)) + { + lastCaptureZeroLogUtc = DateTime.UtcNow; + var err = sender.LastCaptureError; + logFile.Event($"sender running but no capture callbacks yet (device=\"{sender.CaptureDeviceName}\", format=\"{sender.CaptureFormatDescription ?? "?"}\", error=\"{err ?? "none"}\")"); + } + } + else + { + firstCaptureCallbackLogged = false; + firstSenderPacketLogged = false; + } + + // Diag block runs if EITHER side is active. The original gate was `receiver.IsRunning` + // only, which was correct for the typical bidirectional case but silently dropped the + // diag line on send-only machines (no receiver bound, but the sender's capture-callback + // gap is exactly what we want to log there). Adding `|| sender.IsRunning` lets the + // send-only branch below actually emit. + if (receiver.IsRunning || sender.IsRunning) + { + if (receiver.IsRunning && !firstReceiverPacketLogged && receiver.PacketsReceived > 0) + { + firstReceiverPacketLogged = true; + logFile.Event($"first packet received ({receiver.BytesReceived} bytes total)"); + } + + // Sub-second diagnostics — tells us what's actually happening at audio-rate + // resolution rather than guessing from a 1 Hz buffer reading. Look for: + // bufMin near 0 or maxGapMs > 30 → network burstiness or thread starvation + // bufAvg << target → clock drift, adaptive rate should compensate + // inputRate drifting from 48000 → adaptive rate is actively compensating + // maxReadMs much bigger than 15 → WASAPI is gulping more than expected + var diag = receiver.IsRunning ? receiver.TakeDiagnosticsSnapshot() : default; + // Pull sendCbGapMs unconditionally so it always resets cleanly between log emissions. + // We log it on whichever line we end up emitting — the receiver's diag line if the + // receiver has activity, otherwise a sender-only line. Skipping the call when the + // receiver is idle would leave the sender's max growing forever, never resetting. + var sendCbGapMs = sender.TakeMaxCaptureCallbackGapMs(); + if (diag.BufferSampleCount > 0 || diag.RenderReadCount > 0) + { + // pcmRej / pcmDiscard let us see if PCM frames are being lost in assembly + // (out-of-order parts, mismatched parts, partial frame discarded). Both are + // cumulative since the stream session started — non-zero growing values during + // a steady-state run indicate the network/USB stack is jumbling PCM packet pairs. + // sendCbGapMs = sender's worst capture-callback gap since the last log. + // High value here (e.g. > 10 ms with ASIO buffer ≤ 5 ms) means the LOCAL + // capture path stalled — GC pause, USB driver hiccup, scheduler delay. The + // emitted audio will contain a discontinuity at that moment, which the peer + // can't detect (no packets lost, just audio with a hole). When this metric + // and the receiver's own maxGapMs both spike together, suspect the network; + // when only sendCbGapMs spikes, suspect this machine's audio stack. + // renderCbGapMs = worst gap between consecutive audio-render callbacks on THIS + // machine. Healthy = sub-ms variance from the audio buffer's natural period + // (e.g. ~5 ms for a 256-sample ASIO buffer at 48 kHz). Spikes here mean Windows + // scheduled the audio-output thread late, which causes the audio device's + // hardware buffer to underrun even though RemSound's playout buffer was full — + // RemSound's "Underruns" counter would NOT see this, so it can be the smoking + // gun for clicks-with-everything-else-clean. + // + // Drop-cause split (Codex's catch — the legacy `Drops` rolled up several + // unrelated mechanisms): + // trimB = bytes deliberately dropped by the smoothness-knob click-trim + // trimN = number of times that trim fired (so we can see frequency) + // drainB = bytes dropped on a one-shot drain (knob change) + // ovfB = ringbuffer-overflow / catastrophic-cap drops (everything else) + // pktRej = malformed/unknown-type packets we rejected at the network edge + var trimBytes = receiver.TrimDropBytes; + var trimFires = receiver.TrimFireCount; + var drainBytes = receiver.DrainDropBytes; + var ovfBytes = receiver.RingbufferOverflowDropBytes; + var pktRej = receiver.PacketsRejectedMalformed; + // Diag legend (post-Phase-3 cleanup): + // driftDrop / driftRep = Phase-2 drift correction counters. Each event = one + // stereo frame dropped (sender clock faster) or repeated (sender clock + // slower) = 21 µs of audio at 48 kHz with crossfade smoothing, designed to + // be inaudible. Healthy: one or the other slowly climbing at a few/sec rate. + // trimB / trimN / drainB / ovfB = the click-trim safety net + drain on knob + // change + ringbuffer overflow. All should stay near zero in normal + // operation now that the drift corrector handles steady drift. + // spikesN = adaptive second-derivative outlier count. Music-content invariant. + // >0 = real anomalous samples in RemSound's output. ~0 = clean output. + // sampleStepMax = raw peak step magnitude (false-positive prone on bright + // music; informational only). + var driftDrops = receiver.DriftDropFrames; + var driftReps = receiver.DriftRepeatFrames; + // 2026-05-11 added timing-split metrics: + // emitMs = sender's worst time-in-OnMixedSamples (encode + scratch + send) + // sndCallMs = sender's worst time-in-udp.Client.SendTo (kernel send only) + // rxDispMs = receiver's worst time-in-onPacket dispatch (after kernel receive) + // If observed maxGapMs is large but all three of these are sub-ms, the variance + // is between sender SendTo-return and receiver ReceiveFrom-return — i.e. the + // network or the kernel TX/RX path. If one of them spikes alongside maxGapMs, + // that's where our code is taking the time. + var emitMs = sender.TakeMaxEmitMs(); + var sendCallMs = sender.TakeMaxSendCallMs(); + var rxDispatchMs = receiver.TakeMaxOnPacketMs(); + // fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single + // active render lane should sit at ~0; non-zero says the FanOut is sitting on + // samples that aren't reaching the audio output, i.e. extra perceived latency + // not visible in bufAvg. Always 0 in WasapiOnly (no FanOut). + var fanCacheMs = receiver.TakeMaxFanOutCacheMs(); + logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " + + $"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " + + $"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " + + $"trimB={trimBytes} trimN={trimFires} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + + $"driftDrop={driftDrops} driftRep={driftReps} " + + $"sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " + + $"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}"); + } + else if (sender.IsRunning) + { + // Send-only machine (no receive output ticked). Emit a sender-side diag line so + // sendCbGapMs is visible — that's the most important metric on a send-only box, + // since it tells us whether THIS machine's capture path is stalling. Without + // this branch, send-only sessions logged zero diag info. + var emitMs = sender.TakeMaxEmitMs(); + var sendCallMs = sender.TakeMaxSendCallMs(); + logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}"); + } + + // Synthesised end-to-end one-way latency estimate. Sums: + // * sender_accumulator: half the codec frame size (avg packet wait) + // * wire_one_way: lowest active peer's heartbeat RTT / 2 + // * receiver_queue: bufAvg from diag (the real measured queue depth, 0 on send-only) + // * render_buffer: rough estimate per audio mode + // Logged whenever either side is active so we capture the latency picture even when + // the local machine is send-only. + if ((diag.BufferSampleCount > 0 || diag.RenderReadCount > 0) || sender.IsRunning) + { + var senderAccumulatorMs = SenderAccumulatorEstimateMs(); + var wireOneWayMs = LowestPeerRttMs() / 2.0; + var renderBufferMs = RenderBufferEstimateMs(); + var totalMs = senderAccumulatorMs + wireOneWayMs + diag.BufferAvgMs + renderBufferMs; + logFile.Event($"latency-probe estimated one-way ≈ {totalMs:0.0}ms " + + $"(send-accum={senderAccumulatorMs:0.0}, wire={wireOneWayMs:0.0}, recv-queue={diag.BufferAvgMs}, render={renderBufferMs:0.0})"); + } + + // If a new stream session opened since the last SNAP tick, flush the gap windows. + // The diag.MaxArrivalGapMs we're about to enqueue is bounded inside this tick by + // ReceiverDiagnostics.ResetGapMeasurements() (called from AudioReceiver when the + // session opens), but any previously-queued entries are stale relative to the new + // session. Bumping lastSourceChangeUtc also makes the auto-tune defer for one + // interval, letting the new session's measurements populate the window before any + // recommendation fires. + var openCount = receiver.SessionsOpenedCount; + if (openCount > lastObservedSessionsOpenedCount) + { + lastObservedSessionsOpenedCount = openCount; + recentMaxGaps.Clear(); + recentRenderCbGaps.Clear(); + lastSourceChangeUtc = DateTime.UtcNow; + } + + // Push this second's max-gap reading into the rolling window the continuous + // auto-tune samples from. Capped at RecentMaxGapWindowSeconds entries so older + // readings naturally fall out as conditions evolve. + if (diag.PacketCount > 0) + { + recentMaxGaps.Enqueue(diag.MaxArrivalGapMs); + while (recentMaxGaps.Count > RecentMaxGapWindowSeconds) recentMaxGaps.Dequeue(); + // Mirror window for actual render-callback period. Same windowing so they age + // out together; auto-tune uses the max of this for an honest formula. + recentRenderCbGaps.Enqueue(diag.MaxRenderCallbackGapMs); + while (recentRenderCbGaps.Count > RecentMaxGapWindowSeconds) recentRenderCbGaps.Dequeue(); + } + } + else + { + firstReceiverPacketLogged = false; + } + } + + private void AppendLogEntry(string message) + { + // No on-form log box now (kept just-in-status-line). Leaving this method to make the call sites + // future-proof; if we re-add a visible log box, AppendLogEntry is the single hook point. + logFile.Event(message); + } + + // ===================== Tray ===================== + + private void ToggleTrayFromHotkey() + { + BeginInvoke(() => trayController.Toggle()); + } + + /// + /// Most Alt+letter shortcuts are wired via the WinForms `&` mnemonic on the relevant + /// control's Text (Buttons, CheckBoxes) or its paired Label (ListBoxes, NumericUpDowns, + /// ComboBoxes — see for the label-→target dispatch). The + /// framework's built-in ProcessMnemonic walk handles those automatically: when the user + /// presses Alt+letter, only controls on the visible tab respond, which gives us per-tab + /// shortcut isolation as a free side-effect of how WinForms scopes mnemonics. + /// + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // No ProcessCmdKey overrides currently — base class handles everything. The previous + // Alt+M tab-local gating became unnecessary once the Audio mode listbox was retired + // (2026-05-11); minimise to tray is reachable via Alt+F → M (File menu mnemonic) or + // the configurable "Show or hide window" global hotkey (default Ctrl+Shift+F10). + return base.ProcessCmdKey(ref msg, keyData); + } + + + // ===================== Profile system ===================== + + /// Called once from Shown after device lists are populated. Applies the + /// control-state portion of the loaded profile (device ticks, send/receive checkboxes, + /// volume) — settings-shaped fields were applied earlier in the constructor via + /// settings.ApplyProfile(). Devices in the profile that don't exist on this machine + /// are silently skipped (the matching CheckedListBox simply won't have them ticked). + private void ApplyPendingProfileToControls() + { + if (pendingProfile is null) return; + var p = pendingProfile; + applyingProfile = true; + try + { + // Volume first — affects what's audible during the rest of this method. + volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum); + + // Tick checkboxes. Order matters: setting Checked fires runtime apply paths + // (Connect/Disconnect) so the side-effect cascade has to happen here, not in + // the constructor where the engines aren't fully wired up yet. + ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs); + ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs); + ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs); + ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs); + ApplyTicksToList(asioSendDevicesList, p.SelectedAsioSendInputs); + + receiveAudioCheckbox.Checked = p.ReceiveAudioOn; + sendMyAudioCheckbox.Checked = p.SendAudioOn; + + // Re-establish previously-connected peers. Each entry is re-resolved + re-selected + // exactly as if the user had typed it into the manual-peer field. Discovered peers + // (no longer reachable / different IP) just fail gracefully — no popup. + ReconnectSavedPeers(p.SelectedConnectedPeers); + } + catch (Exception ex) + { + AppendLogEntry($"profile apply: error applying \"{p.Title}\": {ex.GetType().Name}: {ex.Message}"); + } + finally + { + // Don't re-apply on subsequent device-list refreshes. The user's later ticks are + // captured by save-profile from current control state; we don't keep pulling from + // the original profile forever. + pendingProfile = null; + applyingProfile = false; + } + // Schedule baseline capture for the unsaved-changes-on-close check. Done as a + // delayed snapshot so async peer-reconnects have settled. + ScheduleBaselineCapture(); + } + + /// Tick the items in whose DeviceId appears in + /// . Items not in the wanted set are unticked. Items in the + /// wanted set that don't exist on this machine are silently dropped (this is how the + /// profile system handles missing-hardware portability). + private static void ApplyTicksToList(CheckedListBox list, IReadOnlyList wantedIds) + { + if (list.Items.Count == 0) return; + var wanted = new HashSet(wantedIds, StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < list.Items.Count; i++) + { + if (list.Items[i] is not AudioDeviceChoice choice || choice.DeviceId is null) continue; + var shouldBeChecked = wanted.Contains(choice.DeviceId); + if (list.GetItemChecked(i) != shouldBeChecked) + { + list.SetItemChecked(i, shouldBeChecked); + } + } + } + + /// Window title shows the active profile name explicitly so the user knows what + /// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or + /// just "RemSound" (blank template). + private static string FormatWindowTitle(string? loadedTitle) => + string.IsNullOrEmpty(loadedTitle) + ? AppName + : $"{AppName} — Active profile: {loadedTitle}"; + + /// Show/hide the Update button based on whether a profile is currently loaded. + /// Update only makes sense when there's an existing profile to overwrite; Save-as is + /// always available (and the only way to save from a blank template). Both Visible and + /// Enabled are toggled — Visible to keep NVDA / sighted users from seeing it, Enabled + /// so the Alt+U hotkey is a no-op even if focus somehow lands on it. + private void UpdateProfileButtonsVisibility() + { + // Retained as a stub — multiple call sites still poke this on profile load / + // save-as / rename. With the Profiles tab retired (2026-05-08) there's no UI to + // refresh; the Save / Rename actions on the File menu work for both + // blank-template and loaded-profile states because the menu handlers branch on + // currentProfileTitle internally. The window title is updated where the profile + // title actually changes (SaveProfileTo, RenameCurrentProfile, profile-load). + } + + /// Update existing profile button. Overwrites the active profile with current + /// state. No prompt — user explicitly chose this button to commit. Hidden when no + /// profile is loaded. + private void UpdateExistingProfile() + { + if (profileStore is null || string.IsNullOrEmpty(currentProfileTitle)) + { + // Defensive — button should be hidden in this case. + return; + } + SaveProfileTo(currentProfileTitle); + } + + /// Save profile as button. Always prompts for a (new) name. From a blank + /// template this is the only way to create the first profile; from a loaded profile this + /// forks a copy under a new name and switches to that copy as the active profile. + private void SaveProfileAs() + { + if (profileStore is null) + { + MessageBox.Show(this, "Profile system not active in this run.", "RemSound", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + // Real Windows Save As dialog (2026-05-10) — picks an arbitrary path with the standard + // filename + folder picker, instead of the previous text-only "Profile name" prompt. + // Default folder is the active profiles folder. Saving inside that folder produces a + // profile that's loadable from File → Open profile next launch; saving outside is an + // export the user is responsible for managing (RemSound only auto-discovers profiles + // in AppConfig.ProfilesDirectory, so external saves don't appear in the picker). + using var dialog = new SaveFileDialog + { + Title = "Save profile as", + Filter = "RemSound profiles (*.json)|*.json", + DefaultExt = "json", + AddExtension = true, + OverwritePrompt = true, + InitialDirectory = profileStore.BaseDirectory, + FileName = string.IsNullOrEmpty(currentProfileTitle) ? "" : currentProfileTitle + ".json", + }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + + var path = dialog.FileName; + var title = Path.GetFileNameWithoutExtension(path); + if (string.IsNullOrWhiteSpace(title)) return; + + try + { + var profile = BuildCurrentProfile(title); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + + currentProfileTitle = title; + currentProfilePath = path; + Text = FormatWindowTitle(title); + AccessibleName = Text; + UpdateProfileButtonsVisibility(); + AppendLogEntry($"profile saved: \"{title}\" → {path}"); + // Refresh baseline so the diff against unsaved-changes uses the just-saved state. + try { baselineProfileJson = SerializeCurrentStateAsProfile(); } + catch { /* baseline failure shouldn't block save */ } + unsavedChanges = false; + // No confirmation popup here. The Save-As dialog the user just dismissed is itself + // the explicit, user-driven "I am saving to this path" — a follow-up "Saved." popup + // is pure friction (one more Enter press, one more NVDA read of the same fact). + // The window title updates to the new name, the file appears on disk, and the + // baseline diff resets — all the silent affordances the user actually needs. + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save profile: {ex.Message}", "RemSound", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + /// Build a Profile POCO from the current MainForm control state. Used by the + /// Save / Save as / SerializeCurrentStateAsProfile paths so the snapshotting logic lives + /// in one place. + private Profile BuildCurrentProfile(string title) + { + var profile = new Profile { Title = title }; + settings.CopyTo(profile); + profile.Volume = volumeBar.Value; + profile.Muted = receiver.IsMuted; + profile.ReceiveAudioOn = receiveAudioCheckbox.Checked; + profile.SendAudioOn = sendMyAudioCheckbox.Checked; + profile.SelectedWasapiReceiveOutputs = ExtractCheckedDeviceIds(receiveOutputDevicesList); + profile.SelectedAsioReceiveOutputs = ExtractCheckedDeviceIds(asioReceiveOutputDevicesList); + profile.SelectedWasapiSendOutputs = ExtractCheckedDeviceIds(sendOutputDevicesList); + profile.SelectedWasapiSendInputs = ExtractCheckedDeviceIds(sendInputDevicesList); + profile.SelectedAsioSendInputs = ExtractCheckedDeviceIds(asioSendDevicesList); + profile.SelectedConnectedPeers = GatherSelectedPeerEntries(); + return profile; + } + + /// Common save body — gathers all current state into a Profile and writes it. + /// On success, becomes the active profile (sets currentProfileTitle, updates window + /// title, refreshes button visibility, and shows a confirmation popup). + private void SaveProfileTo(string title) => SaveProfileTo(title, showConfirmation: true); + + private void SaveProfileTo(string title, bool showConfirmation) + { + if (profileStore is null) return; + try + { + SaveCurrentStateToProfileFile(title); + AppendLogEntry($"profile saved: \"{title}\""); + // Refresh the unsaved-changes baseline so this saved state becomes the new + // "no changes" reference. The Title field changes on save-as, so the next + // diff comparison must use the new state as baseline, not the pre-save one. + try { baselineProfileJson = SerializeCurrentStateAsProfile(); } + catch { /* baseline failure shouldn't block save */ } + unsavedChanges = false; + if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed) + { + // Explicit confirmation. Without this the only feedback is the silent + // baseline-diff reset; sighted users miss it, screen-reader users only catch + // it on the next focus event. TaskDialog (not MessageBox) so we can attach a + // "Do not show me this again" verification checkbox — NVDA reads the checkbox + // as part of the dialog tab order, and once ticked the preference persists in + // remsound.config.json. Suppressed entirely when invoked from the close- + // confirmation flow (the user already confirmed save+exit; extra Enter = friction). + ShowSaveConfirmationDialog(title); + } + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save profile: {ex.Message}", "RemSound", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + /// Builds a Profile from the current control state and writes it via the store. + /// Doesn't touch UI feedback — that's the caller's job. Throws on store failure. + private void SaveCurrentStateToProfileFile(string title) + { + if (profileStore is null) return; + var profile = BuildCurrentProfile(title); + // If the active profile has a tracked path (set by Save As or by startup load), + // write to that exact location — even if it's outside BaseDirectory. Otherwise + // (no path tracked, e.g. blank-template-direct-save edge case) fall through to the + // store's BaseDirectory-relative save. + if (!string.IsNullOrEmpty(currentProfilePath)) + { + var dir = Path.GetDirectoryName(currentProfilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(currentProfilePath, json); + } + else + { + profileStore.Save(profile); + currentProfilePath = profileStore.PathFor(title); + } + currentProfileTitle = title; + Text = FormatWindowTitle(title); + AccessibleName = Text; + UpdateProfileButtonsVisibility(); + } + + /// Mark the profile as having unsaved user changes. No-op while a profile is + /// being applied programmatically (otherwise loading a profile would immediately mark + /// itself dirty). Hooked from peer (de)selection plus a few other key paths; the close + /// path also does a JSON-state diff as a safety net to catch settings we forgot to hook. + private void MarkProfileDirty() + { + if (applyingProfile) return; + unsavedChanges = true; + } + + /// Serializes the current control state as if the user had just clicked Save. + /// Used for the unsaved-changes-on-close diff. Mirrors + /// but doesn't write anywhere. + private string SerializeCurrentStateAsProfile() => + JsonSerializer.Serialize(BuildCurrentProfile(currentProfileTitle ?? "")); + + /// Capture the "this is what no-changes-since-load looks like" baseline 3 seconds + /// after the profile has been applied (or the app has started, for blank template). The + /// delay lets async peer-reconnects finish so they're folded into the baseline rather + /// than seen as user-initiated changes. If the user closes within those 3 seconds the + /// baseline is null and we just close without prompting (treating fast-close as + /// confident-close). + private void ScheduleBaselineCapture() + { + var timer = new System.Windows.Forms.Timer { Interval = 3000 }; + timer.Tick += (_, _) => + { + timer.Stop(); + timer.Dispose(); + try { baselineProfileJson = SerializeCurrentStateAsProfile(); } + catch { /* ignore — baseline just stays null */ } + }; + timer.Start(); + } + + private static List ExtractCheckedDeviceIds(CheckedListBox list) + { + var result = new List(); + for (var i = 0; i < list.Items.Count; i++) + { + if (!list.GetItemChecked(i)) continue; + if (list.Items[i] is AudioDeviceChoice choice && !string.IsNullOrEmpty(choice.DeviceId)) + { + result.Add(choice.DeviceId); + } + } + return result; + } + + /// Collect the currently-connected peers as their original entry text (the + /// user's typed string, e.g. "remote.ednun.com:47830" or "192.168.1.2"). Stored in the + /// profile so a profile reload re-resolves the hostname (in case the IP has changed) + /// and reconnects via the same code path the user uses for manual peer adds. Falls back + /// to "address:port" when we don't have the original text — happens for peers that + /// arrived via discovery rather than a manual add. + private List GatherSelectedPeerEntries() + { + var result = new List(); + foreach (var (instanceId, endpoint) in selectedPeerEndpoints) + { + // Preferred: original text the user typed (preserves hostnames vs IPs). + string? entry = null; + foreach (var (text, id) in rememberedPeerInstanceIds) + { + if (id == instanceId) { entry = text; break; } + } + if (string.IsNullOrEmpty(entry)) + { + // Fall back to the discovery label, then to address:port literal. + if (selectedPeerLabels.TryGetValue(instanceId, out var label) && !string.IsNullOrWhiteSpace(label)) + { + entry = label; + } + else + { + entry = $"{endpoint.Address}:{endpoint.Port}"; + } + } + if (!result.Contains(entry, StringComparer.OrdinalIgnoreCase)) result.Add(entry); + } + return result; + } + + /// Re-establish the connections that were active when the profile was saved. + /// Mirrors but quieter — failures (DNS, empty entry) + /// log to the diagnostic file instead of popping a MessageBox, because we don't want + /// a startup-time profile load to fire several modal dialogs at the user. Selected + /// peers that resolve become connected exactly as if the user had typed them. + private void ReconnectSavedPeers(IReadOnlyList entries) + { + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + _ = ReconnectOneSavedPeerAsync(entry); + } + } + + private async Task ReconnectOneSavedPeerAsync(string entry) + { + try + { + var address = await ResolvePeerAddressAsync(entry); + if (address is null) + { + AppendLogEntry($"profile reconnect: could not resolve \"{entry}\"; skipping"); + return; + } + var rememberedEntries = settings.LoadRememberedPeers() + .Select(static value => value.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + rememberedEntries.Add(entry.Trim()); + settings.SaveRememberedPeers(rememberedEntries); + + var peer = CreateManualPeer(entry, address); + manualPeers[peer.InstanceId] = peer; + rememberedPeerInstanceIds[entry.Trim()] = peer.InstanceId; + SelectPeer(peer, fromProfileRestore: true); + PushDiscoveryUnicastHints(); + logFile.Event($"profile reconnect: \"{entry}\" → {address}:{peer.AudioPort}"); + RefreshKnownPeers(); + // CRITICAL: SelectPeer alone only updates the receiver's allow-list and the + // selectedPeerEndpoints dictionary; it does NOT engage the audio sender's outbound + // peer list. Without this ApplyAudioRuntime call, profile-restored peers showed + // up "ticked" in the UI but the sender never actually transmitted to them, leaving + // the user staring at "pending" heartbeat for ~40-60 s until the relay's stale-slot + // timeout expired (or until the user manually unticked + re-ticked, which DOES + // route through the runtime apply). Observed in logs from 2026-05-05. + ApplyAudioRuntime(); + } + catch (Exception ex) + { + AppendLogEntry($"profile reconnect: \"{entry}\" failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + // OpenManageProfilesDialog and ProfileManagementDialog removed in Phase 4 of the + // 2026-05-06 UI refactor. Profile management lives inline on the Profiles & preferences + // tab — see BuildProfilesPrefsTab + SwitchSelectedProfile / RenameSelectedProfile / + // DeleteSelectedProfile. + + private void TryLoadCueSound(string fileName, out System.Media.SoundPlayer? player) + { + player = null; + try + { + var path = Path.Combine(AppContext.BaseDirectory, fileName); + if (!File.Exists(path)) + { + logFile.Event($"cue sound missing: {fileName} (looked at {path})"); + return; + } + var sp = new System.Media.SoundPlayer(path); + sp.LoadAsync(); + player = sp; + } + catch (Exception ex) + { + logFile.Event($"cue sound load failed for {fileName}: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Compares current peer-health states to the last-seen states and plays a connect / + /// disconnect cue on the relevant transitions. Driven from the 1 Hz snapshot tick. Rules: + /// • Any state → Healthy: play connect cue (first connection, or a stale/unreachable peer + /// came back). + /// • Healthy or Stale → Unreachable: play disconnect cue. We deliberately do NOT fire a + /// disconnect cue for Unknown → Unreachable — that's "we typed an address but never got + /// a single heartbeat reply", which is a connect-failed event, not a connect-then-lost + /// event. Playing a disconnect ding for a peer that never connected is jarring and was + /// observed at jam-session start when the relay/peer hadn't paired yet. + /// • Tracked peer disappeared from the list (deselected): play disconnect if the peer was + /// Healthy at the last observation — quiet otherwise. + /// Stale is ignored (it's a transient between Healthy and Unreachable). + /// + private void DetectAndAnnouncePeerHealthTransitions() + { + if (heartbeatService is null) return; + var current = heartbeatService.GetAllPeerHealth(); + + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var ph in current) + { + var key = $"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"; + seenKeys.Add(key); + previousPeerHealthStates.TryGetValue(key, out var prior); + if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy) + { + if (!settings.LoadMuteConnectionCues()) connectSound?.Play(); + logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)"); + } + else if (ph.State == PeerHealthState.Unreachable + && (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale)) + { + if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play(); + logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)"); + } + previousPeerHealthStates[key] = ph.State; + } + + // Peers that vanished from tracking entirely (user deselected). Play disconnect if they + // were healthy when last seen. + foreach (var key in previousPeerHealthStates.Keys.Where(k => !seenKeys.Contains(k)).ToList()) + { + if (previousPeerHealthStates[key] == PeerHealthState.Healthy) + { + if (!settings.LoadMuteConnectionCues()) disconnectSound?.Play(); + logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)"); + } + previousPeerHealthStates.Remove(key); + } + } + + private void NudgeVolume(int deltaPercent) + { + BeginInvoke(() => + { + var newValue = Math.Clamp(volumeBar.Value + deltaPercent, volumeBar.Minimum, volumeBar.Maximum); + if (newValue == volumeBar.Value) return; + volumeBar.Value = newValue; + receiver.Volume = volumeBar.Value / 100f; + }); + } + + /// + /// Send a remote-control Control packet to every currently-tracked peer. Triggered by the + /// global hotkeys configured in the Keyboard shortcuts dialog. The local volume / mute + /// state on THIS machine is deliberately not touched — only peers that have ticked their + /// "Accept remote volume commands from peers" box honour the request. Use case: I'm + /// NVDA-Remote'd into another PC and want to nudge listening volume on the laptop I'm + /// physically at without breaking out of the session. + /// + /// VolumeUp / VolumeDown / MuteToggle. + /// Percent-point delta (signed). Ignored for MuteToggle. + private void SendRemoteControl(RemoteControlKind kind, sbyte delta) + { + if (!connected) return; + var endpoints = SelectedSendEndpoints(); + if (endpoints.Length == 0) return; + + Span packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.ControlPayloadSize]; + // streamId 0xFFFE for control packets (heartbeat already uses 0xFFFF). Distinct value + // makes diag logs easier to read; the receiver doesn't actually filter on it. + var seq = unchecked((uint)Interlocked.Increment(ref remoteControlSequence)); + RemPacket.WriteHeader(packet, RemPacketType.Control, 0xFFFE, seq); + RemPacket.WriteControlPayload(packet[RemPacket.HeaderSize..], kind, delta); + var bytes = packet.ToArray(); + + var sentTo = 0; + foreach (var ep in endpoints) + { + try + { + if (sender.SendVia(bytes, bytes.Length, ep)) sentTo++; + } + catch (Exception ex) + { + logFile.Event($"remote-control send to {ep} failed: {ex.GetType().Name}: {ex.Message}"); + } + } + logFile.Event($"remote-control sent kind={kind} delta={delta} seq={seq} peers={sentTo}/{endpoints.Length}"); + } + + private int remoteControlSequence; + + /// + /// Handler for incoming Control packets. Runs on the network thread — marshal to UI before + /// touching controls. Gates on (a) the user's + /// preference and (b) the audio allow-list (the sender must already be a ticked peer). + /// We deliberately don't gate on receive-audio-enabled: the volume / mute state is meaningful + /// even when playback is currently off, because the next time the user enables receive + /// they'll hear it at the right level. + /// + private void HandleRemoteControlPacket(RemoteControlKind kind, sbyte delta, IPEndPoint remote) + { + // Allow-list match by IP only — the sender's source port is their ephemeral outbound, + // not their announced audio port. + var allowed = false; + foreach (var ep in selectedPeerEndpoints.Values) + { + if (ep.Address.Equals(remote.Address)) { allowed = true; break; } + } + if (!allowed) + { + logFile.Event($"remote-control IGNORED (not in allow-list) kind={kind} delta={delta} from={remote}"); + return; + } + if (!settings.LoadAcceptRemoteVolumeCommands()) + { + logFile.Event($"remote-control IGNORED (Accept remote volume commands is off) kind={kind} delta={delta} from={remote}"); + return; + } + + BeginInvoke(() => + { + switch (kind) + { + case RemoteControlKind.VolumeUp: + case RemoteControlKind.VolumeDown: + var nudge = kind == RemoteControlKind.VolumeUp + ? Math.Abs((int)delta) // positive + : -Math.Abs((int)delta); // negative + var newValue = Math.Clamp(volumeBar.Value + nudge, volumeBar.Minimum, volumeBar.Maximum); + if (newValue != volumeBar.Value) + { + volumeBar.Value = newValue; + receiver.Volume = volumeBar.Value / 100f; + } + logFile.Event($"remote-control APPLIED kind={kind} delta={delta} new-volume={volumeBar.Value} from={remote}"); + break; + case RemoteControlKind.MuteToggle: + receiver.IsMuted = !receiver.IsMuted; + logFile.Event($"remote-control APPLIED kind=MuteToggle muted={receiver.IsMuted} from={remote}"); + break; + case RemoteControlKind.SystemVolumeUp: + { + var ok = SystemVolumeHelper.TryStepUp(); + var st = SystemVolumeHelper.TryReadState(); + logFile.Event($"remote-control APPLIED kind=SystemVolumeUp ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); + break; + } + case RemoteControlKind.SystemVolumeDown: + { + var ok = SystemVolumeHelper.TryStepDown(); + var st = SystemVolumeHelper.TryReadState(); + logFile.Event($"remote-control APPLIED kind=SystemVolumeDown ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); + break; + } + case RemoteControlKind.SystemMuteToggle: + { + var ok = SystemVolumeHelper.TryToggleMute(); + var st = SystemVolumeHelper.TryReadState(); + logFile.Event($"remote-control APPLIED kind=SystemMuteToggle ok={ok} state={(st is { } v ? $"{(int)(v.scalar * 100)}%{(v.mute ? " MUTED" : "")}" : "?")} from={remote}"); + break; + } + } + }); + } + + // === Latency probe helpers === + + /// Average send-side accumulator wait — half the active codec frame size. PCM + /// 5 ms → 2.5 ms typical; PCM 2.5 ms → 1.25 ms; Opus 20 ms → 10 ms; tight-latency PCM in + /// AsioOnly bypasses the accumulator entirely so this estimate is an upper bound there. + private double SenderAccumulatorEstimateMs() + { + if (codecBox.SelectedItem is not CodecChoice item) return 2.5; + var rate = settings.LoadSendRate(); + if (item.Codec == AudioTransportCodec.Opus) + { + return EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate) / 2.0; + } + // PCM + if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly) + { + return 0.5; // per-callback ASIO send → ~one ASIO buffer, hard to know without driver introspection + } + return rate == SendRate.Tight ? 1.25 : 2.5; + } + + /// Lowest healthy peer's heartbeat RTT. Used as the wire-time estimate. Returns 0 + /// if no peers are healthy. + private double LowestPeerRttMs() + { + if (heartbeatService is null) return 0; + var min = double.MaxValue; + foreach (var ph in heartbeatService.GetAllPeerHealth()) + { + if (ph.State != PeerHealthState.Healthy || ph.RttMs is not { } rtt) continue; + if (rtt < min) min = rtt; + } + return min == double.MaxValue ? 0 : min; + } + + /// Rough render-side buffer estimate. WASAPI shared-mode is ~10 ms typical. + /// BothIndependent has no tee — both lanes run at their native callback rate — so the + /// worse of the two governs perceived delay. ASIO depends on driver buffer settings + /// we don't query, but is always lower than WASAPI in practice, so the WASAPI estimate + /// is what governs in both modes. + private double RenderBufferEstimateMs() => 10; + + /// + /// Translates a codec choice + the user's Send Rate into the effective Opus frame size. + /// PCM frame size is set separately in AudioSender.SetSendRate (it's a sample-count, not + /// a milliseconds value). Standard returns the codec's natural frame; Tight halves it + /// (Opus 20 → 10, Opus 10 → 5, PCM frame size handled in AudioSender). Opus codec accepts + /// 2.5/5/10/20/40/60 ms — never goes below 5 here so we don't need sub-millisecond Opus. + /// + private static int EffectiveOpusFrameMs(AudioTransportCodec codec, int opusFrameMs, SendRate rate) + { + if (codec != AudioTransportCodec.Opus) return opusFrameMs; + return rate == SendRate.Tight ? Math.Max(5, opusFrameMs / 2) : opusFrameMs; + } + + /// + /// Short codec label for the per-peer line in the connectivity dialog. e.g. "PCM", + /// "Opus 10ms", "Opus 20ms". Uses the same EffectiveOpusFrameMs the encoder uses so the + /// label reflects the actually-encoded frame size, not the codec menu choice. + /// + private static string FormatCodecLabel(AudioTransportCodec codec, int opusFrameMs) + { + return codec switch + { + AudioTransportCodec.Opus => $"Opus {Math.Max(1, opusFrameMs)}ms", + AudioTransportCodec.Pcm => "PCM", + _ => codec.ToString(), + }; + } + + /// Snap an integer to the nearest 5. Used to keep RTT chatter in the per-peer + /// listbox line low — single-millisecond drift no longer re-announces under NVDA. + private static int RoundToFive(int value) => ((value + 2) / 5) * 5; + + /// Re-applies the codec/Opus-frame setting after the user changes Send Rate. The + /// PCM frame size is updated by AudioSender.SetSendRate directly; for Opus we have to + /// re-init the encoder via ConfigureCodec. + private void ApplySendRateToOpus(SendRate rate) + { + if (codecBox.SelectedItem is CodecChoice item && item.Codec == AudioTransportCodec.Opus) + { + sender.ConfigureCodec(item.Codec, EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate)); + logFile.Event($"send rate changed to {rate} → Opus frame {EffectiveOpusFrameMs(item.Codec, item.OpusFrameMs, rate)}ms"); + } + else + { + logFile.Event($"send rate changed to {rate} (PCM)"); + } + } + + private static int ResolveCodecIndex(AudioTransportCodec codec, int opusFrameMs) + { + if (codec == AudioTransportCodec.Pcm) return 0; + return opusFrameMs == 20 ? 1 : 2; // Opus 20 = index 1, Opus 10 (default) = index 2 + } + + // ===================== Auto-tune ===================== + + /// (Re)configures the continuous-tune timer based on the current checkbox / combo + /// state held in / . + /// Called whenever either changes (in the dialog) or at startup. The timer fires when + /// either lane has auto-tune enabled — in classic modes that's just the single WASAPI/ + /// Mixed flag; in BothIndependent either WASAPI or ASIO being on is enough to keep the + /// timer running. The per-route filtering inside the tick gates which sliders actually + /// move. + private void ApplyContinuousTuneTimer() + { + continuousTuneTimer.Stop(); + var inBothIndependent = settings.LoadAudioMode() == AudioMode.BothIndependent; + var asioEnabled = inBothIndependent && settings.LoadContinuousAutoTuneAsioEnabled(); + // Auto-tune needs the per-second diag snapshot (arrival-gap and render-callback-gap + // history) to make its recommendation. Make sure the engine's instrumentation is on + // whenever either lane's continuous tune is active, even if the Enable-logs checkbox + // is off. + UpdateDiagnosticsGate(); + if (!continuousTuneEnabled && !asioEnabled) return; + continuousTuneTimer.Interval = Math.Max(1000, continuousTuneIntervalSec * 1000); + continuousTuneTimer.Start(); + } + + /// Recompute from every reason the engine + /// might need its instrumentation on: the user-facing Enable-logs checkbox, plus either + /// continuous-auto-tune toggle. Auto-tune reads diag.MaxArrivalGapMs / + /// diag.MaxRenderCallbackGapMs from the per-second snapshot to size the latency + /// target, so its data has to keep flowing even when logs are off; the user shouldn't + /// have to enable logging just to make auto-tune work. + private void UpdateDiagnosticsGate() + { + var asioContinuous = settings.LoadAudioMode() == AudioMode.BothIndependent + && settings.LoadContinuousAutoTuneAsioEnabled(); + DiagnosticsGate.Enabled = logFile.Enabled || continuousTuneEnabled || asioContinuous; + } + + /// Which route the legacy "Audio latency / WASAPI latency" slider operates on. + /// In classic modes that's the Mixed route (only sessions in play). In BothIndependent + /// the slider has been relabeled to "WASAPI latency" and drives the WasapiLane route. + private RenderRoute MaxLatencyBoxRoute => + settings.LoadAudioMode() == AudioMode.BothIndependent ? RenderRoute.WasapiLane : RenderRoute.Mixed; + + /// + /// Continuous-tune tick. Computes a recommended target from the rolling max-gap window and + /// adjusts the slider, with several robustness rules learned from real-world testing: + /// + /// 1. **Max over a long lookback window.** Earlier we used p95 of the recent few seconds, + /// but with very few samples that's mathematically the same as the max anyway, and + /// bad events aged out of the window in seconds — so auto-tune could drop the target + /// below the level that had just earned the user a pop. Now we take the worst gap + /// across the last seconds, so a bad event keeps target + /// elevated long enough to cover the long-tail of the same disturbance. + /// 2. **Cap auto-tune recommendations at .** Beyond + /// that the user is in "I want a huge buffer for terrible network" territory — they can + /// drag the slider there manually; the auto-tuner shouldn't go there on its own. + /// 3. **Asymmetric step.** Raising the target on observed jitter happens immediately. Lowering + /// is rate-limited to per tick so a brief good window + /// doesn't undo the protection a bad event just earned us. + /// 4. **Skip tuning while underruns are growing.** If the buffer is currently underrunning, + /// the system isn't in steady state. Tuning now would react to broken stats. + /// 5. **Skip if the user just touched the slider** — see . + /// + private void ContinuousTuneTick() + { + if (!receiver.IsRunning) return; + var frameMs = receiver.ActiveStreamFrameMs; + if (frameMs is null) return; + if (recentMaxGaps.Count < 2) return; + // Same deferral when the source list changed: the freshly-added capture's first packets + // can land slightly off-cadence as its ring buffer fills, and we don't want that + // transient to influence the recommendation. Applied to every per-route tick. + var intervalSec = continuousTuneIntervalSec; + if (DateTime.UtcNow - lastSourceChangeUtc < TimeSpan.FromSeconds(intervalSec)) return; + + // Dispatch per route. Classic modes drive only the Mixed route (the legacy single-knob + // world). BothIndependent ticks both routes — each respecting its own enable flag, + // slider, last-user-move timestamp and underrun delta — so the WASAPI lane's distress + // can't make the ASIO lane's auto-tune defer (and vice versa). + if (settings.LoadAudioMode() == AudioMode.BothIndependent) + { + // Skip ticking a lane that has no active sessions. The shared recentMaxGaps + // window is populated by every incoming packet regardless of lane, so without + // this gate a route with no audio would still react to the OTHER route's + // gap signal and silently inflate its target before any of its own audio has + // arrived. + // Skip ticking a lane that has no active sessions. The shared recentMaxGaps + // window is populated by every incoming packet regardless of lane, so without + // this gate a route with no audio would still react to the OTHER route's + // gap signal and silently inflate its target before any of its own audio has + // arrived. + if (continuousTuneEnabled && receiver.HasSessionsForRoute(RenderRoute.WasapiLane)) + { + TickRoute(RenderRoute.WasapiLane, maxLatencyBox, "WASAPI", + ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking, + lastUserSliderMoveUtc, intervalSec, frameMs.Value); + } + if (settings.LoadContinuousAutoTuneAsioEnabled() && receiver.HasSessionsForRoute(RenderRoute.AsioLane)) + { + TickRoute(RenderRoute.AsioLane, maxLatencyAsioBox, "ASIO", + ref lastObservedUnderrunCountAsio, ref suppressUserAsioSliderMoveTracking, + lastUserAsioSliderMoveUtc, intervalSec, frameMs.Value); + } + } + else + { + if (continuousTuneEnabled) + { + TickRoute(RenderRoute.Mixed, maxLatencyBox, "", + ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking, + lastUserSliderMoveUtc, intervalSec, frameMs.Value); + } + } + } + + // Per-route auto-tune-tick state. lastObservedUnderrunCount + suppress flag are the + // existing single-route fields; the *Asio variants below are their BothIndependent + // counterparts. The ref-pass into TickRoute keeps the existing field-update semantics + // (atomic delta computation, suppress-flag lifecycle) for both routes without needing + // a heap-allocated state object on the hot path. + private long lastObservedUnderrunCountAsio; + private bool suppressUserAsioSliderMoveTracking; + + /// + /// Per-route auto-tune tick body. Same algorithm as the pre-2026-05-11 single-route + /// version, generalised to operate on a route + slider pair passed by the caller. The + /// gap and render-callback histories ( / + /// ) are still shared across routes — the network signal + /// is one signal, both lanes ride the same UDP socket — but the underrun delta, the + /// last-user-slider-move timestamp, and the slider itself are per-route so each lane + /// settles at its own native latency. Logs include the route name so the diagnostic + /// trail makes which lane was tuned obvious. + /// + private void TickRoute( + RenderRoute route, + NumericUpDown slider, + string routeLabel, + ref long lastObservedUnderruns, + ref bool suppressFlag, + DateTime lastUserMoveUtc, + int intervalSec, + int frameMs) + { + // Render period was a hardcoded 10ms here (sized for shared-mode WASAPI). On ASIO + // with a small buffer (32 samples = 0.67ms callback) the real value is 1-2ms, and + // the constant inflated every recommendation by 8ms+ for ASIO users. Now derived + // from the actual render-callback measurements over the same lookback as the gap + // measurement. + const int RenderPeriodFloorMs = 2; + const int SafetyMarginMs = 5; + const int HysteresisMs = 5; + const int AutoTuneRecommendationCapMs = 200; + const int MaxDecreasePerTickMs = 5; + const int LookbackSeconds = 15; + + // Defer to user's manual change — wait at least one tick interval before overriding. + if (DateTime.UtcNow - lastUserMoveUtc < TimeSpan.FromSeconds(intervalSec)) return; + + // Per-route underrun delta. The receiver tracks underruns per session, so summing + // only over sessions tagged with this route gives a route-local distress signal. + var currentUnderruns = route == RenderRoute.Mixed ? receiver.Underruns : receiver.UnderrunsFor(route); + var underrunDelta = currentUnderruns - lastObservedUnderruns; + lastObservedUnderruns = currentUnderruns; + if (underrunDelta > 0) + { + // Route label slots into the message body when present, omitted entirely in classic + // modes so the legacy "continuous auto-tune: skipping (N new underruns...)" wording + // is preserved bit-for-bit. The trailing-space + colon ordering is what gave the + // pre-fix line its weird "continuous auto-tune : skipping" formatting when the + // label was empty. + var prefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}"; + logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick)"); + return; + } + + var sampleCount = Math.Min(LookbackSeconds, recentMaxGaps.Count); + var skip = recentMaxGaps.Count - sampleCount; + var observedGap = 0; + var i = 0; + foreach (var gap in recentMaxGaps) + { + if (i++ < skip) continue; + if (gap > observedGap) observedGap = gap; + } + + var observedRenderCb = RenderPeriodFloorMs; + var rcbSkip = recentRenderCbGaps.Count - sampleCount; + var rcbI = 0; + foreach (var rcb in recentRenderCbGaps) + { + if (rcbI++ < rcbSkip) continue; + if (rcb > observedRenderCb) observedRenderCb = rcb; + } + + var codecFloor = (int)Math.Ceiling(1.5 * frameMs); + var jitterBased = observedGap + observedRenderCb + SafetyMarginMs; + var recommended = Math.Max(codecFloor, jitterBased); + var capped = Math.Min(recommended, AutoTuneRecommendationCapMs); + var current = (int)slider.Value; + + int target; + if (capped > current) + { + target = capped; + } + else + { + target = Math.Max(capped, current - MaxDecreasePerTickMs); + } + + var clamped = Math.Clamp(target, (int)slider.Minimum, (int)slider.Maximum); + if (Math.Abs(clamped - current) < HysteresisMs) return; + + suppressFlag = true; + try + { + slider.Value = clamped; + } + finally + { + suppressFlag = false; + } + var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}"; + logFile.Event($"{logPrefix}: gap-max={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms"); + } + + // UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button. + // The continuous auto-tune toggle on the Audio profile tab is the live successor. + + // ===================== Accessibility helpers (CheckedListBox status labels) ===================== + + private void WireCheckedListAccessibility(CheckedListBox list, Label statusLabel, string itemKind) + { + list.SelectedIndexChanged += (_, _) => + { + if (list.SelectedIndex >= 0) lastFocusedListIndices[list] = list.SelectedIndex; + UpdateCheckedListStatus(list, statusLabel, itemKind); + }; + list.Enter += (_, _) => RestoreListFocus(list, statusLabel, itemKind); + list.GotFocus += (_, _) => RestoreListFocus(list, statusLabel, itemKind); + list.MouseDown += (_, args) => + { + var index = list.IndexFromPoint(args.Location); + if (index >= 0) + { + list.SelectedIndex = index; + lastFocusedListIndices[list] = index; + } + }; + // First-letter navigation: highlights the matching item without ever toggling its check. + // Default CheckedListBox key handling has been observed to (sometimes) toggle the check + // when a single-letter prefix uniquely matches one item. Bypass that by handling KeyDown + // ourselves and suppressing the default key processing for letters/digits. Spacebar still + // falls through to the default handler so users can still toggle with Space. + list.KeyDown += (_, args) => + { + if (args.Modifiers != Keys.None) return; + char ch; + if (args.KeyCode >= Keys.A && args.KeyCode <= Keys.Z) + ch = (char)('a' + (args.KeyCode - Keys.A)); + else if (args.KeyCode >= Keys.D0 && args.KeyCode <= Keys.D9) + ch = (char)('0' + (args.KeyCode - Keys.D0)); + else if (args.KeyCode >= Keys.NumPad0 && args.KeyCode <= Keys.NumPad9) + ch = (char)('0' + (args.KeyCode - Keys.NumPad0)); + else return; + + var startIdx = list.SelectedIndex < 0 ? 0 : list.SelectedIndex + 1; + for (var offset = 0; offset < list.Items.Count; offset++) + { + var idx = (startIdx + offset) % list.Items.Count; + var text = list.Items[idx]?.ToString() ?? string.Empty; + if (text.Length > 0 && char.ToLowerInvariant(text[0]) == ch) + { + list.SelectedIndex = idx; + break; + } + } + // Always swallow letter/digit keys so the default handler can't toggle anything. + args.Handled = true; + args.SuppressKeyPress = true; + }; + list.ItemCheck += (_, args) => + { + void Update() + { + if (list.IsDisposed || statusLabel.IsDisposed) return; + var checkedNow = args.NewValue == CheckState.Checked; + UpdateCheckedListStatus(list, statusLabel, itemKind, args.Index, checkedNow); + } + + if (list.IsHandleCreated) list.BeginInvoke((MethodInvoker)Update); + else Update(); + }; + UpdateCheckedListStatus(list, statusLabel, itemKind); + } + + private void RestoreListFocus(CheckedListBox list, Label statusLabel, string itemKind) + { + if (list.Items.Count == 0) { UpdateCheckedListStatus(list, statusLabel, itemKind); return; } + var target = list.SelectedIndex >= 0 + ? list.SelectedIndex + : lastFocusedListIndices.TryGetValue(list, out var saved) ? Math.Clamp(saved, 0, list.Items.Count - 1) : 0; + + void Restore() + { + if (list.IsDisposed || list.Items.Count == 0) return; + target = Math.Clamp(target, 0, list.Items.Count - 1); + list.SelectedIndex = target; + list.TopIndex = Math.Max(0, target); + lastFocusedListIndices[list] = target; + UpdateCheckedListStatus(list, statusLabel, itemKind); + // Force-fire EVENT_OBJECT_FOCUS once the SelectedIndex and AccessibleDescription + // have been set, so NVDA re-announces the list with its current item state. This is + // the same load-bearing pattern that fixed the CheckBox state-change announcement. + WinEventNotifier.NotifyFocus(list); + } + + if (list.IsHandleCreated) list.BeginInvoke((MethodInvoker)Restore); + else Restore(); + } + + private static void UpdateCheckedListStatus(CheckedListBox list, Label statusLabel, string itemKind, int? overrideIndex = null, bool? overrideChecked = null) + { + if (list.Items.Count == 0) + { + var emptyText = $"No {itemKind}s available."; + statusLabel.Text = emptyText; + list.AccessibleDescription = emptyText; + return; + } + + var index = overrideIndex ?? (list.SelectedIndex >= 0 ? list.SelectedIndex : 0); + index = Math.Clamp(index, 0, list.Items.Count - 1); + var isChecked = overrideChecked ?? list.GetItemChecked(index); + var checkedText = isChecked ? "checked" : "not checked"; + var itemText = list.Items[index]?.ToString() ?? itemKind; + var text = $"{checkedText}, {itemText}. Item {index + 1} of {list.Items.Count}. Press Space to toggle."; + statusLabel.Text = text; + list.AccessibleDescription = text; + statusLabel.AccessibleDescription = text; + } + + /// + /// Makes a NumericUpDown's text content fully selected whenever the control receives focus, + /// so the user's first typed digit replaces the existing value rather than being inserted + /// into it. Without this, tabbing into a spinner showing "80" and typing "10" produces + /// "8010" — the WinForms default that nobody wants. Hooks both Enter (keyboard / Tab) and + /// the underlying TextBox's GotFocus (mouse-click into the field). The Select(0, length) + /// targets the inner TextBox via NumericUpDown.Select. + /// + private static void SelectAllOnFocus(NumericUpDown box) + { + void SelectAll() => box.Select(0, box.Text.Length); + box.Enter += (_, _) => SelectAll(); + // The inner TextBox's own GotFocus also fires when the user clicks directly into the + // text portion of the spinner. Subscribe defensively to it as well. + foreach (Control c in box.Controls) + { + if (c is TextBox tb) + { + tb.GotFocus += (_, _) => SelectAll(); + break; + } + } + } + + private void FocusControl(Control control) + { + if (!control.CanFocus) return; + control.Focus(); + if (control is ComboBox combo && combo.Items.Count > 0 && combo.SelectedIndex < 0) combo.SelectedIndex = 0; + // Same defensive pre-select for ListBox so NVDA reads the current item on first focus + // (otherwise an unselected list is announced as just "list" with no item). + if (control is ListBox listBox && listBox.Items.Count > 0 && listBox.SelectedIndex < 0) listBox.SelectedIndex = 0; + // 2026-05-06: removed the WinEventNotifier.NotifyFocus(control) call here. It was + // forcing NVDA to re-announce on every Focus() — and I now suspect that's why NVDA + // sometimes reads "tab control" before the focused control: the explicit focus + // event triggers a fresh role-context announcement. Andre's app doesn't fire any + // such events. Trying without it. + } + + private void FocusListControl(CheckedListBox list) + { + // Pre-select an item BEFORE calling Focus(). The previous order was Focus() → then + // RestoreListFocus → BeginInvoke → SelectedIndex = N. That defers the selection past + // NVDA's first focus-event announcement, so NVDA reads only the list's name and not + // the current item. Setting SelectedIndex synchronously here means the focus event + // fires with the list already pointing at item N, so NVDA reads ", list, item N + // of M: , " in one go. + var statusLabel = list == sendOutputDevicesList + ? sendOutputDevicesStatusLabel + : list == sendInputDevicesList + ? sendInputDevicesStatusLabel + : list == receiveOutputDevicesList + ? receiveOutputDevicesStatusLabel + : list == asioSendDevicesList + ? asioSendDevicesStatusLabel + : list == asioReceiveOutputDevicesList + ? asioReceiveOutputDevicesStatusLabel + : new Label(); + var itemKind = list == sendOutputDevicesList + ? "output device" + : list == sendInputDevicesList + ? "input device" + : list == receiveOutputDevicesList + ? "receive output device" + : list == asioSendDevicesList + ? "ASIO send channel" + : list == asioReceiveOutputDevicesList + ? "ASIO receive channel" + : "item"; + if (list.Items.Count > 0 && list.SelectedIndex < 0) + { + var target = lastFocusedListIndices.TryGetValue(list, out var saved) + ? Math.Clamp(saved, 0, list.Items.Count - 1) + : 0; + list.SelectedIndex = target; + list.TopIndex = Math.Max(0, target); + lastFocusedListIndices[list] = target; + } + UpdateCheckedListStatus(list, statusLabel, itemKind); + list.Focus(); + WinEventNotifier.NotifyFocus(list); + } + + /// Prompt the user to save unsaved profile changes before exiting. Skipped when + /// the close is a profile-switch / folder-change reload (Program.cs handles re-launching + /// the form on the new profile, and we don't want to nag during that handoff). The + /// MessageBox is Yes/No/Cancel: Yes = save (save-as flow on blank template), No = exit + /// without saving, Cancel = stay in the form. + protected override void OnFormClosing(FormClosingEventArgs e) + { + // Skip the prompt during profile-switch handoff or forced reload — those are + // controlled close paths where the user has already confirmed their intent via the + // management dialog, and the MainForm gets reconstructed under the new profile + // immediately afterwards. + var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch; + + if (!skipPrompt && profileStore is not null && unsavedChanges) + { + // Originally this also did a JSON-state diff against a baseline snapshot as a + // backstop for hooks we forgot to wire. Removed 2026-05-05 because it caused + // false-positive prompts: continuous auto-tune routinely nudges MaxLatencyMs while + // the user just listens, and the diff would catch those auto-internal changes as + // "user changes". Now we trust the dirty flag exclusively. The risk of missing a + // hook (false-NEGATIVE — user changes something via an unhooked path, no prompt + // on close) is acceptable; the previous false-POSITIVE behaviour was nagging. + { + var result = MessageBox.Show(this, + "You have unsaved changes to your profile. Save them before exiting?\n\n" + + "Yes — save and exit.\nNo — exit without saving.\nCancel — keep RemSound open.", + "RemSound — unsaved changes", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button3); + + if (result == DialogResult.Cancel) + { + e.Cancel = true; + return; // stay; don't fire base.OnFormClosing or the cleanup chain. + } + if (result == DialogResult.Yes) + { + if (string.IsNullOrEmpty(currentProfileTitle)) + { + // Blank template — need a name. Save-as prompt; if the user cancels + // the prompt, treat that as "I changed my mind, don't exit either". + var title = ProfileSaveAsPrompt.Show(this, profileStore, null); + if (string.IsNullOrEmpty(title)) + { + e.Cancel = true; + return; + } + SaveProfileTo(title, showConfirmation: false); + } + else + { + SaveProfileTo(currentProfileTitle, showConfirmation: false); + } + } + // result == No falls through to a normal close. + } + } + + base.OnFormClosing(e); + } +} diff --git a/src/RemSound.App/MainFormChoices.cs b/src/RemSound.App/MainFormChoices.cs new file mode 100644 index 0000000..cb12a27 --- /dev/null +++ b/src/RemSound.App/MainFormChoices.cs @@ -0,0 +1,98 @@ +using System.Windows.Forms; +using RemSound.Core; + +namespace RemSound.App; + +/// +/// CheckedListBox subclass that exposes the protected RefreshItem method publicly. Used +/// for the connectivity dialog's "Connected peers" list, where each row's text is updated in +/// place (live RTT, codec, direction) without re-adding items — that would destroy NVDA's row +/// focus on every tick. +/// +internal sealed class LiveCheckedListBox : CheckedListBox +{ + public void RefreshItemPublic(int index) => RefreshItem(index); +} + +/// +/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame +/// size is only meaningful when Codec == Opus; for PCM it's ignored. +/// +internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs) +{ + public override string ToString() => Label; +} + +internal sealed record AudioDeviceChoice(string Name, string? DeviceId, CaptureKind Kind = CaptureKind.Loopback) +{ + public override string ToString() => Name; +} + +internal sealed record RememberedPeerItem(string Entry) +{ + public override string ToString() => Entry; +} + +/// +/// Live per-peer status surfaced in the connectivity dialog's listbox text. Mutated in place +/// each tick by MainForm.SyncAllDialogPeerLists, then ListBox.RefreshItem(i) is called on the +/// containing item so the visible label updates without rebuilding the listbox (which would +/// destroy NVDA focus on the row). +/// +internal sealed class PeerLineStatus +{ + public bool Connected; + /// True when our sender is actively pushing audio at this peer. + public bool Sending; + /// True when audio is arriving from this peer's IP (a fresh receiver session exists). + public bool Receiving; + /// Codec label like "Opus 10ms", "Opus 20ms", "PCM". Null when not connected. + public string? CodecLabel; + /// Round-trip ping ms from heartbeat. Null when not connected or pending. + public int? RttMs; +} + +internal sealed class PeerListItem +{ + public PeerAnnouncement Peer { get; } + public PeerLineStatus Status { get; } = new(); + + public PeerListItem(PeerAnnouncement peer) { Peer = peer; } + + /// + /// Stable identity for signature-based listbox-rebuild detection. Does NOT include live + /// status — that gets updated in-place via RefreshItem so NVDA focus survives tick updates. + /// + public string StableKey() => $"{Peer.InstanceId}:{Peer.Name}:{Peer.Address}:{Peer.AudioPort}"; + + public override string ToString() + { + // Base label: "hostname (ip)" for discovered peers, just "ip" for manual-by-IP entries + // (where hostname equals the IP address). Avoids "192.168.1.95 (192.168.1.95)" duplication. + var addr = Peer.Address.ToString(); + var basePart = Peer.Name == addr ? addr : $"{Peer.Name} ({addr})"; + + if (!Status.Connected) + { + return basePart; + } + + // Connected line — extra metadata after a dash. Comma-separated so NVDA reads naturally: + // "Andre's PC (1.2.3.4) — connected, Opus 10ms, send and receive, 32ms" + var parts = new List { "connected" }; + if (Status.CodecLabel is { Length: > 0 } codec) parts.Add(codec); + + var direction = (Status.Sending, Status.Receiving) switch + { + (true, true) => "send and receive", + (true, false) => "send only", + (false, true) => "receive only", + _ => null, + }; + if (direction is not null) parts.Add(direction); + + if (Status.RttMs is { } rtt) parts.Add($"{rtt}ms"); + + return $"{basePart} — {string.Join(", ", parts)}"; + } +} diff --git a/src/RemSound.App/MainFormHotkeyController.cs b/src/RemSound.App/MainFormHotkeyController.cs new file mode 100644 index 0000000..e2e9391 --- /dev/null +++ b/src/RemSound.App/MainFormHotkeyController.cs @@ -0,0 +1,605 @@ +using System.Runtime.InteropServices; +using RemSound.Core; + +namespace RemSound.App; + +internal sealed class MainFormHotkeyController : IDisposable +{ + private readonly RemSoundSettingsStore settingsStore; + private readonly Action toggleSend; + private readonly Action toggleReceive; + private readonly Action toggleTray; + private readonly Action volumeUp; + private readonly Action volumeDown; + // Remote control hotkeys: trigger this machine to send a Control packet to its connected + // peers. The local volume slider on this machine isn't touched — receivers that have opted + // in handle the change. See Profile.AcceptRemoteVolumeCommands and the RemPacketType.Control + // wire format. + // * sendRemote* → adjust the receiver's RemSound app volume slider (in-app). + // * sendSystem* → adjust the receiver's Windows default-output-device volume + // (system-wide on the receiving machine — affects every app + // there, including the screen reader). + private readonly Action sendRemoteVolumeUp; + private readonly Action sendRemoteVolumeDown; + private readonly Action sendRemoteMuteToggle; + private readonly Action sendSystemVolumeUp; + private readonly Action sendSystemVolumeDown; + private readonly Action sendSystemMuteToggle; + private Form? owner; + private HotkeyInfo sendMuteHotkey; + private HotkeyInfo receiveMuteHotkey; + private HotkeyInfo trayHotkey; + private HotkeyInfo volumeUpHotkey; + private HotkeyInfo volumeDownHotkey; + private HotkeyInfo remoteVolumeUpHotkey; + private HotkeyInfo remoteVolumeDownHotkey; + private HotkeyInfo remoteMuteToggleHotkey; + private HotkeyInfo systemVolumeUpHotkey; + private HotkeyInfo systemVolumeDownHotkey; + private HotkeyInfo systemMuteToggleHotkey; + private GlobalHotkey? sendMuteGlobalHotkey; + private GlobalHotkey? receiveMuteGlobalHotkey; + private GlobalHotkey? trayGlobalHotkey; + private GlobalHotkey? volumeUpGlobalHotkey; + private GlobalHotkey? volumeDownGlobalHotkey; + private GlobalHotkey? remoteVolumeUpGlobalHotkey; + private GlobalHotkey? remoteVolumeDownGlobalHotkey; + private GlobalHotkey? remoteMuteToggleGlobalHotkey; + private GlobalHotkey? systemVolumeUpGlobalHotkey; + private GlobalHotkey? systemVolumeDownGlobalHotkey; + private GlobalHotkey? systemMuteToggleGlobalHotkey; + + /// Optional log sink. MainForm wires this to logFile.Event(...) so each + /// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X + /// successfully" / "registration FAILED with Win32 error N" to the diagnostic log. Lets + /// us tell the difference between a capture that didn't fire, a save that didn't persist, + /// and a Windows-side RegisterHotKey rejection. + public Action? Log { get; set; } + + /// Optional callback fired when the user successfully captures and saves a new + /// hotkey via the Keyboard shortcuts dialog. MainForm wires this to MarkProfileDirty so + /// the unsaved-changes prompt fires on close and the user gets a Save reminder. Without + /// this hook, hotkey edits silently bypass the dirty-flag and the user finds out their + /// new bindings never made it into the profile JSON. + public Action? OnHotkeyChanged { get; set; } + + public MainFormHotkeyController( + RemSoundSettingsStore settingsStore, + Action toggleSend, + Action toggleReceive, + Action toggleTray, + Action volumeUp, + Action volumeDown, + Action sendRemoteVolumeUp, + Action sendRemoteVolumeDown, + Action sendRemoteMuteToggle, + Action sendSystemVolumeUp, + Action sendSystemVolumeDown, + Action sendSystemMuteToggle) + { + this.settingsStore = settingsStore; + this.toggleSend = toggleSend; + this.toggleReceive = toggleReceive; + this.toggleTray = toggleTray; + this.volumeUp = volumeUp; + this.volumeDown = volumeDown; + this.sendRemoteVolumeUp = sendRemoteVolumeUp; + this.sendRemoteVolumeDown = sendRemoteVolumeDown; + this.sendRemoteMuteToggle = sendRemoteMuteToggle; + this.sendSystemVolumeUp = sendSystemVolumeUp; + this.sendSystemVolumeDown = sendSystemVolumeDown; + this.sendSystemMuteToggle = sendSystemMuteToggle; + sendMuteHotkey = settingsStore.LoadSendMuteHotkey(); + receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey(); + trayHotkey = settingsStore.LoadTrayHotkey(); + volumeUpHotkey = settingsStore.LoadVolumeUpHotkey(); + volumeDownHotkey = settingsStore.LoadVolumeDownHotkey(); + remoteVolumeUpHotkey = settingsStore.LoadRemoteVolumeUpHotkey(); + remoteVolumeDownHotkey = settingsStore.LoadRemoteVolumeDownHotkey(); + remoteMuteToggleHotkey = settingsStore.LoadRemoteMuteToggleHotkey(); + systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey(); + systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey(); + systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey(); + } + + public void Initialize(Form ownerForm) + { + owner = ownerForm; + sendMuteGlobalHotkey = new GlobalHotkey(ownerForm); + receiveMuteGlobalHotkey = new GlobalHotkey(ownerForm); + trayGlobalHotkey = new GlobalHotkey(ownerForm); + volumeUpGlobalHotkey = new GlobalHotkey(ownerForm); + volumeDownGlobalHotkey = new GlobalHotkey(ownerForm); + remoteVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm); + remoteVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm); + remoteMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm); + systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm); + systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm); + systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm); + sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend); + receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive); + trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray); + volumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(volumeUp); + volumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(volumeDown); + remoteVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeUp); + remoteVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeDown); + remoteMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteMuteToggle); + systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp); + systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown); + systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle); + RegisterSendMuteHotkey(); + RegisterReceiveMuteHotkey(); + RegisterTrayHotkey(); + RegisterVolumeUpHotkey(); + RegisterVolumeDownHotkey(); + RegisterRemoteVolumeUpHotkey(); + RegisterRemoteVolumeDownHotkey(); + RegisterRemoteMuteToggleHotkey(); + RegisterSystemVolumeUpHotkey(); + RegisterSystemVolumeDownHotkey(); + RegisterSystemMuteToggleHotkey(); + } + + public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner) + { + // Modeled on the SpaceBlaster menu dialogs: + // * A ListBox fills the dialog. Each row is one bindable hotkey shown as + // "Action: current binding" — self-describing for NVDA on arrow-up/down. + // * Enter on the list (or double-click) → opens the capture form for that row. + // * Escape (or the Close button) closes the dialog. + // * Tab cycles list → Close button. No "Change selected" intermediate button — + // 2026-05-08 cleanup; the workflow is "arrow + Enter" exclusively, removing + // the extra Tab-to-button step the user had to make for every change. + // + // Why a ListBox instead of one Button per row: the hotkey count grew to eleven + // (5 local + 3 remote-app + 3 system-volume) and the per-row Button stack made + // arrow-key / Tab navigation slow. ListBox is one focusable control with native + // arrow-key navigation and NVDA reads each item as the selection moves — much + // quicker to triage which binding you want to change. + // CmdKeyForm gives us a ProcessCmdKey hook that runs BEFORE the form's + // ProcessDialogKey path (which is what would fire AcceptButton on Enter). We + // need that to make Enter-on-the-list rebind a hotkey instead of closing the + // dialog. Without this, AcceptButton swallowed Enter regardless of which + // control had focus and the user got bounced straight back to the Profiles + // and preferences tab. (KeyPreview + the form-level KeyDown wasn't enough on + // its own — that fires AFTER ProcessCmdKey/ProcessDialogKey, so AcceptButton + // had already won.) + using var dialog = new CmdKeyForm + { + Text = "Keyboard shortcuts", + StartPosition = FormStartPosition.CenterParent, + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false, + ShowInTaskbar = false, + KeyPreview = true, // form-level Esc handler + ClientSize = new Size(640, 440), + }; + + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 1, + RowCount = 3, // 0 intro, 1 list, 2 buttons + }; + root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var introLabel = new Label + { + Text = "Arrow up and down to pick a shortcut. Press Enter to rebind it, Del to clear it. Escape closes the dialog.\n\n" + + "The remote-control rows send commands to connected peers; they only have an effect on peers that have 'Accept remote volume commands from peers' enabled.", + AutoSize = true, + MaximumSize = new Size(600, 0), + Anchor = AnchorStyles.Left, + }; + root.Controls.Add(introLabel, 0, 0); + + var list = new ListBox + { + Dock = DockStyle.Fill, + IntegralHeight = false, + // NVDA reads this on first focus, then the per-item text on each arrow move. + AccessibleName = "Keyboard shortcuts", + TabIndex = 0, + }; + root.Controls.Add(list, 0, 1); + + var buttonsPanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + Padding = new Padding(0, 8, 0, 0), + }; + var closeButton = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 1 }; + buttonsPanel.Controls.Add(closeButton); + root.Controls.Add(buttonsPanel, 0, 2); + + dialog.Controls.Add(root); + + // The list rows correspond to the order below. Index → which Change* helper to call. + // Stable ordering keeps the user's muscle memory between sessions: local hotkeys + // first, then the remote-app trio, then the Windows system-volume trio. + void RefreshList() + { + var prev = list.SelectedIndex; + list.BeginUpdate(); + list.Items.Clear(); + list.Items.Add($"Toggle sending audio: {sendMuteHotkey}"); + list.Items.Add($"Toggle receiving audio: {receiveMuteHotkey}"); + list.Items.Add($"Show or hide window: {trayHotkey}"); + list.Items.Add($"Volume up for received sound on this machine: {volumeUpHotkey}"); + list.Items.Add($"Volume down for received sound on this machine: {volumeDownHotkey}"); + list.Items.Add($"Send remote volume up to peers: {remoteVolumeUpHotkey}"); + list.Items.Add($"Send remote volume down to peers: {remoteVolumeDownHotkey}"); + list.Items.Add($"Send remote receive mute toggle to peers: {remoteMuteToggleHotkey}"); + list.Items.Add($"Send Windows global volume up to peers: {systemVolumeUpHotkey}"); + list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}"); + list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}"); + if (prev >= 0 && prev < list.Items.Count) + { + list.SelectedIndex = prev; + } + else if (list.Items.Count > 0) + { + list.SelectedIndex = 0; + } + list.EndUpdate(); + } + + void ChangeSelected() + { + // Pass `dialog` (the shortcuts dialog itself) as the modal owner of the + // HotkeyCaptureForm — NOT the original `dialogOwner` (which is MainForm). + // Original bug: when MainForm was the owner, the new capture form was modal + // to MainForm rather than to the shortcuts dialog, which (a) put it behind + // the still-modal-to-MainForm shortcuts dialog in the Z-order, sometimes + // invisibly so, and (b) created two parallel modal-to-MainForm chains. With + // `dialog` as the owner, the capture form sits cleanly on top of the + // shortcuts dialog, the shortcuts dialog is correctly disabled while it's + // showing, and focus returns to the shortcuts list when it closes. + switch (list.SelectedIndex) + { + case 0: ChangeSendMuteHotkey(dialog); break; + case 1: ChangeReceiveMuteHotkey(dialog); break; + case 2: ChangeTrayHotkey(dialog); break; + case 3: ChangeVolumeUpHotkey(dialog); break; + case 4: ChangeVolumeDownHotkey(dialog); break; + case 5: ChangeRemoteVolumeUpHotkey(dialog); break; + case 6: ChangeRemoteVolumeDownHotkey(dialog); break; + case 7: ChangeRemoteMuteToggleHotkey(dialog); break; + case 8: ChangeSystemVolumeUpHotkey(dialog); break; + case 9: ChangeSystemVolumeDownHotkey(dialog); break; + case 10: ChangeSystemMuteToggleHotkey(dialog); break; + default: return; + } + RefreshList(); + // Move focus back to the list so the user can immediately arrow to another + // row without an extra Tab. Without this, focus stays on the Change button + // (which is what was clicked / Enter'd) — which is fine but feels sticky. + list.Focus(); + } + + // Clear the selected row's binding (set it to "(not set)"). Mirrors the same path + // the rebinding flow uses: writes Unset into the in-memory settings cache, calls + // RegisterIfSet (which unregisters because the new info IsUnset), marks the + // profile dirty, and refreshes the list display. NB: we do this from the list's + // KeyDown rather than ProcessCmdKey because Del isn't intercepted by the form- + // level AcceptButton dance and works fine via the standard event. + void UnsetSelected() + { + switch (list.SelectedIndex) + { + case 0: ApplyUnset("send-mute", h => sendMuteHotkey = h, RegisterSendMuteHotkey, settingsStore.SaveSendMuteHotkey); break; + case 1: ApplyUnset("receive-mute", h => receiveMuteHotkey = h, RegisterReceiveMuteHotkey, settingsStore.SaveReceiveMuteHotkey); break; + case 2: ApplyUnset("tray", h => trayHotkey = h, RegisterTrayHotkey, settingsStore.SaveTrayHotkey); break; + case 3: ApplyUnset("volume-up", h => volumeUpHotkey = h, RegisterVolumeUpHotkey, settingsStore.SaveVolumeUpHotkey); break; + case 4: ApplyUnset("volume-down", h => volumeDownHotkey = h, RegisterVolumeDownHotkey, settingsStore.SaveVolumeDownHotkey); break; + case 5: ApplyUnset("send-remote-volume-up", h => remoteVolumeUpHotkey = h, RegisterRemoteVolumeUpHotkey, settingsStore.SaveRemoteVolumeUpHotkey); break; + case 6: ApplyUnset("send-remote-volume-down", h => remoteVolumeDownHotkey = h, RegisterRemoteVolumeDownHotkey, settingsStore.SaveRemoteVolumeDownHotkey); break; + case 7: ApplyUnset("send-remote-mute-toggle", h => remoteMuteToggleHotkey = h, RegisterRemoteMuteToggleHotkey, settingsStore.SaveRemoteMuteToggleHotkey); break; + case 8: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break; + case 9: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break; + case 10: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break; + default: return; + } + RefreshList(); + list.Focus(); + } + + // Helper for UnsetSelected — assign Unset to the field, re-register (which + // unregisters since IsUnset is true), persist to the settings cache, log, and + // mark the profile dirty so the close-prompt fires. + void ApplyUnset(string description, Action setField, Action register, Action save) + { + setField(HotkeyInfo.Unset); + register(); + save(HotkeyInfo.Unset); + Log?.Invoke($"unset {description}: cleared (was bound, now (not set))"); + OnHotkeyChanged?.Invoke(); + } + + // Enter-on-the-list rebinds via ProcessCmdKey at the form, so that the form's + // AcceptButton dispatch (Close) doesn't get the keystroke first. ProcessCmdKey + // runs ahead of ProcessDialogKey in WinForms' message pipeline; returning true + // marks the key as consumed and the AcceptButton path is skipped. When focus + // is anywhere else (e.g. the Close button) we let Enter fall through, so + // Tab-to-Close + Enter still closes the dialog naturally. + dialog.CmdKeyHandler = keyData => + { + if (keyData != Keys.Enter) return false; + if (dialog.ActiveControl != list) return false; + ChangeSelected(); + return true; + }; + list.DoubleClick += (_, _) => ChangeSelected(); + // Del on the list clears the highlighted binding back to "(not set)". No confirm + // dialog — the user can rebind in two key presses (Enter + capture) if they hit Del + // by mistake. Mirrors the SpaceBlaster-style "list + Del" idiom Ed asked for. + list.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Delete) + { + UnsetSelected(); + e.SuppressKeyPress = true; + e.Handled = true; + } + }; + + dialog.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + { + dialog.DialogResult = DialogResult.Cancel; + dialog.Close(); + e.SuppressKeyPress = true; + e.Handled = true; + } + }; + + RefreshList(); + // Enter on Close closes — works because list KeyDown above handled Enter when + // focus was on the list. AcceptButton fires only when no control consumed Enter. + dialog.AcceptButton = closeButton; + dialog.CancelButton = closeButton; + dialog.Load += (_, _) => list.Focus(); + dialog.ShowDialog(dialogOwner); + } + + public void Dispose() + { + sendMuteGlobalHotkey?.Dispose(); + receiveMuteGlobalHotkey?.Dispose(); + trayGlobalHotkey?.Dispose(); + volumeUpGlobalHotkey?.Dispose(); + volumeDownGlobalHotkey?.Dispose(); + remoteVolumeUpGlobalHotkey?.Dispose(); + remoteVolumeDownGlobalHotkey?.Dispose(); + remoteMuteToggleGlobalHotkey?.Dispose(); + systemVolumeUpGlobalHotkey?.Dispose(); + systemVolumeDownGlobalHotkey?.Dispose(); + systemMuteToggleGlobalHotkey?.Dispose(); + } + + public HotkeyInfo SendMuteHotkey => sendMuteHotkey; + public HotkeyInfo ReceiveMuteHotkey => receiveMuteHotkey; + public HotkeyInfo TrayHotkey => trayHotkey; + public HotkeyInfo VolumeUpHotkey => volumeUpHotkey; + public HotkeyInfo VolumeDownHotkey => volumeDownHotkey; + public HotkeyInfo RemoteVolumeUpHotkey => remoteVolumeUpHotkey; + public HotkeyInfo RemoteVolumeDownHotkey => remoteVolumeDownHotkey; + public HotkeyInfo RemoteMuteToggleHotkey => remoteMuteToggleHotkey; + public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey; + public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey; + public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey; + + /// Open the capture dialog, log what came back, and (on a successful capture) + /// run with the captured hotkey. Centralises the boilerplate + /// the eleven per-row Change methods used to duplicate. The + /// is what shows in the diagnostic log so a user / developer can see the trail of + /// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J / register …: OK" or + /// "capture …: cancelled (DialogResult=Cancel)" / "register …: FAILED Win32 1409". + private void ChangeHotkey(IWin32Window dialogOwner, string description, Action apply) + { + using var dialog = new HotkeyCaptureForm(); + var result = dialog.ShowDialog(dialogOwner); + if (result == DialogResult.OK && dialog.CapturedHotkey is not null) + { + Log?.Invoke($"capture {description}: OK = {dialog.CapturedHotkey}"); + apply(dialog.CapturedHotkey); + // Mark the active profile dirty so the unsaved-changes prompt fires on close. + // The previous design relied on MarkProfileDirty being called from each UI event + // hook in MainForm — but the hotkey controller is its own object that doesn't + // know about that flag. Without this callback, hotkey edits silently slipped + // past the dirty-check and the user closed without being prompted to save. + OnHotkeyChanged?.Invoke(); + } + else + { + // Detect the "low-level hook ate your combination" case. If the capture form + // observed modifier presses but never received the non-modifier key the user + // was trying to bind, something else (NVDA / NVDA Remote / AutoHotkey / similar + // accessibility / hotkey-manager tool that hooks at WH_KEYBOARD_LL level) is + // intercepting the combination before Windows can deliver it to our window. + // RegisterHotKey would have succeeded if we'd ever reached that point, so the + // existing 1409-style warning never fires for this case — that's why the user + // saw "no popup" even though their combination genuinely was unusable. + // + // The popup is shown TopMost via the same Win32 path the register-warning uses, + // so it's guaranteed visible regardless of modal-stack Z-order. + Log?.Invoke($"capture {description}: cancelled (DialogResult={result}, sawModifier={dialog.SawAnyModifier}, sawNonModifier={dialog.SawAnyNonModifier})"); + if (dialog.SawAnyModifier && !dialog.SawAnyNonModifier) + { + Log?.Invoke($"capture {description}: warning user about likely low-level hook interception"); + ShowRegisterWarning( + "RemSound saw your modifier keys (Ctrl, Shift, Alt) but never received the non-modifier key you were pressing with them.\n\n" + + "That almost always means another app on this machine — NVDA, NVDA Remote, AutoHotkey, or a similar tool — is intercepting that key combination at a low level, before it can reach RemSound. The combination is unusable as a RemSound hotkey on this PC until the conflicting tool is reconfigured or that combination is freed up.\n\n" + + "Try a different key combination."); + } + } + } + + private void ChangeSendMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-mute", h => + { + sendMuteHotkey = h; + RegisterSendMuteHotkey(); + settingsStore.SaveSendMuteHotkey(h); + }); + + private void ChangeReceiveMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "receive-mute", h => + { + receiveMuteHotkey = h; + RegisterReceiveMuteHotkey(); + settingsStore.SaveReceiveMuteHotkey(h); + }); + + private void ChangeTrayHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "tray", h => + { + trayHotkey = h; + RegisterTrayHotkey(); + settingsStore.SaveTrayHotkey(h); + }); + + private void ChangeVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-up", h => + { + volumeUpHotkey = h; + RegisterVolumeUpHotkey(); + settingsStore.SaveVolumeUpHotkey(h); + }); + + private void ChangeVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-down", h => + { + volumeDownHotkey = h; + RegisterVolumeDownHotkey(); + settingsStore.SaveVolumeDownHotkey(h); + }); + + private void ChangeRemoteVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-up", h => + { + remoteVolumeUpHotkey = h; + RegisterRemoteVolumeUpHotkey(); + settingsStore.SaveRemoteVolumeUpHotkey(h); + }); + + private void ChangeRemoteVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-down", h => + { + remoteVolumeDownHotkey = h; + RegisterRemoteVolumeDownHotkey(); + settingsStore.SaveRemoteVolumeDownHotkey(h); + }); + + private void ChangeRemoteMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-mute-toggle", h => + { + remoteMuteToggleHotkey = h; + RegisterRemoteMuteToggleHotkey(); + settingsStore.SaveRemoteMuteToggleHotkey(h); + }); + + private void ChangeSystemVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-up", h => + { + systemVolumeUpHotkey = h; + RegisterSystemVolumeUpHotkey(); + settingsStore.SaveSystemVolumeUpHotkey(h); + }); + + private void ChangeSystemVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-down", h => + { + systemVolumeDownHotkey = h; + RegisterSystemVolumeDownHotkey(); + settingsStore.SaveSystemVolumeDownHotkey(h); + }); + + private void ChangeSystemMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-mute-toggle", h => + { + systemMuteToggleHotkey = h; + RegisterSystemMuteToggleHotkey(); + settingsStore.SaveSystemMuteToggleHotkey(h); + }); + + // Hotkeys come in two flavours and need different Windows-side registration: + // * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back + // and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire. + // * Step hotkeys (volume up/down, both local-receive and remote-app and remote-system + // variants) — holding the key is the natural way to ramp through a range. Registered + // WITHOUT MOD_NOREPEAT so Windows fires WM_HOTKEY at the user's keyboard auto-repeat + // rate, exactly mirroring how the physical volume keys feel. The remote-control + // packet send-path is light-weight enough that a held key doesn't strain the link; + // the receiver-side COM volume call is hoisted out of the COM-enumeration cost via + // SystemVolumeHelper's cached endpoint reference. + private void RegisterSendMuteHotkey() => RegisterIfSet(sendMuteGlobalHotkey, sendMuteHotkey, "toggle sending"); + private void RegisterReceiveMuteHotkey() => RegisterIfSet(receiveMuteGlobalHotkey, receiveMuteHotkey, "toggle receiving"); + private void RegisterTrayHotkey() => RegisterIfSet(trayGlobalHotkey, trayHotkey, "tray"); + private void RegisterVolumeUpHotkey() => RegisterIfSet(volumeUpGlobalHotkey, volumeUpHotkey, "volume up", allowRepeat: true); + private void RegisterVolumeDownHotkey() => RegisterIfSet(volumeDownGlobalHotkey, volumeDownHotkey, "volume down", allowRepeat: true); + private void RegisterRemoteVolumeUpHotkey() => RegisterIfSet(remoteVolumeUpGlobalHotkey, remoteVolumeUpHotkey, "send remote volume up", allowRepeat: true); + private void RegisterRemoteVolumeDownHotkey() => RegisterIfSet(remoteVolumeDownGlobalHotkey, remoteVolumeDownHotkey, "send remote volume down", allowRepeat: true); + private void RegisterRemoteMuteToggleHotkey() => RegisterIfSet(remoteMuteToggleGlobalHotkey, remoteMuteToggleHotkey, "send remote mute toggle"); + private void RegisterSystemVolumeUpHotkey() => RegisterIfSet(systemVolumeUpGlobalHotkey, systemVolumeUpHotkey, "send Windows global volume up", allowRepeat: true); + private void RegisterSystemVolumeDownHotkey() => RegisterIfSet(systemVolumeDownGlobalHotkey, systemVolumeDownHotkey, "send Windows global volume down", allowRepeat: true); + private void RegisterSystemMuteToggleHotkey() => RegisterIfSet(systemMuteToggleGlobalHotkey, systemMuteToggleHotkey, "send Windows global mute toggle"); + + private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false) + { + if (globalHotkey is null) return; + globalHotkey.Unregister(); + if (hotkey.IsUnset) + { + Log?.Invoke($"register {description}: SKIPPED (unset)"); + return; + } + if (globalHotkey.Register(hotkey, allowRepeat)) + { + Log?.Invoke($"register {description}: OK = {hotkey}"); + } + else + { + // Win32 error 1409 = ERROR_HOTKEY_ALREADY_REGISTERED. Anything else is unusual + // (e.g. invalid VK code, no handle). Logging the raw code lets us distinguish + // "another app/process owns this combo" from genuine registration weirdness. + var err = globalHotkey.LastWin32ErrorOnRegister; + var hint = err switch + { + 1409 => "another app or another RemSound process already registered this combo", + _ => "Win32 error", + }; + Log?.Invoke($"register {description}: FAILED = {hotkey} (Win32 error {err}: {hint})"); + ShowRegisterWarning($"Could not register {description} hotkey {hotkey}. " + (err == 1409 + ? "Another app — or another running copy of RemSound — is already using that combo. The hotkey is saved in your profile, so the binding will take effect once the conflict is resolved." + : $"Windows reported error {err}. The hotkey is saved in your profile but Windows didn't accept the registration.")); + } + } + + private void InvokeOnOwner(Action action) + { + if (owner is null || owner.IsDisposed) return; + owner.BeginInvoke(action); + } + + private void ShowRegisterWarning(string message) + { + // Use the Win32 MessageBox API directly with MB_TOPMOST + MB_SETFOREGROUND so the + // popup is guaranteed to sit above every other window on the desktop, including + // any modal dialog stack RemSound currently has open. The previous WinForms + // MessageBox.Show(parent, …) calls were sometimes hiding behind the still-modal + // Keyboard shortcuts dialog — the user reported "no popup" when in fact the popup + // had been created and then occluded. + // + // MB_SETFOREGROUND on its own is sometimes ignored by Windows under foreground-lock + // rules, but MB_TOPMOST overrides that. Together they're the most reliable way to + // get a hotkey-conflict warning into the user's face at the moment the conflict is + // detected. + var hwnd = (Form.ActiveForm?.Handle) ?? owner?.Handle ?? IntPtr.Zero; + const uint MB_OK = 0x00000000; + const uint MB_ICONWARNING = 0x00000030; + const uint MB_TOPMOST = 0x00040000; + const uint MB_SETFOREGROUND = 0x00010000; + MessageBoxW(hwnd, message, "RemSound — hotkey conflict", MB_OK | MB_ICONWARNING | MB_TOPMOST | MB_SETFOREGROUND); + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType); +} diff --git a/src/RemSound.App/MainFormTrayController.cs b/src/RemSound.App/MainFormTrayController.cs new file mode 100644 index 0000000..f7f670f --- /dev/null +++ b/src/RemSound.App/MainFormTrayController.cs @@ -0,0 +1,44 @@ +namespace RemSound.App; + +internal sealed class MainFormTrayController : IDisposable +{ + private readonly Form owner; + private readonly NotifyIcon trayIcon = new(); + + public MainFormTrayController(Form owner, Action enableSending, Action enableReceiving, Action exit) + { + this.owner = owner; + trayIcon.Text = "RemSound"; + trayIcon.Icon = SystemIcons.Application; + trayIcon.Visible = false; + trayIcon.DoubleClick += (_, _) => Restore(); + var menu = new ContextMenuStrip(); + menu.Items.Add("Show", null, (_, _) => Restore()); + menu.Items.Add("Enable sending", null, (_, _) => enableSending()); + menu.Items.Add("Enable receiving", null, (_, _) => enableReceiving()); + menu.Items.Add("Exit", null, (_, _) => exit()); + trayIcon.ContextMenuStrip = menu; + } + + public void Toggle() + { + if (owner.Visible && owner.WindowState != FormWindowState.Minimized) Minimize(); + else Restore(); + } + + public void Restore() + { + owner.Show(); + owner.WindowState = FormWindowState.Normal; + owner.Activate(); + trayIcon.Visible = false; + } + + public void Minimize() + { + owner.Hide(); + trayIcon.Visible = true; + } + + public void Dispose() => trayIcon.Dispose(); +} diff --git a/src/RemSound.App/ManualPeerPrompt.cs b/src/RemSound.App/ManualPeerPrompt.cs new file mode 100644 index 0000000..c40d3e6 --- /dev/null +++ b/src/RemSound.App/ManualPeerPrompt.cs @@ -0,0 +1,51 @@ +namespace RemSound.App; + +internal static class ManualPeerPrompt +{ + public static string? Show(IWin32Window owner) + { + using var dialog = new Form + { + Text = "Add manual peer", + StartPosition = FormStartPosition.CenterParent, + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false, + ShowInTaskbar = false, + ClientSize = new Size(440, 130), + }; + // No port hint — RemSound now uses a single canonical UDP port (RemPacket.DefaultPort, + // 47830 as of 2026-05-05) for Tailscale, LAN, and relay peers alike. Users just type a + // bare IP or hostname; the port is implied. Advanced users can still suffix `:port` for + // a non-standard server, but it's no longer the common path that needed onboarding. + var textBox = new TextBox + { + Dock = DockStyle.Top, + Width = 380, + AccessibleName = "Peer IP address or hostname", + }; + var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + textBox.KeyDown += (_, args) => + { + if (args.KeyCode == Keys.Enter) + { + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + args.Handled = true; + args.SuppressKeyPress = true; + } + }; + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 3, ColumnCount = 1 }; + panel.Controls.Add(new Label { Text = "Peer IP address or hostname:", AutoSize = true }, 0, 0); + panel.Controls.Add(textBox, 0, 1); + var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill }; + buttons.Controls.Add(okButton); + buttons.Controls.Add(cancelButton); + panel.Controls.Add(buttons, 0, 2); + dialog.Controls.Add(panel); + dialog.AcceptButton = okButton; + dialog.CancelButton = cancelButton; + return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null; + } +} diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs new file mode 100644 index 0000000..521f190 --- /dev/null +++ b/src/RemSound.App/PreferencesDialog.cs @@ -0,0 +1,286 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Preferences dialog. Holds the three settings that used to live on the (now-removed) +/// Profiles and preferences tab and aren't profile-management actions in their own right: +/// * Mute connect/disconnect sounds — the small ding on peer state changes. +/// * Accept remote volume commands from peers — opt-in for the remote-control feature. +/// * Startup behaviour — opens the existing sub-dialog. +/// +/// Both checkboxes save through on every change (so +/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button +/// just opens the existing modal sub-dialog. Esc or the Close button dismisses. +/// +/// Reachable via the File → Preferences menu item or Ctrl+P from the main window. +/// +internal sealed class PreferencesDialog : Form +{ + private readonly Button browseProfilesFolderButton = new() + { + Text = "&Browse for RemSound profiles folder...", + AccessibleName = "Browse for RemSound profiles folder", + AutoSize = true, + }; + + private readonly AccessibleCheckBox muteCuesBox = new() + { + Text = "Mute connect/disconnect sounds (Alt+&M)", + AccessibleName = "Mute connect/disconnect sounds", + AutoSize = true, + }; + + private readonly AccessibleCheckBox acceptRemoteVolumeBox = new() + { + Text = "Accept remote volume commands from peers (Alt+&A)", + AccessibleName = "Accept remote volume commands from peers", + AutoSize = true, + }; + + private readonly Button startupBehaviourButton = new() + { + Text = "Startup behaviour... (Alt+&S)", + AccessibleName = "Startup behaviour", + AutoSize = true, + }; + + // Update settings — frequency dropdown, manual check button, silent-install checkbox. + // Sits above the logging row so users meet it during setup; the canonical order in the + // dialog is "things related to the program staying current" before "things related to + // diagnosing how it's running". + private readonly Label updateFrequencyLabel = new() + { + Text = "Check for updates (Alt+&U):", + AccessibleName = "Check for updates frequency", + AutoSize = true, + }; + + private readonly ComboBox updateFrequencyBox = new() + { + DropDownStyle = ComboBoxStyle.DropDownList, + Width = 200, + AccessibleName = "Check for updates (Alt+U)", + }; + + private readonly Button checkForUpdatesNowButton = new() + { + Text = "Check for updates &now", + AccessibleName = "Check for updates now", + AutoSize = true, + }; + + private readonly AccessibleCheckBox silentlyInstallUpdatesBox = new() + { + Text = "Silently &install updates when available", + AccessibleName = "Silently install updates when available", + AutoSize = true, + }; + + private readonly AccessibleCheckBox loggingBox = new() + { + Text = "Enable &logs", + AccessibleName = "Enable logs", + AutoSize = true, + }; + + private readonly Button writeLogsNowButton = new() + { + Text = "&Write logs now", + AccessibleName = "Write logs now", + AutoSize = true, + }; + + private readonly Button closeButton = new() + { + Text = "Close", + AutoSize = true, + DialogResult = DialogResult.OK, + }; + + /// True if the user toggled Mute cues or Accept remote during this dialog + /// session. The owner uses this to know whether to MarkProfileDirty after the dialog + /// closes (since both settings live on Profile and need to flag a save-pending state). + public bool ChangedAnyProfileSetting { get; private set; } + + public PreferencesDialog( + RemSoundSettingsStore settings, + ProfileStore? profileStore, + Func getLoggingEnabled, + Action applyLoggingEnabled, + Action writeLogsNow, + Action checkForUpdatesNow, + Action onUpdateFrequencyChanged) + { + Text = "Preferences"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + KeyPreview = true; + ClientSize = new Size(560, 440); + + // 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup + // ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory + // and applied on next launch (mid-session reload would force a re-pick of profile + // which is more disruption than the change is worth — users restart RemSound when + // they want to switch folders). + browseProfilesFolderButton.Click += (_, _) => + { + using var picker = new FolderBrowserDialog + { + Description = "Choose a folder for RemSound profiles", + UseDescriptionForTitle = true, + SelectedPath = profileStore?.BaseDirectory ?? AppContext.BaseDirectory, + ShowNewFolderButton = true, + }; + if (picker.ShowDialog(this) != DialogResult.OK) return; + if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return; + var cfg = AppConfig.Load(); + cfg.ProfilesDirectory = picker.SelectedPath; + try + { + cfg.Save(); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save app config: {ex.Message}", + "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + MessageBox.Show(this, + $"Profiles folder updated to:\n\n{picker.SelectedPath}\n\nThe new folder will be used next time RemSound launches.", + "Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information); + }; + + muteCuesBox.Checked = settings.LoadMuteConnectionCues(); + muteCuesBox.CheckedChanged += (_, _) => + { + settings.SaveMuteConnectionCues(muteCuesBox.Checked); + ChangedAnyProfileSetting = true; + }; + + acceptRemoteVolumeBox.Checked = settings.LoadAcceptRemoteVolumeCommands(); + acceptRemoteVolumeBox.CheckedChanged += (_, _) => + { + settings.SaveAcceptRemoteVolumeCommands(acceptRemoteVolumeBox.Checked); + ChangedAnyProfileSetting = true; + }; + + startupBehaviourButton.Click += (_, _) => + { + using var dialog = new StartupBehaviourDialog(profileStore); + dialog.ShowDialog(this); + // Startup behaviour persists through AppConfig + registry directly, so we + // don't need to flag profile-dirty for that. + }; + + // Update settings — wired against AppConfig directly since they're machine-local. + // The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering + // either side stays in lockstep. + updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" }); + var cfgForLoad = AppConfig.Load(); + updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency; + silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates; + updateFrequencyBox.SelectedIndexChanged += (_, _) => + { + var cfg = AppConfig.Load(); + cfg.UpdateCheckFrequency = (UpdateCheckFrequency)updateFrequencyBox.SelectedIndex; + try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ } + onUpdateFrequencyChanged(); + }; + silentlyInstallUpdatesBox.CheckedChanged += (_, _) => + { + var cfg = AppConfig.Load(); + cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked; + try { cfg.Save(); } catch { /* harmless */ } + }; + checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow(); + + loggingBox.Checked = getLoggingEnabled(); + loggingBox.CheckedChanged += (_, _) => + { + applyLoggingEnabled(loggingBox.Checked); + ChangedAnyProfileSetting = true; + }; + + writeLogsNowButton.Click += (_, _) => writeLogsNow(); + + closeButton.Click += (_, _) => Close(); + + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 1, + RowCount = 10, + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + // Tab order top-to-bottom: browse, mute cues, accept remote, startup, update + // frequency, check-now, silent install, enable logs, write logs now, close. Updates + // sit above the log row so a user setting up the app meets them first. + browseProfilesFolderButton.TabIndex = 0; + muteCuesBox.TabIndex = 1; + acceptRemoteVolumeBox.TabIndex = 2; + startupBehaviourButton.TabIndex = 3; + updateFrequencyBox.TabIndex = 4; + checkForUpdatesNowButton.TabIndex = 5; + silentlyInstallUpdatesBox.TabIndex = 6; + loggingBox.TabIndex = 7; + writeLogsNowButton.TabIndex = 8; + closeButton.TabIndex = 9; + + // Group the frequency label + combo on one FlowLayoutPanel row so the visible label + // sits inline next to the combo while keeping the combo as the focusable target. + var freqRow = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Padding = new Padding(0, 4, 0, 0), + }; + updateFrequencyLabel.Padding = new Padding(0, 6, 8, 0); + freqRow.Controls.Add(updateFrequencyLabel); + freqRow.Controls.Add(updateFrequencyBox); + + panel.Controls.Add(browseProfilesFolderButton, 0, 0); + panel.Controls.Add(muteCuesBox, 0, 1); + panel.Controls.Add(acceptRemoteVolumeBox, 0, 2); + panel.Controls.Add(startupBehaviourButton, 0, 3); + panel.Controls.Add(freqRow, 0, 4); + panel.Controls.Add(checkForUpdatesNowButton, 0, 5); + panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6); + panel.Controls.Add(loggingBox, 0, 7); + panel.Controls.Add(writeLogsNowButton, 0, 8); + + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + Padding = new Padding(0, 0, 12, 12), + }; + buttons.Controls.Add(closeButton); + + Controls.Add(panel); + Controls.Add(buttons); + + AcceptButton = closeButton; + CancelButton = closeButton; + + KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + { + Close(); + e.SuppressKeyPress = true; + e.Handled = true; + } + }; + } +} diff --git a/src/RemSound.App/ProfileSaveAsPrompt.cs b/src/RemSound.App/ProfileSaveAsPrompt.cs new file mode 100644 index 0000000..7d380b6 --- /dev/null +++ b/src/RemSound.App/ProfileSaveAsPrompt.cs @@ -0,0 +1,95 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// Tiny single-line modal — "give this profile a name". Used by File → Rename +/// (and historically by File → Save As, before that flow moved to a real Windows +/// SaveFileDialog on 2026-05-10). Returns the trimmed name or null on cancel. +/// +/// Two parameter knobs let the dialog title and prompt label change between use cases: +/// * Rename: title = "Rename profile", prompt = "Please enter a new name for your profile:" +/// * Legacy save-as (close-confirm path): title = "Save profile as", prompt = "Profile name:" +/// and pass non-null so the dialog refuses an existing-name unless +/// the user confirms overwrite. +/// +/// When is null, no overwrite check is performed — the caller is +/// responsible for handling name collisions (rename has its own conflict logic in MainForm). +internal static class ProfileSaveAsPrompt +{ + public static string? Show( + IWin32Window owner, + ProfileStore? store, + string? defaultName = null, + string dialogTitle = "Save profile as", + string promptLabel = "Profile name:") + { + using var dialog = new Form + { + Text = dialogTitle, + StartPosition = FormStartPosition.CenterParent, + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false, + ShowInTaskbar = false, + ClientSize = new Size(420, 140), + }; + var textBox = new TextBox + { + Width = 380, + Text = defaultName ?? "", + AccessibleName = promptLabel.TrimEnd(':', ' '), + }; + var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + textBox.KeyDown += (_, args) => + { + if (args.KeyCode == Keys.Enter) + { + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + args.Handled = true; + args.SuppressKeyPress = true; + } + }; + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + RowCount = 3, + ColumnCount = 1, + }; + panel.Controls.Add(new Label { Text = promptLabel, AutoSize = true }, 0, 0); + panel.Controls.Add(textBox, 0, 1); + var buttons = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.RightToLeft, + Dock = DockStyle.Fill, + }; + buttons.Controls.Add(okButton); + buttons.Controls.Add(cancelButton); + panel.Controls.Add(buttons, 0, 2); + dialog.Controls.Add(panel); + dialog.AcceptButton = okButton; + dialog.CancelButton = cancelButton; + + while (true) + { + if (dialog.ShowDialog(owner) != DialogResult.OK) return null; + var name = textBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show(owner, "Please enter a profile name.", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + continue; + } + if (store is not null && store.Exists(name)) + { + var overwrite = MessageBox.Show(owner, + $"A profile named \"{name}\" already exists. Overwrite?", + "Confirm overwrite", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); + if (overwrite != DialogResult.Yes) continue; + } + return name; + } + } +} diff --git a/src/RemSound.App/ProfileSelectionDialog.cs b/src/RemSound.App/ProfileSelectionDialog.cs new file mode 100644 index 0000000..6cdb670 --- /dev/null +++ b/src/RemSound.App/ProfileSelectionDialog.cs @@ -0,0 +1,253 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Modal dialog shown at app startup to pick which profile to load. Listbox of saved +/// profile titles plus a synthetic "(Blank template)" entry for an unsaved-defaults +/// session. Enter or OK selects; Esc does nothing (deliberately disabled — picking is +/// required); Alt+F4 closes the dialog and exits the app; Del on a profile prompts to +/// delete it with a yes/no confirm. The user can also browse to a custom profiles +/// folder, which persists in remsound.config.json next to the exe. +/// +/// On OK, exposes: +/// * — the chosen title, or null for blank template. +/// * — the loaded , or null for blank. +/// * — the (possibly-rebuilt) profile store. If the user clicked +/// Browse and changed the folder, this points at the new folder; the caller should +/// use this reference rather than the one it passed in. +/// +internal sealed class ProfileSelectionDialog : Form +{ + private const string BlankTemplateLabel = "(Blank template)"; + + private ProfileStore store; + private readonly ListBox listBox; + private readonly Label folderLabel; + + public string? SelectedTitle { get; private set; } + public Profile? SelectedProfile { get; private set; } + /// Current profile store. If the user clicked Browse during the dialog, + /// this is rebuilt to point at the new folder; otherwise it's the same instance the + /// caller passed in. + public ProfileStore Store => store; + + public ProfileSelectionDialog(ProfileStore store) + { + this.store = store; + + Text = "RemSound — pick a profile"; + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = true; + ClientSize = new Size(480, 420); + // Esc is deliberately ignored (no CancelButton). Alt+F4 routes through the + // window manager to FormClosing → DialogResult.Cancel, which the caller treats + // as "user wants to quit". + KeyPreview = true; + + listBox = new ListBox + { + Dock = DockStyle.Fill, + IntegralHeight = false, + AccessibleName = "Profiles", + }; + listBox.KeyDown += OnListKeyDown; + listBox.DoubleClick += (_, _) => Accept(); + + var instructions = new Label + { + Text = "Select a profile and press Enter, or pick \"" + BlankTemplateLabel + "\" to start fresh.", + Dock = DockStyle.Top, + AutoSize = false, + Height = 36, + Padding = new Padding(8, 8, 8, 4), + }; + + // Folder status + Browse button row at the bottom. Browse opens a folder picker; + // on OK we save AppConfig, rebuild the store, and refresh the list. Status label + // shows the active folder so the user can verify where their profiles are coming + // from. NVDA reads the label as a sibling of the listbox. + folderLabel = new Label + { + Text = "Profiles folder: " + store.BaseDirectory, + Dock = DockStyle.Top, + AutoSize = false, + Height = 28, + Padding = new Padding(8, 4, 8, 4), + AccessibleName = "Profiles folder: " + store.BaseDirectory, + }; + + var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.None }; + okButton.Click += (_, _) => Accept(); + var deleteButton = new Button { Text = "&Delete", AutoSize = true }; + deleteButton.Click += (_, _) => DeleteSelected(); + var browseButton = new Button { Text = "&Browse for profiles folder…", AutoSize = true }; + browseButton.Click += (_, _) => BrowseForFolder(); + var resetFolderButton = new Button { Text = "&Reset to default folder", AutoSize = true }; + resetFolderButton.Click += (_, _) => ResetToDefaultFolder(); + var buttonRow = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.LeftToRight, + Height = 80, + AutoSize = false, + Padding = new Padding(8), + WrapContents = true, + }; + buttonRow.Controls.Add(okButton); + buttonRow.Controls.Add(deleteButton); + buttonRow.Controls.Add(browseButton); + buttonRow.Controls.Add(resetFolderButton); + + Controls.Add(listBox); + Controls.Add(buttonRow); + Controls.Add(folderLabel); + Controls.Add(instructions); + + AcceptButton = okButton; // makes Enter work in the form context too + + Load += (_, _) => + { + RefreshList(); + listBox.Focus(); + }; + } + + private void RefreshList() + { + var prevSelected = listBox.SelectedItem as string; + listBox.BeginUpdate(); + listBox.Items.Clear(); + listBox.Items.Add(BlankTemplateLabel); + foreach (var t in store.ListProfileTitles()) + { + listBox.Items.Add(t); + } + // Try to restore selection; fall back to first item. + var idx = prevSelected is null ? 0 : Math.Max(0, listBox.Items.IndexOf(prevSelected)); + listBox.SelectedIndex = Math.Min(idx, listBox.Items.Count - 1); + listBox.EndUpdate(); + // Keep the folder label in sync so it always reflects what the listbox is reading. + folderLabel.Text = "Profiles folder: " + store.BaseDirectory; + folderLabel.AccessibleName = folderLabel.Text; + } + + private void OnListKeyDown(object? sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter) + { + Accept(); + e.Handled = true; + e.SuppressKeyPress = true; + } + else if (e.KeyCode == Keys.Delete) + { + DeleteSelected(); + e.Handled = true; + e.SuppressKeyPress = true; + } + } + + private void Accept() + { + var selected = listBox.SelectedItem as string; + if (string.IsNullOrEmpty(selected)) return; + if (selected == BlankTemplateLabel) + { + SelectedTitle = null; + SelectedProfile = null; + } + else + { + SelectedTitle = selected; + SelectedProfile = store.Load(selected); + if (SelectedProfile is null) + { + MessageBox.Show(this, + $"Could not read profile \"{selected}\". Treating as blank template.", + "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + SelectedTitle = null; + } + } + DialogResult = DialogResult.OK; + Close(); + } + + private void DeleteSelected() + { + var selected = listBox.SelectedItem as string; + if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return; + var result = MessageBox.Show(this, + $"Delete profile \"{selected}\"? This cannot be undone.", + "Confirm delete", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2); + if (result != DialogResult.Yes) return; + if (!store.Delete(selected)) + { + MessageBox.Show(this, + $"Could not delete \"{selected}\".", + "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + RefreshList(); + } + + /// Open a folder picker, persist the choice to AppConfig, and rebuild the + /// profile store + list against the new folder. No-op on cancel. If the new folder + /// has no profiles yet, the listbox simply shows just the blank-template entry; the + /// user can save into the new folder later. + private void BrowseForFolder() + { + using var picker = new FolderBrowserDialog + { + Description = "Choose a folder for RemSound profiles", + UseDescriptionForTitle = true, + SelectedPath = store.BaseDirectory, + ShowNewFolderButton = true, + }; + if (picker.ShowDialog(this) != DialogResult.OK) return; + ApplyFolder(picker.SelectedPath); + } + + private void ResetToDefaultFolder() + { + // Clearing the AppConfig field and reloading swings the store back to the legacy + // default (per-machine subfolder under the exe). Cheap and reversible — user can + // Browse to a custom folder again any time. + var cfg = AppConfig.Load(); + cfg.ProfilesDirectory = null; + try { cfg.Save(); } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save app config: {ex.Message}", + "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + store = cfg.CreateStore(); + RefreshList(); + } + + private void ApplyFolder(string folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath)) return; + var cfg = AppConfig.Load(); + cfg.ProfilesDirectory = folderPath; + try { cfg.Save(); } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save app config: {ex.Message}", + "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + store = cfg.CreateStore(); + RefreshList(); + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // Eat plain Esc — selection is required, no implicit cancel. + if (keyData == Keys.Escape) return true; + return base.ProcessCmdKey(ref msg, keyData); + } +} diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs new file mode 100644 index 0000000..00228f3 --- /dev/null +++ b/src/RemSound.App/Program.cs @@ -0,0 +1,136 @@ +using System.Runtime; +using System.Windows.Forms; +using RemSound.Core; + +namespace RemSound.App; + +internal static class Program +{ + [STAThread] + private static void Main() + { + // SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming. + // Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing + // the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly + // gen 2 sweeps. This trades a bit of memory headroom (the GC will hold on to garbage longer) + // for dramatically more predictable timing — exactly the trade real-time audio wants. + GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; + + ApplicationConfiguration.Initialize(); + + // F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so + // it works on the profile picker (the very first thing the user sees). The filter + // is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free. + HelpLauncher.Install(); + + // Outer loop: lets ProfileManagementDialog change the profiles folder mid-session. + // When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build + // a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile + // (or blank template) from the *new* folder. Inner loop handles the cheaper "switch to + // a profile in the same folder" case. + while (true) + { + var appConfig = AppConfig.Load(); + var store = appConfig.CreateStore(); + + Profile? profile; + string? title; + // Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named + // profile actually exists in the current store, skip the picker entirely and + // load that profile directly. This is what the Startup behaviour dialog's + // "Start with a specific profile" toggle drives. Combined with the Windows + // auto-start registry entry and the StartMinimised flag, it lets the user + // boot a machine and have RemSound up and streaming with no clicks. Falls + // through to the normal picker if the configured profile no longer exists + // (deleted since it was selected, or the profiles folder changed) so the user + // isn't stuck. + Profile? autoLoaded = null; + string? autoLoadedTitle = null; + if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle)) + { + try + { + autoLoaded = store.Load(appConfig.StartWithProfileTitle!); + if (autoLoaded is not null) autoLoadedTitle = appConfig.StartWithProfileTitle; + } + catch { /* fall back to picker */ } + } + + if (autoLoaded is not null) + { + profile = autoLoaded; + title = autoLoadedTitle; + } + else + { + using var dialog = new ProfileSelectionDialog(store); + if (dialog.ShowDialog() != DialogResult.OK) return; + // ProfileSelectionDialog can have changed the folder via its Browse button; + // if so, it's already saved AppConfig and rebuilt its internal store. Pick up + // its post-Browse store reference for the rest of the session. + store = dialog.Store; + profile = dialog.SelectedProfile; + title = dialog.SelectedTitle; + } + + // Switch-profile loop: when the user clicks "Switch to profile" in the Manage + // Profiles dialog, the form sets NextProfileTitleToLoad and closes; we re-open + // MainForm under the newly chosen profile. Null = user closed the form normally + // → exit. ReloadFromScratch = the user changed the profiles FOLDER mid-session, + // so we break out of this inner loop and let the outer loop redo the selection + // dialog under the new folder. + var reloadFromScratch = false; + string? nextPath = null; + while (true) + { + using var form = new MainForm(store, profile, title, nextPath); + Application.Run(form); + + if (form.ReloadFromScratch) + { + reloadFromScratch = true; + break; + } + + // Path-based reload (File → Open profile from a path that may be outside + // the active store's BaseDirectory) takes precedence — read JSON directly + // from that path. Falls back to title-based store.Load when no path is set + // (e.g. legacy switch-by-title flows that pre-date the path tracking). + nextPath = form.NextProfilePathToLoad; + var nextTitle = form.NextProfileTitleToLoad; + if (!string.IsNullOrEmpty(nextPath)) + { + try + { + var json = File.ReadAllText(nextPath); + profile = System.Text.Json.JsonSerializer.Deserialize(json) ?? Profile.NewBlank(); + title = !string.IsNullOrEmpty(nextTitle) + ? nextTitle + : Path.GetFileNameWithoutExtension(nextPath); + } + catch + { + // Malformed / unreadable JSON. Fall back to blank template under + // whatever title we have, rather than crashing the loop. + profile = Profile.NewBlank(); + title = !string.IsNullOrEmpty(nextTitle) + ? nextTitle + : Path.GetFileNameWithoutExtension(nextPath); + nextPath = null; + } + } + else if (!string.IsNullOrEmpty(nextTitle)) + { + title = nextTitle; + profile = store.Load(nextTitle) ?? Profile.NewBlank(); + } + else + { + return; // form closed normally — exit app + } + } + + if (!reloadFromScratch) return; + } + } +} diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj new file mode 100644 index 0000000..ea01edc --- /dev/null +++ b/src/RemSound.App/RemSound.App.csproj @@ -0,0 +1,47 @@ + + + WinExe + net10.0-windows + enable + enable + true + RemSound.App + RemSound + app.manifest + true + SystemAware + + 1.0.0 + + + + + + + + + + + + + connect.wav + PreserveNewest + + + disconnect.wav + PreserveNewest + + + + readme.html + PreserveNewest + + + diff --git a/src/RemSound.App/RemSoundLog.cs b/src/RemSound.App/RemSoundLog.cs new file mode 100644 index 0000000..cbcbc8a --- /dev/null +++ b/src/RemSound.App/RemSoundLog.cs @@ -0,0 +1,184 @@ +namespace RemSound.App; + +/// +/// Lightweight tab-separated log file written next to the executable in logs\. +/// Two row kinds: +/// SNAP — periodic snapshot of runtime counters (one per second) +/// EVT — one-off events (connect, disconnect, codec change, errors) +/// Format: +/// SNAP\t{Timestamp}\t{Machine}\t{Connected}\t{SendRunning}\t{ReceiveRunning}\t{Codec}\t +/// {MaxLatencyMs}\t{TargetLatencyMs}\t{BufferMs}\t{SenderPackets}\t{SenderKB}\t +/// {SenderDevice}\t{ReceiverPackets}\t{ReceiverKB}\t{Underruns}\t{Drops}\t{ReceiveDevice} +/// EVT\t{Timestamp}\t{Message} +/// +/// The log file is created lazily on the first write that arrives while +/// is true. When the user has logs turned off in Preferences (the default), no file is created +/// at all — the App can construct the log object freely without spawning empty files in +/// logs\. Flipping back on mid-session is also safe: the next +/// write creates the file with its header and the session continues normally. +/// +internal sealed class RemSoundLog : IDisposable +{ + // MaxLatencyMsAsio / TargetLatencyMsAsio columns hold the per-lane numbers in + // BothIndependent mode. In WasapiOnly they are 0 and the legacy MaxLatencyMs / + // TargetLatencyMs columns continue to mean "the receiver's only latency". + private const string SnapHeader = + "Kind\tTimestamp\tMachine\tConnected\tSendRunning\tReceiveRunning\tCodec\t" + + "MaxLatencyMs\tTargetLatencyMs\tBufferMs\tSenderPackets\tSenderKB\t" + + "SenderDevice\tReceiverPackets\tReceiverKB\tUnderruns\tDrops\tReceiveDevice\tHeartbeat\t" + + "OpusFecRecoveries\tOpusUnrecoveredGaps\tMaxLatencyMsAsio\tTargetLatencyMsAsio"; + + private StreamWriter? writer; + private bool fileCreationFailed; + /// Serialises all writes. StreamWriter is documented as non-thread-safe and + /// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener, + /// UI thread and ASIO callback can interleave bytes into a single line in the output + /// file. Worse, an interleaved write can leave the StreamWriter's internal char buffer + /// in a state that throws on the next Flush — that exception escapes the inner try/catch + /// and can bring the process down. A single gate around every WriteLine, every Dispose + /// and every lazy-init step serialises writes cleanly; the cost is microseconds and + /// worth the diagnostic integrity. + private readonly object writeGate = new(); + + /// Path of the log file once it has been created. Null until the first write + /// arrives with true (or null forever if logging is never enabled + /// or file creation fails). + public string? Path { get; private set; } + + /// Master gate for all writes. When false, both and + /// short-circuit before touching the file system — no creation, + /// no headers, no data. Defaults to false so the App can construct the log object + /// before reading the user's preference; the App pushes the real value in after. + public bool Enabled { get; set; } + + public RemSoundLog() + { + // No file work in the constructor. EnsureFileOpenLocked does it lazily on first + // write, only when Enabled has been confirmed true. + } + + /// Open the underlying file if it hasn't been opened yet and write the schema + /// header + a "log started" event line. Must be called while holding + /// . Returns true on success or if the file is already open; + /// false if creation has failed (either now or earlier) — caller should give up on the + /// current write. + private bool EnsureFileOpenLocked() + { + if (writer is not null) return true; + if (fileCreationFailed) return false; + try + { + var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs"); + Directory.CreateDirectory(dir); + var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log"; + Path = System.IO.Path.Combine(dir, name); + writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite)) + { + AutoFlush = true, + }; + writer.WriteLine(SnapHeader); + writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started"); + return true; + } + catch + { + // Logging is best-effort. Locked dir, permissions, disk full — any of these + // shouldn't kill the app. Park the file as failed so we don't keep retrying + // creation on every subsequent write attempt. + writer = null; + Path = null; + fileCreationFailed = true; + return false; + } + } + + public void Snapshot( + bool connected, + bool sendRunning, + bool receiveRunning, + string codec, + int maxLatencyMs, + int targetLatencyMs, + int bufferMs, + long senderPackets, + long senderBytes, + string senderDevice, + long receiverPackets, + long receiverBytes, + long underruns, + long drops, + string receiveDevice, + string heartbeat, + long opusFecRecoveries, + long opusUnrecoveredGaps, + int maxLatencyMsAsio = 0, + int targetLatencyMsAsio = 0) + { + if (!Enabled) return; + lock (writeGate) + { + if (!EnsureFileOpenLocked()) return; + try + { + writer!.WriteLine(string.Join('\t', + "SNAP", + DateTime.Now.ToString("o"), + Environment.MachineName, + connected, + sendRunning, + receiveRunning, + codec, + maxLatencyMs, + targetLatencyMs, + bufferMs, + senderPackets, + senderBytes / 1024, + Sanitize(senderDevice), + receiverPackets, + receiverBytes / 1024, + underruns, + drops, + Sanitize(receiveDevice), + Sanitize(heartbeat), + opusFecRecoveries, + opusUnrecoveredGaps, + maxLatencyMsAsio, + targetLatencyMsAsio)); + } + catch { /* swallow — log is best-effort */ } + } + } + + public void Event(string message) + { + if (!Enabled) return; + lock (writeGate) + { + if (!EnsureFileOpenLocked()) return; + try + { + writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}"); + } + catch { /* swallow */ } + } + } + + public void Dispose() + { + lock (writeGate) + { + try + { + if (writer is not null && Enabled) + { + // Inline the "log stopped" write so we don't re-acquire the gate. + writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog stopped"); + } + writer?.Dispose(); + } + catch { /* swallow */ } + } + } + + private static string Sanitize(string value) => value.Replace('\t', ' ').Replace('\n', ' '); +} diff --git a/src/RemSound.App/RemSoundUpdater.cs b/src/RemSound.App/RemSoundUpdater.cs new file mode 100644 index 0000000..2424732 --- /dev/null +++ b/src/RemSound.App/RemSoundUpdater.cs @@ -0,0 +1,264 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Self-updater. Polls the GitHub Releases API for the latest published version, compares it +/// to the running assembly's version, and (optionally) downloads and installs the new build. +/// +/// Update install flow (Windows-only): RemSound.exe can't overwrite itself while it's running, +/// so a successful install does the swap via a detached cmd.exe helper: +/// +/// Download the release ZIP to %TEMP%\RemSound-update-<tag>.zip. +/// Extract to <exe>\_update\. +/// Write a one-shot batch file at <exe>\_apply-update.cmd that waits for +/// RemSound.exe to exit, robocopies the staged folder over the publish folder, deletes +/// the staging area, restarts RemSound.exe, and removes itself. +/// Start the batch with CreateNoWindow + detached, then call +/// . +/// +/// The batch survives RemSound's exit because cmd.exe is its own process. Robocopy's +/// retry/wait flags handle the brief moment between RemSound exit and the file unlock. +/// +/// The GitHub repo to poll is hard-coded — the App was designed to be redistributed from a +/// single canonical release stream, not to be re-pointed at a fork. If you need to publish +/// from a different repo, change / . +/// +internal sealed class RemSoundUpdater : IDisposable +{ + public const string RepoOwner = "Ednunp"; + public const string RepoName = "RemSound"; + + /// Asset name on the GitHub release that the updater downloads. The release + /// publisher's gh release create command must attach exactly this filename for + /// the auto-install path to work; other assets in the release are ignored. The literal + /// "{tag}" placeholder is replaced with the release's tag_name at runtime. + public const string AssetNameTemplate = "RemSound-{tag}.zip"; + + private static readonly HttpClient http = CreateClient(); + + /// Sink for diagnostic lines — the App wires this to logFile.Event so an + /// admin can see what the updater did (which version it saw, whether it downloaded, why + /// an install attempt failed). Updater output never goes to a popup unless the user + /// triggered a manual check. + public Action? Log { get; set; } + + public string CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; + + public void Dispose() + { + // HttpClient is static and shared across the process; nothing to dispose here. + } + + /// Hit the GitHub Releases API, parse the latest release, return a struct + /// describing what was found. Returns null if the request fails (network down, rate + /// limited, repo not found) or if the latest version is not newer than the running + /// assembly. Caller decides whether to surface "you're up to date" vs silently doing + /// nothing — both paths get null back. + public async Task CheckForUpdateAsync(CancellationToken token = default) + { + try + { + var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases/latest"; + Log?.Invoke($"updater: GET {url}"); + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + using var resp = await http.SendAsync(req, token).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub"); + return null; + } + await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + var release = await JsonSerializer.DeserializeAsync(stream, JsonOpts, token).ConfigureAwait(false); + if (release?.TagName is null) + { + Log?.Invoke("updater: response had no tag_name"); + return null; + } + + var latest = ParseTag(release.TagName); + var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); + Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})"); + if (latest <= current) return null; + + var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName); + var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase)); + if (asset?.BrowserDownloadUrl is null) + { + Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'"); + return null; + } + + return new UpdateInfo( + Tag: release.TagName, + Version: latest, + DownloadUrl: asset.BrowserDownloadUrl, + ReleaseNotes: release.Body ?? "", + ReleaseUrl: release.HtmlUrl ?? ""); + } + catch (Exception ex) + { + Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + /// Download the update ZIP, stage it next to RemSound.exe, spawn the detached + /// install helper, and ask the App to exit so the helper can take over. Returns true if + /// the helper was launched (caller should Application.Exit immediately afterwards); + /// false on any failure earlier in the pipeline. A false return leaves the running + /// instance untouched. + public async Task DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default) + { + try + { + var baseDir = AppContext.BaseDirectory; + var stagingDir = Path.Combine(baseDir, "_update"); + var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip"); + var batchPath = Path.Combine(baseDir, "_apply-update.cmd"); + + // Tidy any leftover from a previous failed attempt before we start. + TryDelete(zipPath); + TryDeleteDirectory(stagingDir); + + Log?.Invoke($"updater: downloading {info.DownloadUrl}"); + await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false)) + await using (var dst = File.Create(zipPath)) + { + await src.CopyToAsync(dst, token).ConfigureAwait(false); + } + + Log?.Invoke($"updater: extracting to {stagingDir}"); + Directory.CreateDirectory(stagingDir); + System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, stagingDir, overwriteFiles: true); + + // Some release zips wrap everything in a single top-level folder + // (e.g. "RemSound-v1.1/RemSound.exe"). Flatten if that's the case so the + // robocopy step copies the right tree over the install location. + var stagingRoot = ResolveStagingRoot(stagingDir); + + Log?.Invoke($"updater: writing install helper {batchPath}"); + File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir)); + + var pid = System.Environment.ProcessId; + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c \"\"{batchPath}\" {pid}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = baseDir, + }; + Log?.Invoke($"updater: launching install helper, parent PID {pid}"); + System.Diagnostics.Process.Start(psi); + return true; + } + catch (Exception ex) + { + Log?.Invoke($"updater: install failed: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + /// One-shot installer batch. Waits for the supplied PID to exit (so file locks + /// release), robocopies the staged folder over the install folder, removes the staging + /// area, restarts RemSound.exe, and self-deletes. Robocopy's /R:5 /W:1 flags give + /// the audio threads a few extra seconds to wind down if the exe is slow to release. The + /// helper is detached from RemSound at start time, so it survives the parent's exit. + private static string BuildInstallScript(string stagingRoot, string installDir) => + $""" + @echo off + setlocal + rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting. + set "PID=%~1" + :wait_loop + tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul + if not errorlevel 1 ( + timeout /t 1 /nobreak >nul + goto wait_loop + ) + robocopy "{stagingRoot}" "{installDir}" /E /IS /IT /NFL /NDL /NJH /NJS /R:5 /W:1 /XF _apply-update.cmd >nul + rmdir /S /Q "{Path.Combine(installDir, "_update")}" 2>nul + start "" "{Path.Combine(installDir, "RemSound.exe")}" + del "%~f0" + """; + + /// If the zip extracted to a single subfolder (typical when GitHub zips a tag), + /// return that subfolder so the copy works from the inner level. Otherwise return the + /// staging dir itself. + private static string ResolveStagingRoot(string stagingDir) + { + var subdirs = Directory.GetDirectories(stagingDir); + var files = Directory.GetFiles(stagingDir); + if (files.Length == 0 && subdirs.Length == 1) return subdirs[0]; + return stagingDir; + } + + /// Parses a release tag like v1.2 or 1.2.3 into a . + /// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result + /// always compares meaningfully against .Version. + public static Version ParseTag(string tag) + { + if (string.IsNullOrWhiteSpace(tag)) return new Version(0, 0, 0); + var trimmed = tag.TrimStart('v', 'V').Trim(); + var parts = trimmed.Split('.', '-', '+'); + var nums = new int[3]; + for (var i = 0; i < 3 && i < parts.Length; i++) + { + int.TryParse(parts[i], out nums[i]); + } + return new Version(nums[0], nums[1], nums[2]); + } + + private static HttpClient CreateClient() + { + var c = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20), + }; + // GitHub rejects API requests without a User-Agent. The header doubles as a way for + // their abuse team to contact us if our polling misbehaves at scale. + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("RemSound-Updater", "1.0")); + return c; + } + + private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { /* ignore */ } } + private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* ignore */ } } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + }; + + private sealed class GitHubRelease + { + [JsonPropertyName("tag_name")] public string? TagName { get; set; } + [JsonPropertyName("body")] public string? Body { get; set; } + [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + [JsonPropertyName("assets")] public List? Assets { get; set; } + } + + private sealed class GitHubAsset + { + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; } + } +} + +/// What returns when there's a +/// newer release available. is the raw Markdown body of the +/// release on GitHub — show it directly in a confirmation dialog if the install isn't +/// silent. +internal sealed record UpdateInfo( + string Tag, + Version Version, + string DownloadUrl, + string ReleaseNotes, + string ReleaseUrl); diff --git a/src/RemSound.App/StartupAutoStart.cs b/src/RemSound.App/StartupAutoStart.cs new file mode 100644 index 0000000..9b8cc67 --- /dev/null +++ b/src/RemSound.App/StartupAutoStart.cs @@ -0,0 +1,90 @@ +using Microsoft.Win32; + +namespace RemSound.App; + +/// +/// Wires "run RemSound automatically when this user logs in" via the per-user Run +/// registry key at HKCU\Software\Microsoft\Windows\CurrentVersion\Run. This is the +/// same mechanism many Windows apps use for "launch on login"; the entry shows up in Task +/// Manager's Startup tab so the user can also toggle it off there if they ever want to. +/// +/// Why HKCU\...\Run rather than dropping a .lnk into the Startup folder: +/// - No COM interop / IShellLink wrangling; just RegistryKey.SetValue. +/// - User-scoped (HKCU): no admin elevation needed and only affects this user. +/// - Manageable via Task Manager → Startup, which is where Windows users now expect to +/// find login-launched apps. +/// +/// All methods catch all exceptions and return success bools — flipping the toggle in the +/// Startup behaviour dialog should never throw, even on policy-locked machines. +/// +internal static class StartupAutoStart +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "RemSound"; + + /// True when an entry called "RemSound" exists under the per-user Run key. + /// Reads the registry each call (cheap; single key open + value read). Never throws. + public static bool IsEnabled + { + get + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false); + if (key is null) return false; + var value = key.GetValue(ValueName) as string; + return !string.IsNullOrWhiteSpace(value); + } + catch + { + return false; + } + } + } + + /// Add or update the Run-key entry to point at the currently-running exe. + /// Quotes the path so spaces work. Returns true on success. + public static bool TryEnable() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true) + ?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true); + if (key is null) return false; + var exePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(exePath)) + { + // Fallback: use AppContext.BaseDirectory. .NET hosting produces a + // different process path for self-contained vs framework-dependent + // publish, but BaseDirectory is reliable. + exePath = System.IO.Path.Combine(AppContext.BaseDirectory, "RemSound.exe"); + } + // Wrap in double-quotes so a path containing spaces (e.g. C:\Program Files\) + // parses correctly when Windows launches it. + key.SetValue(ValueName, $"\"{exePath}\""); + return true; + } + catch + { + return false; + } + } + + /// Remove the Run-key entry. Returns true if the entry is gone afterwards + /// (whether we deleted it or it never existed). Returns false only on registry + /// access errors. + public static bool TryDisable() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true); + if (key is null) return true; // No Run subkey at all → nothing to disable. + key.DeleteValue(ValueName, throwOnMissingValue: false); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/RemSound.App/StartupBehaviourDialog.cs b/src/RemSound.App/StartupBehaviourDialog.cs new file mode 100644 index 0000000..59dec3e --- /dev/null +++ b/src/RemSound.App/StartupBehaviourDialog.cs @@ -0,0 +1,278 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Modal dialog for the three "what should RemSound do at launch" toggles: +/// * Start minimised — main window goes to the tray immediately after Show. +/// * Start RemSound automatically with this user — wires HKCU\...\Run. +/// * Start with a specific profile — skips the startup picker and loads the chosen +/// profile directly. Companion listbox of saved profiles appears alongside. +/// +/// The dialog persists changes through (StartMinimised / +/// StartWithProfileTitle) and through the Windows registry (the auto-start checkbox). +/// Each change is committed immediately, no OK/Apply button — same per-tick-saves +/// pattern as the rest of the Profiles and preferences tab. +/// +/// Keyboard shape (per Ed's spec): +/// * Tab cycles: minimised checkbox → auto-start checkbox → specific-profile +/// checkbox → profiles list (when shown) → Close button. +/// * Esc or the Close button closes. +/// * Each checkbox has an Alt+letter mnemonic. +/// +internal sealed class StartupBehaviourDialog : Form +{ + private readonly AccessibleCheckBox startMinimisedBox = new() + { + Text = "Start minimised to tray (Alt+&M)", + AccessibleName = "Start minimised to tray", + AutoSize = true, + }; + private readonly AccessibleCheckBox startWithUserBox = new() + { + Text = "Start RemSound automatically when this user logs in (Alt+&A)", + AccessibleName = "Start RemSound automatically when this user logs in", + AutoSize = true, + }; + private readonly AccessibleCheckBox startWithProfileBox = new() + { + Text = "Start with a specific profile (Alt+&P)", + AccessibleName = "Start with a specific profile", + AutoSize = true, + }; + private readonly Label profileListLabel = new() + { + Text = "Profile to start with (Alt+&L):", + AutoSize = true, + AccessibleName = "Profile to start with", + }; + private readonly ListBox profileList = new() + { + IntegralHeight = false, + Width = 360, + Height = 140, + AccessibleName = "Profile to start with", + }; + private readonly Button closeButton = new() + { + Text = "Close", + AutoSize = true, + DialogResult = DialogResult.OK, + AccessibleName = "Close", + }; + + public StartupBehaviourDialog(ProfileStore? profileStore) + { + Text = "Startup behaviour"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + KeyPreview = true; // form-level Esc handler + ClientSize = new Size(540, 360); + + // === Layout === + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 1, + RowCount = 6, // 0 intro, 1 minimise, 2 auto-start, 3 specific-profile, 4 list (with label), 5 close + }; + root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + for (var i = 0; i < 5; i++) root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var intro = new Label + { + Text = "These options control what RemSound does when it launches. They persist across sessions and affect every launch (whether started by the user or by Windows on login).", + AutoSize = true, + MaximumSize = new Size(500, 0), + Anchor = AnchorStyles.Left, + }; + root.Controls.Add(intro, 0, 0); + + // Each checkbox lives on its own row, plus the profile list row when relevant. + root.Controls.Add(startMinimisedBox, 0, 1); + root.Controls.Add(startWithUserBox, 0, 2); + root.Controls.Add(startWithProfileBox, 0, 3); + + // Profile list: label + list stacked, hidden until startWithProfileBox is ticked. + // FlowLayoutPanel keeps them tidy and the whole sub-block can be toggled visible + // as a unit. + var listSubPanel = new FlowLayoutPanel + { + FlowDirection = FlowDirection.TopDown, + AutoSize = true, + WrapContents = false, + Padding = new Padding(20, 0, 0, 0), // indent slightly so it visually belongs to the checkbox above + }; + listSubPanel.Controls.Add(profileListLabel); + listSubPanel.Controls.Add(profileList); + root.Controls.Add(listSubPanel, 0, 4); + + var closePanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + Padding = new Padding(0, 8, 0, 0), + }; + closePanel.Controls.Add(closeButton); + root.Controls.Add(closePanel, 0, 5); + + Controls.Add(root); + + // === Tab order === + startMinimisedBox.TabIndex = 0; + startWithUserBox.TabIndex = 1; + startWithProfileBox.TabIndex = 2; + profileList.TabIndex = 3; + closeButton.TabIndex = 4; + + // === Initial state === + var cfg = AppConfig.Load(); + startMinimisedBox.Checked = cfg.StartMinimised; + startWithUserBox.Checked = StartupAutoStart.IsEnabled; + var hasProfile = !string.IsNullOrWhiteSpace(cfg.StartWithProfileTitle); + startWithProfileBox.Checked = hasProfile; + + // Populate profile list and select the saved choice if any. + if (profileStore is not null) + { + foreach (var title in profileStore.ListProfileTitles()) + { + profileList.Items.Add(title); + } + } + if (hasProfile && cfg.StartWithProfileTitle is { } savedTitle) + { + var idx = profileList.Items.IndexOf(savedTitle); + if (idx >= 0) profileList.SelectedIndex = idx; + } + + UpdateProfileListVisibility(); + + // === Wiring === + startMinimisedBox.CheckedChanged += (_, _) => + { + var c = AppConfig.Load(); + c.StartMinimised = startMinimisedBox.Checked; + try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); } + }; + + startWithUserBox.CheckedChanged += (_, _) => + { + // Source of truth for the auto-start state is the registry — we don't keep a + // duplicate in AppConfig. So this just flips the registry entry directly. + var ok = startWithUserBox.Checked + ? StartupAutoStart.TryEnable() + : StartupAutoStart.TryDisable(); + if (!ok) + { + MessageBox.Show(this, + "RemSound could not change the auto-start setting in the Windows registry. The setting did not change. (This usually means a policy or another security tool is blocking it.)", + "Auto-start change failed", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + // Re-read truth and reflect it without re-firing this handler. + var actual = StartupAutoStart.IsEnabled; + if (startWithUserBox.Checked != actual) + { + // Temporarily detach the handler to avoid a recursive call. + var savedChecked = actual; + startWithUserBox.CheckedChanged -= AutoStartReentryGuard; + startWithUserBox.Checked = savedChecked; + startWithUserBox.CheckedChanged += AutoStartReentryGuard; + } + } + }; + // Empty handler used as a target-for-removal in the re-entry-guard path above. + // Kept so the +=/-= pair is symmetrical even though it does nothing on its own. + void AutoStartReentryGuard(object? _, EventArgs __) { } + + startWithProfileBox.CheckedChanged += (_, _) => + { + UpdateProfileListVisibility(); + if (startWithProfileBox.Checked) + { + if (profileList.Items.Count == 0) + { + MessageBox.Show(this, + "You don't have any saved profiles yet. Save a profile first (Profiles and preferences tab → Save profile as), then come back here and pick it.", + "No saved profiles", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + // Untick without re-firing. + startWithProfileBox.Checked = false; + return; + } + if (profileList.SelectedIndex < 0) profileList.SelectedIndex = 0; + CommitProfileSelection(); + } + else + { + ClearProfileSelection(); + } + }; + + profileList.SelectedIndexChanged += (_, _) => + { + if (!startWithProfileBox.Checked) return; + if (profileList.SelectedIndex < 0) return; + CommitProfileSelection(); + }; + profileList.DoubleClick += (_, _) => + { + // Same effect as picking a row + closing — convenient for mouse users. + if (startWithProfileBox.Checked && profileList.SelectedIndex >= 0) + { + CommitProfileSelection(); + } + DialogResult = DialogResult.OK; + Close(); + }; + + KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Escape) + { + DialogResult = DialogResult.Cancel; + Close(); + e.SuppressKeyPress = true; + e.Handled = true; + } + }; + AcceptButton = closeButton; + CancelButton = closeButton; + Load += (_, _) => startMinimisedBox.Focus(); + } + + private void UpdateProfileListVisibility() + { + var visible = startWithProfileBox.Checked; + profileListLabel.Visible = visible; + profileList.Visible = visible; + } + + private void CommitProfileSelection() + { + if (profileList.SelectedItem is not string title || string.IsNullOrWhiteSpace(title)) return; + var c = AppConfig.Load(); + c.StartWithProfileTitle = title; + try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); } + } + + private void ClearProfileSelection() + { + var c = AppConfig.Load(); + c.StartWithProfileTitle = null; + try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); } + } + + private void ShowSaveWarning(string message) + { + MessageBox.Show(this, message, "Startup behaviour", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } +} diff --git a/src/RemSound.App/SystemVolumeHelper.cs b/src/RemSound.App/SystemVolumeHelper.cs new file mode 100644 index 0000000..7625f02 --- /dev/null +++ b/src/RemSound.App/SystemVolumeHelper.cs @@ -0,0 +1,110 @@ +using NAudio.CoreAudioApi; + +namespace RemSound.App; + +/// +/// Thin wrapper around NAudio's + +/// to drive the Windows master volume on the system's default render device — the same +/// device the system-tray volume slider and the keyboard's volume keys control. +/// +/// Why default-render-device specifically (and not e.g. the WASAPI outputs RemSound is currently +/// playing through): in Ed's primary use case the listener machine is using ASIO for RemSound +/// playback, but the Windows default output device is what NVDA + browsers + everything else +/// runs through, and that's the volume Ed wants to nudge. Targeting the default device matches +/// what the user already mentally maps "the system volume" to. ASIO devices don't expose a +/// MasterVolumeLevelScalar via this CoreAudio surface anyway — they have hardware gain — so the +/// "what about ASIO?" question doesn't apply here. +/// +/// 2026-05-11 — switched to a cached enumerator/device/endpoint-volume trio (previously each call +/// created and disposed a fresh set). Reason: the system-volume hotkeys now allow Windows-side +/// auto-repeat on hold (see ), which can fire +/// the receiver-side handler at ~30 Hz. Each fresh enumeration is multiple COM calls into the +/// Windows audio service; doing that 30 times a second was correlated with receive-side audio +/// glitches in testing logs. Caching collapses every steady-state call to one VolumeStepUp/Down +/// on the cached endpoint-volume. The cache is invalidated on any COM exception so a device +/// hot-swap or audio-service restart self-heals on the next call. +/// +internal static class SystemVolumeHelper +{ + private static readonly object cacheLock = new(); + private static MMDeviceEnumerator? cachedEnumerator; + private static MMDevice? cachedDevice; + + /// Bumps the default render device's master volume by Windows' native step + /// (typically ~2% — same as one keyboard-volume-up press). Returns true on success, + /// false if the device couldn't be enumerated (catches all exceptions to keep a remote + /// hotkey press from ever throwing). + public static bool TryStepUp() => TryDo(v => v.VolumeStepUp()); + + /// Mirror of in the down direction. + public static bool TryStepDown() => TryDo(v => v.VolumeStepDown()); + + /// Toggles the default render device's master mute. Reads the current state, + /// flips it, writes it back. Returns true on success. + public static bool TryToggleMute() => TryDo(v => v.Mute = !v.Mute); + + /// Reads the current default-render-device master volume scalar (0.0..1.0) and + /// mute state, for diagnostic logging. Returns null on any failure. Uses the same cached + /// endpoint as the step/mute calls. + public static (float scalar, bool mute)? TryReadState() + { + lock (cacheLock) + { + try + { + var device = GetOrCreateDeviceLocked(); + if (device is null) return null; + return (device.AudioEndpointVolume.MasterVolumeLevelScalar, device.AudioEndpointVolume.Mute); + } + catch + { + InvalidateCacheLocked(); + return null; + } + } + } + + private static bool TryDo(Action action) + { + lock (cacheLock) + { + try + { + var device = GetOrCreateDeviceLocked(); + if (device is null) return false; + // Multimedia role matches the system tray slider's idea of "default device" on a + // typical setup. (Console role is for system sounds; the user's default playback + // is normally configured the same for both. Multimedia is the right default for + // "audio I'm listening to".) + action(device.AudioEndpointVolume); + return true; + } + catch + { + // Possible failure modes: default device changed, audio service restarted, + // device disconnected, COM marshalling glitch. Drop the cache so the next call + // re-enumerates fresh; the user just sees a missed tick rather than a thrown + // exception or a stuck-stale endpoint. + InvalidateCacheLocked(); + return false; + } + } + } + + private static MMDevice? GetOrCreateDeviceLocked() + { + if (cachedDevice is not null) return cachedDevice; + cachedEnumerator ??= new MMDeviceEnumerator(); + cachedDevice = cachedEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + return cachedDevice; + } + + private static void InvalidateCacheLocked() + { + try { cachedDevice?.Dispose(); } catch { /* ignore */ } + cachedDevice = null; + // Keep the enumerator across invalidations — the enumerator itself doesn't go stale + // when the default device changes, only the device handle does. Cheaper to keep one + // enumerator alive for the app's lifetime than to re-create it on every device hop. + } +} diff --git a/src/RemSound.App/app.manifest b/src/RemSound.App/app.manifest new file mode 100644 index 0000000..3af87e5 --- /dev/null +++ b/src/RemSound.App/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs new file mode 100644 index 0000000..3ed3f07 --- /dev/null +++ b/src/RemSound.Core/AppConfig.cs @@ -0,0 +1,132 @@ +using System.Text.Json; + +namespace RemSound.Core; + +/// How often the self-updater polls GitHub Releases for a newer build. Values are +/// stable: don't reorder; deserialisation reads the underlying int from remsound.config.json. +public enum UpdateCheckFrequency +{ + Never = 0, + EveryHour = 1, + Every6Hours = 2, + Every24Hours = 3, +} + +/// +/// App-level configuration that lives next to the exe as remsound.config.json. +/// Distinct from : profiles are user-chosen sets of audio / +/// connectivity / device settings; the app config is the *meta* layer that holds +/// preferences that should be sticky regardless of which profile is loaded. Profiles are +/// per-setup; this file is per-installation. +/// +/// What lives here: +/// * — where the profile JSONs are read from. +/// +/// (Pre-2026-05-11 also held BothModeWarningSuppressed — the "do not show me again" +/// tick on the WASAPI+ASIO latency popup. The popup was retired along with the audio-mode +/// listbox; old config JSONs that still contain the key just have it ignored.) +/// +/// Persisted location: <exe>\remsound.config.json. If the file is missing or +/// malformed, defaults are used and the app behaves exactly as it did pre-2026-05-05 +/// (per-machine subfolder under the exe). The file is only written when the user +/// explicitly changes a setting. +/// +public sealed class AppConfig +{ + /// Filesystem path to the directory the app should read profiles from. When + /// null, RemSound uses the legacy default: <exe>\profiles\<machine>\. + /// When set to an explicit folder, that folder IS the profiles folder — no per-machine + /// subfolder is appended (the user picked it, they meant it; that also lets a user point + /// at a Dropbox folder shared between machines). + public string? ProfilesDirectory { get; set; } + + /// True if the user has ticked "do not show me this message again" on the + /// confirmation popup that fires when Save (Ctrl+S / File → Save) successfully + /// overwrites the currently-loaded profile. Lives here (not in Profile) so the + /// preference sticks across profile switches — once you've decided you don't need + /// the "Profile saved" nag, you don't expect it to come back when you load a + /// different profile. The Save-As path doesn't use this flag: the Save-As dialog + /// itself is the user-visible confirmation, so a follow-up popup is redundant. + public bool SaveProfileConfirmationSuppressed { get; set; } + + /// If true, RemSound minimises to the system tray immediately after the main + /// window finishes loading. Lets the user "boot up the machine and have RemSound + /// already running quietly". Default false. + public bool StartMinimised { get; set; } + + /// If true, RemSound writes a tab-separated diagnostic log to + /// <exe>\logs\. Lives here (not in ) because logging + /// is a debugging affordance for the installation, not a user-facing audio preference — + /// switching profiles shouldn't accidentally re-enable a flood of writes the user had + /// turned off, and a one-machine "yes log everything" decision shouldn't have to ride + /// along on every saved profile. Default false: no log file is created until the user + /// ticks Enable logs in the Preferences dialog. + public bool LoggingEnabled { get; set; } + + /// If non-null and a profile with this title exists, RemSound skips the + /// startup profile picker and loads this profile directly. Combine with + /// + the Windows auto-start registry entry + /// (see StartupAutoStart) to get a fully unattended boot-into-streaming flow. + /// To re-show the picker temporarily, untick "Start with a specific profile" in the + /// Startup behaviour dialog. Null = always show the picker (legacy behaviour). + public string? StartWithProfileTitle { get; set; } + + /// How often RemSound polls the GitHub Releases API for a newer build. Default + /// . Set to + /// to disable background checks entirely (the user can still trigger a manual check via + /// the Preferences button or the Help menu). + public UpdateCheckFrequency UpdateCheckFrequency { get; set; } = UpdateCheckFrequency.Every24Hours; + + /// If true, RemSound downloads and applies a new release without prompting: + /// the running instance writes the new files to a staging folder, spawns a small + /// detached helper that waits for the exe to exit, swaps in the new files, and restarts + /// RemSound. Default false — the user gets a confirmation dialog before each install. + public bool SilentlyInstallUpdates { get; set; } + + /// UTC timestamp of the last successful update check. Used by the background + /// update timer to space out polls across launches — if you set the frequency to + /// "every 24 hours" and re-launch the app three times that day, it still hits the API + /// only once. Null on a fresh install. + public DateTime? LastUpdateCheckUtc { get; set; } + + private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json"); + + /// Reads the app config from disk. Always returns a non-null instance — a missing + /// or malformed file becomes a defaults-only AppConfig rather than throwing. + public static AppConfig Load() + { + try + { + if (!File.Exists(ConfigPath)) return new AppConfig(); + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json) ?? new AppConfig(); + } + catch + { + // Corrupt config file shouldn't keep RemSound from launching. Fall back to + // defaults; the user can re-pick a folder via the dialog and we'll overwrite + // the bad file on the next save. + return new AppConfig(); + } + } + + /// Writes this config to disk. Throws on filesystem failures (caller should + /// surface a MessageBox — failure to persist a directory choice is user-visible). + public void Save() + { + var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(ConfigPath, json); + } + + /// Convenience: build the appropriate for the + /// current config. Falls back to the default store (per-machine subfolder) if the + /// configured folder is missing, blank, or doesn't exist on disk. + public ProfileStore CreateStore() + { + if (!string.IsNullOrWhiteSpace(ProfilesDirectory) && Directory.Exists(ProfilesDirectory)) + { + return new ProfileStore(ProfilesDirectory); + } + return new ProfileStore(); + } +} diff --git a/src/RemSound.Core/AudioFormatInfo.cs b/src/RemSound.Core/AudioFormatInfo.cs new file mode 100644 index 0000000..ef722a4 --- /dev/null +++ b/src/RemSound.Core/AudioFormatInfo.cs @@ -0,0 +1,37 @@ +namespace RemSound.Core; + +/// +/// Audio format announcement carried in every Format packet. was added +/// 2026-05-11 alongside the BothIndependent audio mode — see for +/// the semantics. The field is wire-backward-compatible: old receivers parse the first 32 +/// bytes of the format payload and ignore the extra; new receivers reading a 32-byte +/// payload from an old sender default Lane to . +/// +public sealed record AudioFormatInfo( + int SampleRate, + int Channels, + int BitsPerSample, + int Encoding, + int BlockAlign, + int AverageBytesPerSecond, + int Codec = (int)AudioTransportCodec.Pcm, + int FrameDurationMilliseconds = 10, + RenderRoute Lane = RenderRoute.Mixed) +{ + public override string ToString() + { + var encodingName = Encoding switch + { + 1 => "PCM", + 3 => "IEEE float", + _ => $"encoding {Encoding}" + }; + var codecName = (AudioTransportCodec)Codec switch + { + AudioTransportCodec.Opus => $" over Opus ({FrameDurationMilliseconds} ms)", + _ => "" + }; + var laneName = Lane == RenderRoute.Mixed ? "" : $" [{Lane}]"; + return $"{SampleRate} Hz, {Channels} channel(s), {BitsPerSample}-bit {encodingName}{codecName}{laneName}"; + } +} diff --git a/src/RemSound.Core/AudioMode.cs b/src/RemSound.Core/AudioMode.cs new file mode 100644 index 0000000..68275b8 --- /dev/null +++ b/src/RemSound.Core/AudioMode.cs @@ -0,0 +1,29 @@ +namespace RemSound.Core; + +/// +/// Selects which audio backends RemSound runs. Two values are produced by the UI today: +/// +/// WasapiOnly — MixingEngine ⇄ AudioSender direct, MultiOutputPlayout reads +/// PlayoutEngine direct. No ASIO code path runs at all. Used when the user has the +/// ASIO driver picker set to "(none)" or no ASIO drivers are installed. +/// BothIndependent — WASAPI and ASIO both active, but each runs in its own +/// end-to-end pipeline at its own native latency. The sender emits two UDP streams +/// in parallel: a WASAPI lane carrying WASAPI-captured audio (tagged +/// ) and an ASIO lane carrying ASIO audio +/// (). The receiver routes each lane to the +/// matching render backend with no cross-backend mix. Used when the user has picked +/// a real ASIO driver in the picker. +/// +/// AsioOnly and Both are legacy values kept for back-compat with code paths +/// that take an as input. No UI path produces them any more, and the +/// composite backends coerce them into WasapiOnly or BothIndependent on receipt. +/// Old profile JSONs that still contain "AudioModeRaw" simply have the key ignored +/// (the field was removed from in the 2026-05-11 cleanup). +/// +public enum AudioMode +{ + WasapiOnly = 0, + AsioOnly = 1, // Legacy, no UI path produces this any more. + Both = 2, // Legacy classic-Both (tee). No UI path produces this any more. + BothIndependent = 3, +} diff --git a/src/RemSound.Core/AudioRingBuffer.cs b/src/RemSound.Core/AudioRingBuffer.cs new file mode 100644 index 0000000..3945b9e --- /dev/null +++ b/src/RemSound.Core/AudioRingBuffer.cs @@ -0,0 +1,160 @@ +namespace RemSound.Core; + +/// +/// Single-producer / single-consumer byte ring buffer for an audio pipeline. Used on both the +/// receive side (network → playout) and the send side (composite mixing across capture backends). +/// Producer thread calls ; consumer thread calls +/// or . +/// +/// Design choices for predictability: +/// * Power-of-two capacity for cheap mod via mask. +/// * No locks; head/tail are written by exactly one thread each. Reads of the other side use Volatile.Read +/// to get the latest published value. +/// * On overflow the oldest data is dropped, not silently retained — the playout target is the source of truth. +/// * On underrun the consumer gets silence and an underrun count is incremented. +/// +public sealed class AudioRingBuffer +{ + private readonly byte[] storage; + private readonly int mask; + // head is advanced by the consumer (Read); tail is advanced by the producer (Write). + private int head; + private int tail; + private long underruns; + private long drops; + + public AudioRingBuffer(int capacityBytes) + { + // Round up to next power of two. + var capacity = 1; + while (capacity < Math.Max(64, capacityBytes)) capacity <<= 1; + storage = new byte[capacity]; + mask = capacity - 1; + } + + public int CapacityBytes => storage.Length; + + public int BufferedBytes => (Volatile.Read(ref tail) - Volatile.Read(ref head)) & 0x7FFFFFFF; + + public long UnderrunCount => Interlocked.Read(ref underruns); + + public long DropCount => Interlocked.Read(ref drops); + + public void Reset() + { + Volatile.Write(ref head, 0); + Volatile.Write(ref tail, 0); + } + + /// Producer side. Writes the entire span; if the buffer is full, drops the oldest bytes to make room. + public void Write(ReadOnlySpan source) + { + var currentTail = tail; + var currentHead = Volatile.Read(ref head); + var available = storage.Length - ((currentTail - currentHead) & 0x7FFFFFFF); + + if (source.Length > available) + { + // Drop oldest to make room. Advance head by the deficit. + var deficit = source.Length - available; + Volatile.Write(ref head, (currentHead + deficit) & 0x7FFFFFFF); + Interlocked.Add(ref drops, deficit); + } + + var writeIndex = currentTail & mask; + var firstChunk = Math.Min(source.Length, storage.Length - writeIndex); + source[..firstChunk].CopyTo(storage.AsSpan(writeIndex)); + if (firstChunk < source.Length) + { + source[firstChunk..].CopyTo(storage.AsSpan(0)); + } + + Volatile.Write(ref tail, (currentTail + source.Length) & 0x7FFFFFFF); + } + + /// + /// Consumer-side: discard the oldest bytes (or the whole + /// buffered amount if smaller). Used when the user lowers the delay knob, to bring the + /// buffer down to the new target instantly instead of waiting for adaptive rate to drain it. + /// Must be called only from the consumer thread (advances head, which the SPSC + /// invariant treats as consumer-owned). + /// + public void DropOldest(int bytesToDrop) + { + if (bytesToDrop <= 0) return; + var currentHead = head; + var currentTail = Volatile.Read(ref tail); + var available = (currentTail - currentHead) & 0x7FFFFFFF; + var actual = Math.Min(bytesToDrop, available); + if (actual <= 0) return; + Volatile.Write(ref head, (currentHead + actual) & 0x7FFFFFFF); + Interlocked.Add(ref drops, actual); + } + + /// + /// Producer-side trim. If the buffer currently holds more than , + /// advances head to discard the oldest excess. Returns the number of bytes dropped. Same + /// semantics as the overflow-drop path inside : producer can advance + /// head, accepting a rare race against the consumer's own head advance — the alternative + /// (an unbounded queue while no consumer exists) is worse. + /// + /// Used by to soft-cap the playout queue when + /// audio piles up faster than it's being consumed (e.g. a delay between "receive on" and + /// "output device selected" — the listener should hear live audio when render starts, not + /// the multi-second backlog that arrived during the gap). + /// + public int TrimFromProducer(int targetBytes) + { + var currentHead = Volatile.Read(ref head); + var currentTail = Volatile.Read(ref tail); + var available = (currentTail - currentHead) & 0x7FFFFFFF; + if (available <= targetBytes) return 0; + var excess = available - targetBytes; + Volatile.Write(ref head, (currentHead + excess) & 0x7FFFFFFF); + Interlocked.Add(ref drops, excess); + return excess; + } + + /// + /// Float-typed convenience over . Returns the count of floats + /// that came from the buffer (silence-fill is included in destination but not counted here). + /// Use this from the playout read path on the render thread. + /// + public int ReadFloats(Span destination) + { + var bytes = System.Runtime.InteropServices.MemoryMarshal.AsBytes(destination); + var bytesRead = Read(bytes); + return bytesRead / sizeof(float); + } + + /// Consumer side. Reads up to destination.Length bytes. Any shortfall is filled with silence (zero). + /// Returns the number of bytes that came from the buffer (the silence-fill is included in the destination + /// but is reflected in the underrun counter, not the return value). + public int Read(Span destination) + { + var currentHead = head; + var currentTail = Volatile.Read(ref tail); + var available = (currentTail - currentHead) & 0x7FFFFFFF; + var toRead = Math.Min(destination.Length, available); + + if (toRead > 0) + { + var readIndex = currentHead & mask; + var firstChunk = Math.Min(toRead, storage.Length - readIndex); + storage.AsSpan(readIndex, firstChunk).CopyTo(destination); + if (firstChunk < toRead) + { + storage.AsSpan(0, toRead - firstChunk).CopyTo(destination[firstChunk..]); + } + Volatile.Write(ref head, (currentHead + toRead) & 0x7FFFFFFF); + } + + if (toRead < destination.Length) + { + destination[toRead..].Clear(); + Interlocked.Increment(ref underruns); + } + + return toRead; + } +} diff --git a/src/RemSound.Core/AudioTransportCodec.cs b/src/RemSound.Core/AudioTransportCodec.cs new file mode 100644 index 0000000..0fc3de5 --- /dev/null +++ b/src/RemSound.Core/AudioTransportCodec.cs @@ -0,0 +1,7 @@ +namespace RemSound.Core; + +public enum AudioTransportCodec +{ + Pcm = 1, + Opus = 2 +} diff --git a/src/RemSound.Core/CaptureSourceSpec.cs b/src/RemSound.Core/CaptureSourceSpec.cs new file mode 100644 index 0000000..d4e3325 --- /dev/null +++ b/src/RemSound.Core/CaptureSourceSpec.cs @@ -0,0 +1,38 @@ +namespace RemSound.Core; + +/// +/// Whether a capture source pulls audio via WASAPI loopback (rendering side of an output device, +/// e.g. system audio / a soundcard's playback) or via direct WASAPI capture (microphones, +/// line-ins, USB capture inputs). +/// +public enum CaptureKind +{ + Loopback, + Input, +} + +/// +/// Identifies one source the sender should mix into the outgoing stream. is +/// purely for diagnostic logging; is either a WASAPI MMDevice ID or a +/// synthetic ASIO id of the form "asio:<channel-pair-index>". +/// +public sealed record CaptureSourceSpec(string DeviceId, CaptureKind Kind, string Name); + +/// +/// Helpers for the synthetic ASIO device-id format used by both sender and receiver backends. +/// Each ASIO channel pair (stereo) is identified by its zero-based pair index — pair 0 is ASIO +/// channels 0+1, pair 1 is 2+3, etc. The driver itself isn't encoded in the id; only one ASIO +/// driver is active per session and it's configured separately. +/// +public static class AsioDeviceId +{ + public static string Format(int channelPair) => $"asio:{channelPair}"; + + public static bool TryParse(string deviceId, out int channelPair) + { + channelPair = -1; + if (string.IsNullOrEmpty(deviceId)) return false; + if (!deviceId.StartsWith("asio:", StringComparison.OrdinalIgnoreCase)) return false; + return int.TryParse(deviceId.AsSpan("asio:".Length), out channelPair) && channelPair >= 0; + } +} diff --git a/src/RemSound.Core/ConcealmentArtifact.cs b/src/RemSound.Core/ConcealmentArtifact.cs new file mode 100644 index 0000000..cd372e9 --- /dev/null +++ b/src/RemSound.Core/ConcealmentArtifact.cs @@ -0,0 +1,38 @@ +namespace RemSound.Core; + +/// +/// What kind of audio the receiver synthesises across an underrun gap. The receiver-side +/// playout buffer can come up empty for a few frames if the network is late or if the local +/// audio thread woke up faster than packets arrived; the original behaviour was a hard zero +/// (audible click). 2026-05-04 introduced a brief cosine fade so the *edges* of the gap are +/// smooth — but a 32-frame cosine creates a spectral peak near 750 Hz, which sounds like a +/// brief F#-ish tone every time it fires. For dense networks this can be a perceptible +/// pattern. The user picks the artifact character here. +/// +/// Receiver-side only. The sender has no idea its packets came up late at the listener; each +/// listening machine decides locally what its own underruns sound like. Stored per-profile. +/// +public enum ConcealmentArtifact +{ + /// Legacy: 32-frame cosine fade-out + fade-in. ~750 Hz spectral peak — brief tone + /// close to F#5. Was the default 2026-05-04 to 2026-05-06; removed from the dropdown after + /// user feedback that it sounded harsh on orchestral content. Kept in the enum so old + /// profile JSONs still parse; the dialog coerces it to NoiseBurst on load. + CosineToneShort = 0, + + /// Legacy: 96-frame cosine fade. ~250 Hz spectral peak — softer thump than the + /// short variant. Removed from the dropdown 2026-05-06 same as CosineToneShort. Kept for + /// back-compat with old profile JSONs. + CosineToneLow = 1, + + /// 32-frame burst of white noise enveloped at the last sample's amplitude. + /// Energy is broadband (no audible pitch); sounds like a brief shhh and tends to blend + /// into music more than a tone does. The current default since 2026-05-06. + NoiseBurst = 2, + + /// No concealment. Hard zero-fill across the gap (the pre-2026-05-04 behaviour). + /// You'll hear the raw click at the amplitude transition — useful for direct + /// comparison with the smoothed options, or if the click somehow bothers you less than + /// any of the synthesised artifacts. + Click = 3, +} diff --git a/src/RemSound.Core/DiagnosticsGate.cs b/src/RemSound.Core/DiagnosticsGate.cs new file mode 100644 index 0000000..4d406cb --- /dev/null +++ b/src/RemSound.Core/DiagnosticsGate.cs @@ -0,0 +1,43 @@ +namespace RemSound.Core; + +/// +/// Single shared on/off switch for the engine's diagnostic instrumentation. The App sets +/// at startup from AppConfig.LoggingEnabled and re-sets it +/// whenever the user toggles the Enable logs checkbox in Preferences. Every probe +/// site in RemSound.Sender and RemSound.Receiver reads this flag as its very +/// first action and bails before doing any measurement, CAS update or per-sample arithmetic +/// when it is false. +/// +/// What's behind this gate: +/// +/// Sender-side max-time probes — SenderLane.OnMixedSamples emit timing, +/// AudioSender.SendToAll kernel-send timing, capture-callback gap timers in +/// AsioCaptureBackend and MixingEngine. +/// Receiver-side max-time probes — NetworkListener dispatch timing, +/// ReceiverDiagnostics arrival-gap and render-callback-gap recording. +/// The per-sample envelope-spike detector +/// (ReceiverDiagnostics.RecordOutputSampleSteps), which iterates every output +/// sample doing second-derivative arithmetic and is the most expensive probe. +/// +/// +/// What's not behind this gate: the running counters that feed the always-visible +/// status footer (packets sent, packets received, bytes, underruns, drops). Those are cheap +/// Interlocked.Add calls and the UI needs them whether logs are on or off. +/// +/// Flag is plain volatile: the audio path reads it on every callback; lock-free reads +/// are essential, and the only writer is the UI thread on a checkbox-toggle (effectively +/// once per session). The gate flips on or off cleanly without any inflight write needing to +/// see the new value mid-probe. +/// +public static class DiagnosticsGate +{ + private static volatile bool enabled; + + /// True when the engine should run its diagnostic instrumentation. Set by the + /// App at startup and on every toggle of the Enable-logs checkbox. + public static bool Enabled + { + get => enabled; + set => enabled = value; + } +} diff --git a/src/RemSound.Core/GlobalHotkey.cs b/src/RemSound.Core/GlobalHotkey.cs new file mode 100644 index 0000000..9c90904 --- /dev/null +++ b/src/RemSound.Core/GlobalHotkey.cs @@ -0,0 +1,73 @@ +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace RemSound.Core; + +public sealed class GlobalHotkey : NativeWindow, IDisposable +{ + private const int WmHotkey = 0x0312; + private const uint ModAlt = 0x0001; + private const uint ModControl = 0x0002; + private const uint ModShift = 0x0004; + private const uint ModNoRepeat = 0x4000; + private static int nextId = 0x5253; + private readonly int id = Interlocked.Increment(ref nextId); + private bool registered; + + public event Action? Pressed; + + public GlobalHotkey(Form owner) => AssignHandle(owner.Handle); + + /// Register the global hotkey. controls whether + /// holding the key down fires repeatedly at the OS keyboard + /// auto-repeat rate. Default false = Windows' MOD_NOREPEAT flag is set, so each + /// physical press fires exactly once (the right semantic for toggle hotkeys — mute, + /// tray show/hide — where re-firing on hold would flip state back and forth). Pass + /// true for step hotkeys where holding the key is meant to ramp a value + /// (volume up/down, both local and remote/system variants). + public bool Register(HotkeyInfo hotkey, bool allowRepeat = false) + { + Unregister(); + uint modifiers = allowRepeat ? 0 : ModNoRepeat; + if (hotkey.Control) modifiers |= ModControl; + if (hotkey.Shift) modifiers |= ModShift; + if (hotkey.Alt) modifiers |= ModAlt; + registered = RegisterHotKey(Handle, id, modifiers, (uint)hotkey.Key); + LastWin32ErrorOnRegister = registered ? 0 : Marshal.GetLastWin32Error(); + return registered; + } + + /// The Win32 GetLastError value captured immediately after the most recent + /// failed call. 0 when the last register call succeeded. Useful + /// for distinguishing "another app already owns this combo" (1409 ERROR_HOTKEY_ALREADY_REGISTERED) + /// from other failure modes. + public int LastWin32ErrorOnRegister { get; private set; } + + public void Unregister() + { + if (!registered) return; + UnregisterHotKey(Handle, id); + registered = false; + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WmHotkey && m.WParam.ToInt32() == id) + { + Pressed?.Invoke(); + } + base.WndProc(ref m); + } + + public void Dispose() + { + Unregister(); + ReleaseHandle(); + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); +} diff --git a/src/RemSound.Core/HeartbeatService.cs b/src/RemSound.Core/HeartbeatService.cs new file mode 100644 index 0000000..e12e7f0 --- /dev/null +++ b/src/RemSound.Core/HeartbeatService.cs @@ -0,0 +1,344 @@ +using System.Diagnostics; +using System.Net; + +namespace RemSound.Core; + +/// +/// Bidirectional UDP heartbeat: every selected peer is pinged once per second; pongs are +/// echoed back; the sender computes RTT against its own monotonic clock and tracks per-peer +/// reachability state. +/// +/// SINGLE-PORT MODEL (2026-05-06): +/// This service no longer binds a UDP socket of its own. All heartbeat traffic flows on +/// the audio port (default 47830) — outbound via the audio sender's UDP socket (which is +/// the same NAT pinhole the audio packets use), inbound via the audio receiver's listener +/// (LAN: peer pings our audio port directly) or the audio sender's recv-side (WAN/relay: +/// pings come back through the relay on our sender's ephemeral source port). The App +/// forwards heartbeat packets from both sources into . +/// +/// Why we collapsed audioPort+2 into the audio port: +/// * The +2 socket only existed because the audio receiver used to be bound on demand +/// (driven by the user's "Receive audio" tick), and heartbeats need a socket that's +/// bound regardless. Splitting from +/// removed that gap — the listener +/// socket is bound for the duration of a connection. +/// * Asymmetric send-only / receive-only configs broke heartbeat under the old dual- +/// transport scheme (relay path drops the ping when the peer's audio port has no +/// listener). With the listener always bound and the heartbeat travelling on the +/// same port, the asymmetry disappears. +/// * One firewall rule, one router pinhole, one mental model. +/// +/// Why 1 Hz cadence instead of the more common 20–25 s NAT-keepalive interval: +/// - Tiny packets (21 B), so 21 B/s is irrelevant overhead. +/// - Detects unreachability within ~3–5 s instead of 30+ s. +/// - 1 s ≪ NAT timeout (30 s+ on virtually all consumer routers), so keepalive role is +/// covered too. +/// +/// RTT computation borrows the RTCP DLSR pattern (RFC 3550) in simplified form: the originator +/// stamps the Ping with its own Stopwatch.ElapsedMilliseconds; the responder echoes that value +/// verbatim in the Pong; the originator computes now - pongPayload.originatorTickMs +/// using only its own clock. No peer-clock sync needed. +/// +public sealed class HeartbeatService : IDisposable +{ + /// How often a Ping is sent to each tracked peer. + public static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(1); + /// If the most recent Pong is younger than this, the peer is healthy. + public static readonly TimeSpan HealthyWindow = TimeSpan.FromSeconds(2); + /// If the most recent Pong is older than this, the peer is unreachable. + public static readonly TimeSpan UnreachableWindow = TimeSpan.FromSeconds(5); + + private readonly Action? onDiagnostic; + private readonly object gate = new(); + private readonly Dictionary peers = new(StringComparer.OrdinalIgnoreCase); + private readonly Stopwatch monotonic = Stopwatch.StartNew(); + + private CancellationTokenSource? cts; + private Task? sendTask; + private uint sequence; + + /// + /// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but + /// no pings are emitted. Wire it to + /// (or any equivalent UDP send delegate) so heartbeats share the audio sender's NAT + /// pinhole. The bool return is the success indicator (true = sent, false = transport + /// error / socket not bound). Pong replies route through the same transport. + /// + public Func? SendTransport { get; set; } + + public HeartbeatService(Action? onDiagnostic = null) + { + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => sendTask is not null; + + public void Start() + { + lock (gate) + { + if (IsRunning) return; + cts = new CancellationTokenSource(); + sendTask = Task.Run(() => SendLoop(cts.Token)); + onDiagnostic?.Invoke("started (single-port)"); + } + } + + public void Stop() + { + lock (gate) StopInternal(); + } + + private void StopInternal() + { + try { cts?.Cancel(); } catch { /* ignore */ } + try { sendTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ } + cts?.Dispose(); + cts = null; + sendTask = null; + } + + public void Dispose() => Stop(); + + /// + /// Replaces the tracked peer set. Each endpoint is the peer's audio port — heartbeat + /// targets the same port (single-port model). Removing a peer wipes its tracked state + /// immediately; adding a new one starts in the Unknown state until the first Pong arrives. + /// + public void SetTrackedPeers(IEnumerable audioEndpoints) + { + lock (gate) + { + var desired = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var ep in audioEndpoints) + { + desired[KeyFor(ep)] = ep; + } + + // Remove peers that are no longer selected. + foreach (var key in peers.Keys.Where(k => !desired.ContainsKey(k)).ToList()) + { + peers.Remove(key); + } + + // Add or update peers. + foreach (var (key, ep) in desired) + { + if (!peers.TryGetValue(key, out var p)) + { + peers[key] = new PeerState { AudioEndpoint = ep }; + } + else + { + p.AudioEndpoint = ep; + } + } + } + } + + /// + /// Snapshot of the current health state of every tracked peer. Safe to call from any thread. + /// + public IReadOnlyList GetAllPeerHealth() + { + lock (gate) + { + var nowUtc = DateTime.UtcNow; + var result = new List(peers.Count); + foreach (var p in peers.Values) + { + result.Add(SnapshotHealthLocked(p, nowUtc)); + } + return result; + } + } + + /// + /// One-line summary suitable for the snapshot log column or status label. + /// "no peers" / "192.168.1.5: 24ms" / "192.168.1.5: 24ms, 192.168.1.6: unreachable 7s". + /// + public string GetHealthSummary() + { + var entries = GetAllPeerHealth(); + if (entries.Count == 0) return "no peers"; + return string.Join(", ", entries.Select(FormatPeer)); + + static string FormatPeer(PeerHealth p) => p.State switch + { + PeerHealthState.Healthy when p.RttMs is { } rtt => $"{p.AudioEndpoint.Address}: {rtt}ms", + PeerHealthState.Stale when p.AgeOfLastPong is { } age => $"{p.AudioEndpoint.Address}: stale {age.TotalSeconds:0.0}s", + PeerHealthState.Unreachable when p.AgeOfLastPong is { } age => $"{p.AudioEndpoint.Address}: unreachable {age.TotalSeconds:0.0}s", + _ => $"{p.AudioEndpoint.Address}: pending", + }; + } + + private static string KeyFor(IPEndPoint ep) => $"{ep.Address}:{ep.Port}"; + + private PeerHealth SnapshotHealthLocked(PeerState p, DateTime nowUtc) + { + if (p.LastPongUtc is null) + { + // Never heard from. If we've been pinging for a while with no response, that's + // "unreachable"; otherwise still "unknown / pending". + if (p.FirstPingSentUtc is { } firstPing && nowUtc - firstPing > UnreachableWindow) + { + return new PeerHealth(p.AudioEndpoint, PeerHealthState.Unreachable, null, nowUtc - firstPing); + } + return new PeerHealth(p.AudioEndpoint, PeerHealthState.Unknown, null, null); + } + + var age = nowUtc - p.LastPongUtc.Value; + var state = age <= HealthyWindow + ? PeerHealthState.Healthy + : (age <= UnreachableWindow ? PeerHealthState.Stale : PeerHealthState.Unreachable); + return new PeerHealth(p.AudioEndpoint, state, p.RttEwmaMs, age); + } + + private async Task SendLoop(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(PingInterval, ct).ConfigureAwait(false); + SendPings(); + } + } + catch (OperationCanceledException) { /* expected on shutdown */ } + catch (Exception ex) + { + onDiagnostic?.Invoke($"send loop ended: {ex.GetType().Name}: {ex.Message}"); + } + } + + private void SendPings() + { + var transport = SendTransport; + if (transport is null) return; + + List targets; + lock (gate) + { + targets = peers.Values.ToList(); + var nowUtc = DateTime.UtcNow; + foreach (var p in targets) p.FirstPingSentUtc ??= nowUtc; + } + + // Build packet. streamId is fixed at 0xFFFF for heartbeats so it's distinguishable + // in any future stream-aware filter; sequence increments locally per send. + Span packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize]; + var seq = Interlocked.Increment(ref sequence); + var tickMs = monotonic.ElapsedMilliseconds; + RemPacket.WriteHeader(packet, RemPacketType.Heartbeat, 0xFFFF, seq); + RemPacket.WriteHeartbeatPayload(packet[RemPacket.HeaderSize..], HeartbeatKind.Ping, tickMs); + var bytes = packet.ToArray(); + + foreach (var p in targets) + { + try + { + var ok = transport(bytes, bytes.Length, p.AudioEndpoint); + onDiagnostic?.Invoke($"send seq={seq} to={p.AudioEndpoint} {(ok ? "ok" : "FAILED")}"); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"send to {p.AudioEndpoint} failed: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + /// + /// Inject a heartbeat packet that arrived on one of the App's other sockets (the audio + /// receiver's listener for LAN, or the audio sender's recv-side for relay-return). This + /// is the ONLY inbound path in single-port mode — the service no longer binds a socket + /// of its own. Same processing as the old local-socket receive: parse, echo Pongs back + /// via , update RTT state on Pong arrival. + /// + public void HandleInjectedPacket(byte[] buffer, int length, IPEndPoint remote) + { + // Tighten the buffer to `length` so HandlePacket's spans don't read trailing bytes. + if (length < buffer.Length) + { + var trimmed = new byte[length]; + Array.Copy(buffer, trimmed, length); + HandlePacket(trimmed, remote); + } + else + { + HandlePacket(buffer, remote); + } + } + + private void HandlePacket(byte[] buffer, IPEndPoint remote) + { + if (!RemPacket.TryReadHeader(buffer, out var type, out _, out _)) return; + if (type != RemPacketType.Heartbeat) return; + var payload = buffer.AsSpan(RemPacket.HeaderSize); + if (!RemPacket.TryReadHeartbeat(payload, out var kind, out var originatorTickMs)) return; + + if (kind == HeartbeatKind.Ping) + { + onDiagnostic?.Invoke($"recv ping from={remote}"); + + // Echo the originator's timestamp back to them as a Pong. Reply target is the + // remote source endpoint (whatever socket the ping came in on, that's where to + // send the pong) — this works for both LAN-direct (peer's audio port) and + // relay-return (relay's source port) without us needing to know which. + Span reply = stackalloc byte[RemPacket.HeaderSize + RemPacket.HeartbeatPayloadSize]; + var seq = Interlocked.Increment(ref sequence); + RemPacket.WriteHeader(reply, RemPacketType.Heartbeat, 0xFFFF, seq); + RemPacket.WriteHeartbeatPayload(reply[RemPacket.HeaderSize..], HeartbeatKind.Pong, originatorTickMs); + var bytes = reply.ToArray(); + + try { SendTransport?.Invoke(bytes, bytes.Length, remote); } + catch { /* UDP, ignore */ } + return; + } + + // Pong: compute RTT vs our own clock, update peer state. We expect this peer to be + // tracked (we sent a ping that produced this pong) — but we match by IP only since + // the source port of an incoming pong is the peer's outbound source port (NAT can + // rewrite, and on LAN it's the peer's ephemeral sender port, not the audio port). + var nowMs = monotonic.ElapsedMilliseconds; + var rttMs = (int)Math.Max(0, nowMs - originatorTickMs); + var nowUtc = DateTime.UtcNow; + var matchedCount = 0; + lock (gate) + { + foreach (var p in peers.Values) + { + if (!p.AudioEndpoint.Address.Equals(remote.Address)) continue; + p.LastRttMs = rttMs; + p.RttEwmaMs = p.RttEwmaMs is null ? rttMs : (int)(p.RttEwmaMs.Value * 0.7 + rttMs * 0.3); + p.LastPongUtc = nowUtc; + matchedCount++; + } + } + // Diagnostic for the Pong path. matched=0 means we got a pong from an IP we don't + // track (suspicious — possible loopback / echo), >0 is the normal case. + onDiagnostic?.Invoke($"recv pong from={remote} rtt={rttMs}ms matched={matchedCount} origTickMs={originatorTickMs} nowMs={nowMs}"); + } + + private sealed class PeerState + { + public IPEndPoint AudioEndpoint { get; set; } = null!; + public DateTime? FirstPingSentUtc { get; set; } + public DateTime? LastPongUtc { get; set; } + public int? LastRttMs { get; set; } + public int? RttEwmaMs { get; set; } + } +} + +public enum PeerHealthState +{ + Unknown, + Healthy, + Stale, + Unreachable, +} + +public sealed record PeerHealth( + IPEndPoint AudioEndpoint, + PeerHealthState State, + int? RttMs, + TimeSpan? AgeOfLastPong); diff --git a/src/RemSound.Core/HotkeyCaptureForm.cs b/src/RemSound.Core/HotkeyCaptureForm.cs new file mode 100644 index 0000000..7697016 --- /dev/null +++ b/src/RemSound.Core/HotkeyCaptureForm.cs @@ -0,0 +1,140 @@ +using System.Windows.Forms; + +namespace RemSound.Core; + +public sealed class HotkeyCaptureForm : Form +{ + private readonly Label instructionLabel = new() { AutoSize = true }; + private readonly TextBox hotkeyTextBox = new() { ReadOnly = true, Width = 360 }; + private readonly Button cancelButton = new() { Text = "Cancel", AutoSize = true }; + private HotkeyInfo? pendingHotkey; + private bool capturingCombination; + + public HotkeyCaptureForm() + { + Text = "Change hotkey"; + Width = 420; + Height = 180; + KeyPreview = true; + AccessibleName = "Change hotkey"; + + instructionLabel.Text = "Hold the full key combination, then release it to save it automatically."; + instructionLabel.MaximumSize = new Size(360, 0); + hotkeyTextBox.AccessibleName = "Current hotkey"; + hotkeyTextBox.Text = "Press a hotkey combination."; + hotkeyTextBox.KeyDown += CaptureKeyDown; + hotkeyTextBox.KeyUp += CaptureKeyUp; + + cancelButton.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; + + var panel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + Padding = new Padding(12), + AutoSize = true, + }; + panel.Controls.Add(instructionLabel); + panel.Controls.Add(hotkeyTextBox); + panel.Controls.Add(cancelButton); + Controls.Add(panel); + + Shown += (_, _) => hotkeyTextBox.Focus(); + } + + public HotkeyInfo? CapturedHotkey { get; private set; } + + /// True if the user pressed a modifier key (Ctrl / Shift / Alt) at any point + /// during this capture session. Used in conjunction with + /// to detect "user tried to bind a combo but the non-modifier key was swallowed by a + /// low-level keyboard hook" — see . + public bool SawAnyModifier { get; private set; } + + /// True if the user pressed any non-Escape, non-modifier key during this + /// capture session. If is true but this is false when the + /// dialog closes without an OK result, we observed the modifier keys but never the + /// final key the user was trying to bind — strong indicator that another app + /// (NVDA / NVDA Remote / AutoHotkey / etc.) is intercepting the combination at a + /// low-level keyboard hook before our window sees it. The caller can use that to + /// show a clear "your combo is being hooked elsewhere" message. + public bool SawAnyNonModifier { get; private set; } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (msg.Msg is 0x0100 or 0x0104) { CaptureKeyData(keyData); return true; } + if (msg.Msg is 0x0101 or 0x0105) { HandleKeyRelease(); return true; } + return base.ProcessCmdKey(ref msg, keyData); + } + + private void CaptureKeyDown(object? sender, KeyEventArgs e) + { + CaptureKeyData(e.KeyData); + e.SuppressKeyPress = true; + e.Handled = true; + } + + private void CaptureKeyUp(object? sender, KeyEventArgs e) + { + HandleKeyRelease(); + e.SuppressKeyPress = true; + e.Handled = true; + } + + private void CaptureKeyData(Keys keyData) + { + var key = keyData & Keys.KeyCode; + var control = keyData.HasFlag(Keys.Control); + var shift = keyData.HasFlag(Keys.Shift); + var alt = keyData.HasFlag(Keys.Alt); + + if (key == Keys.Escape) { DialogResult = DialogResult.Cancel; Close(); return; } + + if (IsModifier(key)) + { + SawAnyModifier = true; + capturingCombination = true; + hotkeyTextBox.Text = BuildModifierPrompt(control, alt, shift); + return; + } + + // Anything that passed the Escape + IsModifier filters is a "real" non-modifier key. + // Tracking this lets the caller distinguish "user pressed Esc immediately" from + // "user held modifiers but the non-modifier key was eaten by a low-level hook". + SawAnyNonModifier = true; + + var proposed = new HotkeyInfo(key, control, shift, alt); + if (!proposed.IsValid) + { + hotkeyTextBox.Text = "Hotkey must include a modifier and a non-modifier key."; + return; + } + + capturingCombination = true; + pendingHotkey = proposed; + hotkeyTextBox.Text = proposed.ToString(); + } + + private void HandleKeyRelease() + { + if (!capturingCombination || pendingHotkey is null) return; + CapturedHotkey = pendingHotkey; + pendingHotkey = null; + capturingCombination = false; + BeginInvoke(() => { DialogResult = DialogResult.OK; Close(); }); + } + + private static bool IsModifier(Keys key) => + key is Keys.ControlKey or Keys.ShiftKey or Keys.Menu + or Keys.LControlKey or Keys.RControlKey + or Keys.LShiftKey or Keys.RShiftKey + or Keys.LMenu or Keys.RMenu; + + private static string BuildModifierPrompt(bool control, bool alt, bool shift) + { + var parts = new List(3); + if (control) parts.Add("Control"); + if (alt) parts.Add("Alt"); + if (shift) parts.Add("Shift"); + return parts.Count == 0 ? "Press a full key combination." : string.Join("+", parts) + "+..."; + } +} diff --git a/src/RemSound.Core/HotkeyInfo.cs b/src/RemSound.Core/HotkeyInfo.cs new file mode 100644 index 0000000..cec2e81 --- /dev/null +++ b/src/RemSound.Core/HotkeyInfo.cs @@ -0,0 +1,30 @@ +using System.Windows.Forms; + +namespace RemSound.Core; + +public sealed record HotkeyInfo(Keys Key, bool Control, bool Shift, bool Alt) +{ + public static HotkeyInfo Default { get; } = new(Keys.M, true, true, true); + + /// Sentinel for "no hotkey assigned". Used by features that want a global hotkey + /// to be opt-in rather than always-on (volume up/down, etc.). The hotkey controller skips + /// registration silently when a hotkey is unset. + public static HotkeyInfo Unset { get; } = new(Keys.None, false, false, false); + + public bool IsUnset => Key == Keys.None && !Control && !Shift && !Alt; + + public bool IsValid => + (Control || Shift || Alt) && + Key is not Keys.None and not Keys.ControlKey and not Keys.ShiftKey and not Keys.Menu; + + public override string ToString() + { + if (IsUnset) return "(not set)"; + var parts = new List(4); + if (Control) parts.Add("Control"); + if (Shift) parts.Add("Shift"); + if (Alt) parts.Add("Alt"); + parts.Add(Key.ToString()); + return string.Join("+", parts); + } +} diff --git a/src/RemSound.Core/PcmPack.cs b/src/RemSound.Core/PcmPack.cs new file mode 100644 index 0000000..5bf66d6 --- /dev/null +++ b/src/RemSound.Core/PcmPack.cs @@ -0,0 +1,51 @@ +namespace RemSound.Core; + +/// +/// Float32 ↔ packed signed 24-bit little-endian conversions. The 24-bit format is what we put on the wire +/// (3 bytes per sample, no padding) — same quality as float32 in the audible range, 25% less bandwidth. +/// +public static class PcmPack +{ + /// + /// Pack a span of float samples (range −1..+1) into signed 24-bit little-endian PCM. + /// Destination must be at least source.Length * 3 bytes. + /// + public static void FloatToInt24LE(ReadOnlySpan source, Span destination) + { + if (destination.Length < source.Length * 3) + { + throw new ArgumentException("Destination too small", nameof(destination)); + } + + for (int i = 0, j = 0; i < source.Length; i++, j += 3) + { + var clamped = Math.Clamp(source[i], -1f, 1f); + // Signed-symmetric: scale by 2^23 - 1 then truncate. Round-to-nearest avoided here on purpose + // because the audio path is already band-limited; the extra ULP is inaudible and the cost matters. + var sample = (int)(clamped * 8388607f); + destination[j] = (byte)(sample & 0xFF); + destination[j + 1] = (byte)((sample >> 8) & 0xFF); + destination[j + 2] = (byte)((sample >> 16) & 0xFF); + } + } + + /// + /// Unpack signed 24-bit little-endian PCM into floats in [−1, +1]. + /// + public static void Int24LEToFloat(ReadOnlySpan source, Span destination) + { + var sampleCount = source.Length / 3; + if (destination.Length < sampleCount) + { + throw new ArgumentException("Destination too small", nameof(destination)); + } + + for (int i = 0, j = 0; i < sampleCount; i++, j += 3) + { + // Sign-extend by shifting left to bit 31 then arithmetic right back. + int packed = (source[j]) | (source[j + 1] << 8) | (source[j + 2] << 16); + int signed = (packed << 8) >> 8; + destination[i] = signed / 8388607f; + } + } +} diff --git a/src/RemSound.Core/PeerAnnouncement.cs b/src/RemSound.Core/PeerAnnouncement.cs new file mode 100644 index 0000000..251f04a --- /dev/null +++ b/src/RemSound.Core/PeerAnnouncement.cs @@ -0,0 +1,15 @@ +using System.Net; + +namespace RemSound.Core; + +public sealed record PeerAnnouncement( + Guid InstanceId, + string Name, + int AudioPort, + bool CanSend, + bool CanReceive, + DateTime LastSeenUtc, + IPAddress Address) +{ + public string DisplayName => $"{Name} at {Address}"; +} diff --git a/src/RemSound.Core/PeerDiscoveryService.cs b/src/RemSound.Core/PeerDiscoveryService.cs new file mode 100644 index 0000000..b6b2f7c --- /dev/null +++ b/src/RemSound.Core/PeerDiscoveryService.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace RemSound.Core; + +/// +/// UDP peer discovery. Each running instance announces itself on +/// every 1.5 s. Peers expire after 8 s of silence. +/// +/// Announcements go out two ways: +/// • Broadcast on every connected LAN subnet (for same-network discovery — instant on +/// home/office wifi). +/// • Unicast to a configurable list of "known" IPs (for VPN/Tailscale/WAN discovery — +/// broadcast doesn't traverse VPN tunnels, so we explicitly send announcements to +/// remembered/manual peer addresses). The App keeps this list in sync via +/// . +/// +public sealed class PeerDiscoveryService : IDisposable +{ + public const int DefaultDiscoveryPort = 47821; + + private readonly Guid instanceId = Guid.NewGuid(); + private readonly object gate = new(); + private readonly Dictionary peers = []; + private CancellationTokenSource? cts; + private UdpClient? listener; + private UdpClient? announcer; + private Task? listenTask; + private Task? announceTask; + private int audioPort = RemPacket.DefaultPort; + private bool canSend; + private bool canReceive; + private bool announceEnabled = true; + // Snapshot of "send announcements directly to these IPs each tick" — typically the user's + // remembered + manually-typed peer IPs. Replaced atomically; the announce loop reads the + // reference once per tick. Volatile-write semantics via the assignment under the gate are + // sufficient because we only ever swap the reference, never mutate in place. + private IReadOnlyList unicastTargets = []; + + public event Action? PeersChanged; + + public IReadOnlyList Peers + { + get + { + lock (gate) + { + PruneExpiredPeers(); + return peers.Values.OrderBy(p => p.Name).ThenBy(p => p.Address.ToString()).ToList(); + } + } + } + + public void Start(int selectedAudioPort, bool sendEnabled, bool receiveEnabled) + { + Stop(); + audioPort = selectedAudioPort; + canSend = sendEnabled; + canReceive = receiveEnabled; + announceEnabled = true; + cts = new CancellationTokenSource(); + + listener = new UdpClient(AddressFamily.InterNetwork); + listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + listener.EnableBroadcast = true; + listener.Client.Bind(new IPEndPoint(IPAddress.Any, DefaultDiscoveryPort)); + + announcer = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true }; + + listenTask = Task.Run(() => ListenLoop(cts.Token)); + announceTask = Task.Run(() => AnnounceLoop(cts.Token)); + } + + public void UpdateCapabilities(int selectedAudioPort, bool sendEnabled, bool receiveEnabled) + { + audioPort = selectedAudioPort; + canSend = sendEnabled; + canReceive = receiveEnabled; + SendAnnouncement(); + } + + public void SetAnnounceEnabled(bool enabled) + { + announceEnabled = enabled; + if (enabled) SendAnnouncement(); + } + + /// + /// Updates the list of IP addresses that announcements should be unicast to in addition to + /// LAN broadcast. The App calls this whenever its remembered+manual-peers set changes; the + /// loop reads the latest snapshot on each tick. + /// + /// Why unicast at all: broadcast doesn't traverse VPNs (Tailscale, WireGuard, ZeroTier). + /// To be discoverable over a VPN we have to explicitly announce to each known IP. Sending + /// to a remembered peer that happens to be offline is harmless — UDP is fire-and-forget. + /// + public void SetUnicastPeerAddresses(IEnumerable addresses) + { + unicastTargets = addresses.Distinct().ToList(); + SendAnnouncement(); + } + + public void Stop() + { + cts?.Cancel(); + listener?.Dispose(); + announcer?.Dispose(); + listener = null; + announcer = null; + cts?.Dispose(); + cts = null; + } + + public void Dispose() => Stop(); + + private async Task ListenLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + var result = await listener!.ReceiveAsync(token).ConfigureAwait(false); + var json = Encoding.UTF8.GetString(result.Buffer); + var message = JsonSerializer.Deserialize(json); + if (message is null || message.InstanceId == instanceId) continue; + + var peer = new PeerAnnouncement( + message.InstanceId, + string.IsNullOrWhiteSpace(message.Name) ? result.RemoteEndPoint.Address.ToString() : message.Name.Trim(), + message.AudioPort, + message.CanSend, + message.CanReceive, + DateTime.UtcNow, + result.RemoteEndPoint.Address); + + // Auto-add the source IP to our unicast targets so subsequent announcements go + // back the way they came. This is what makes discovery bidirectional over a + // VPN: A unicasts to B (because A had B remembered/manually-added) → B receives + // it → B adds A to its own unicast list → B's announcements now reach A too, + // even though A was never in B's remembered list. Without this, only the side + // that had typed the other's IP would see the other. + AddUnicastTarget(result.RemoteEndPoint.Address); + + bool changed; + lock (gate) + { + changed = !peers.TryGetValue(peer.InstanceId, out var existing) + || existing.Name != peer.Name + || existing.AudioPort != peer.AudioPort + || existing.CanSend != peer.CanSend + || existing.CanReceive != peer.CanReceive + || !Equals(existing.Address, peer.Address); + peers[peer.InstanceId] = peer; + PruneExpiredPeers(); + } + if (changed) PeersChanged?.Invoke(); + } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch + { + try { await Task.Delay(500, token).ConfigureAwait(false); } catch { break; } + } + } + } + + private void AddUnicastTarget(IPAddress address) + { + // Idempotent — only swap the snapshot if this IP isn't already there. Avoids churning + // the list on every received announcement (which is every 1.5 s per peer). + var current = unicastTargets; + if (current.Any(a => a.Equals(address))) return; + var updated = current.ToList(); + updated.Add(address); + unicastTargets = updated; + } + + private async Task AnnounceLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + SendAnnouncement(); + try { await Task.Delay(1500, token).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + private void SendAnnouncement() + { + var currentAnnouncer = announcer; + if (currentAnnouncer is null || !announceEnabled) return; + + var message = new DiscoveryMessage(instanceId, Environment.MachineName, audioPort, canSend, canReceive); + var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + + // Broadcast to LAN — instant discovery on the same physical/wifi network. Each connected + // NIC gets its own subnet broadcast (e.g. 192.168.1.255). + foreach (var broadcastAddress in GetBroadcastAddresses()) + { + try + { + currentAnnouncer.Send(bytes, bytes.Length, new IPEndPoint(broadcastAddress, DefaultDiscoveryPort)); + } + catch + { + // Discovery is convenience. Audio still works without it. + } + } + + // Unicast to known peer IPs — covers Tailscale / VPN / WAN where broadcast doesn't + // traverse the tunnel. Sending to an offline peer is silent fire-and-forget. + foreach (var unicast in unicastTargets) + { + try + { + currentAnnouncer.Send(bytes, bytes.Length, new IPEndPoint(unicast, DefaultDiscoveryPort)); + } + catch + { + // Same — discovery is best-effort. + } + } + } + + private static IEnumerable GetBroadcastAddresses() + { + var addresses = new HashSet { IPAddress.Broadcast }; + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + foreach (var unicast in ni.GetIPProperties().UnicastAddresses) + { + if (unicast.Address.AddressFamily != AddressFamily.InterNetwork || unicast.IPv4Mask is null) continue; + var addr = unicast.Address.GetAddressBytes(); + var mask = unicast.IPv4Mask.GetAddressBytes(); + var bcast = new byte[4]; + for (var i = 0; i < 4; i++) bcast[i] = (byte)(addr[i] | ~mask[i]); + addresses.Add(new IPAddress(bcast)); + } + } + return addresses; + } + + private void PruneExpiredPeers() + { + var cutoff = DateTime.UtcNow.AddSeconds(-8); + foreach (var peer in peers.Values.Where(p => p.LastSeenUtc < cutoff).ToList()) + { + peers.Remove(peer.InstanceId); + } + } + + private sealed record DiscoveryMessage(Guid InstanceId, string Name, int AudioPort, bool CanSend, bool CanReceive); +} diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs new file mode 100644 index 0000000..62cc6fe --- /dev/null +++ b/src/RemSound.Core/Profile.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Serialization; +using System.Windows.Forms; + +namespace RemSound.Core; + +/// +/// A saved snapshot of every user-controllable RemSound setting. Replaces the old +/// machine-wide "settings" file. Profiles live as one JSON file per profile under +/// <exe>\profiles\<machine name>\<title>.json and are portable — +/// copying a profile JSON to another machine's profiles folder makes it appear in that +/// machine's selection list. Device IDs stored in a profile (sound cards, ASIO drivers) +/// that don't exist on the loading machine are silently ignored on apply, so a profile +/// can roam between machines with different hardware without erroring out. +/// +/// Design point: profiles capture EVERY UI control state, including device ticks. The +/// previous design rule was to NOT persist device selections (start unticked every +/// session). Profiles deliberately override that — the whole point is one-click +/// restoration. If a user wants the old "start fresh" behaviour, they pick the blank +/// template at startup. +/// +public sealed class Profile +{ + /// Display title and filename stem (sanitised). Required. + public string Title { get; set; } = ""; + + // === Main form: send / receive === + public bool ReceiveAudioOn { get; set; } + public bool SendAudioOn { get; set; } + public int Volume { get; set; } = 100; + public bool Muted { get; set; } + + // === Audio backend === + /// The ASIO driver this profile uses. null or empty means "no ASIO" — + /// the form runs in WASAPI-only mode. Any other value selects an ASIO driver and puts + /// the form into the WASAPI + ASIO independent-lane mode. There is no separate audio-mode + /// field on the profile any more: the mode is derived from this name alone (2026-05-11 + /// cleanup retired the old AudioMode listbox and its persisted enum). Old profile JSONs + /// that still contain "AudioModeRaw" or "BothModeWarningSuppressed" simply + /// have those keys ignored on deserialisation. + public string? AsioDriverName { get; set; } + + // === Selected devices (raw device IDs, not display names) === + public List SelectedWasapiReceiveOutputs { get; set; } = []; + public List SelectedAsioReceiveOutputs { get; set; } = []; + public List SelectedWasapiSendOutputs { get; set; } = []; // loopback (system audio) + public List SelectedWasapiSendInputs { get; set; } = []; // microphones / line-ins + public List SelectedAsioSendInputs { get; set; } = []; + + // === Connectivity & transport === + public int AudioPort { get; set; } = 47830; + public int CodecRaw { get; set; } = (int)AudioTransportCodec.Pcm; + public int OpusFrameMilliseconds { get; set; } = 10; + public int SendRateRaw { get; set; } = (int)SendRate.Standard; + public bool TightLatencyMode { get; set; } + /// True suppresses the connect/disconnect sound cues. Off by default. + /// 2026-05-06. + public bool MuteConnectionCues { get; set; } + public int MaxLatencyMs { get; set; } = 80; + public int Smoothness { get; set; } = 3; + public bool ContinuousAutoTuneEnabled { get; set; } + public int ContinuousAutoTuneIntervalSec { get; set; } = 5; + /// Per-route latency for the ASIO lane in AudioMode.BothIndependent. Default + /// 10 ms because BothIndependent's value proposition is letting ASIO run at its native + /// low latency; users who pick that mode almost always want ASIO closer to 10 than 80. + /// Ignored in every classic mode. + public int MaxLatencyMsAsio { get; set; } = 10; + /// Continuous auto-tune toggle for the ASIO lane (BothIndependent only). + /// Defaults false to match the WASAPI-lane default — symmetric off-by-default avoids + /// the trap where the ASIO lane auto-inflates its target while WASAPI sits fixed at + /// its slider, producing higher ASIO latency than WASAPI in the typical session. + public bool ContinuousAutoTuneAsioEnabled { get; set; } + // LoggingEnabled was retired from Profile — logging is a machine-local debug knob + // (AppConfig.LoggingEnabled), not a per-profile setting. Old profile JSONs that still + // contain "LoggingEnabled" just have the key ignored on load. + /// Receiver-side concealment artifact, stored as raw int for JSON-stability + /// across enum-reorderings. Defaults to + /// (the cosine-tone variants were removed from the dropdown in Phase 3 cleanup — + /// 2026-05-06 — but the enum values stay around so old profile JSONs still parse; + /// the dialog coerces any cosine-tone value to NoiseBurst at load time). + public int ConcealmentArtifactRaw { get; set; } = (int)ConcealmentArtifact.NoiseBurst; + + [JsonIgnore] + public ConcealmentArtifact ConcealmentArtifact + { + get => (ConcealmentArtifact)ConcealmentArtifactRaw; + set => ConcealmentArtifactRaw = (int)value; + } + + // === Peers === + public List RememberedPeers { get; set; } = []; + /// Peer addresses (IP or host[:port]) the user had ticked in the connected + /// list at save time. On load, RemSound auto-connects to any of these that resolve. + public List SelectedConnectedPeers { get; set; } = []; + + // === Hotkeys === + public HotkeyRecord? ReceiveMuteHotkey { get; set; } + public HotkeyRecord? SendMuteHotkey { get; set; } + public HotkeyRecord? TrayHotkey { get; set; } + public HotkeyRecord? VolumeUpHotkey { get; set; } + public HotkeyRecord? VolumeDownHotkey { get; set; } + /// Hotkey that sends a "raise volume" command to every connected peer that has + /// "Accept remote volume commands" enabled. The local volume slider on this machine is + /// NOT touched. Use case: I'm NVDA-Remote'd into another machine and want to nudge the + /// listening volume on the laptop I'm physically at without breaking out of the session. + public HotkeyRecord? RemoteVolumeUpHotkey { get; set; } + /// Mirror of RemoteVolumeUpHotkey for "lower volume" commands. + public HotkeyRecord? RemoteVolumeDownHotkey { get; set; } + /// Hotkey that sends a "toggle receive mute" command to every connected peer. + public HotkeyRecord? RemoteMuteToggleHotkey { get; set; } + /// Hotkey that sends a "raise Windows default-output-device volume by one step" + /// command to every connected peer that has Accept remote volume commands enabled. Each + /// press bumps the receiving peer's Windows master volume by the OS native step (~2%) — + /// same as pressing the keyboard volume key on the receiver. System-wide on the receiver: + /// affects every app on that machine including its screen reader. + public HotkeyRecord? SystemVolumeUpHotkey { get; set; } + /// Mirror of SystemVolumeUpHotkey for the down direction. + public HotkeyRecord? SystemVolumeDownHotkey { get; set; } + /// Hotkey that sends a "toggle Windows default-output-device mute" command to + /// every connected peer. + public HotkeyRecord? SystemMuteToggleHotkey { get; set; } + /// When true, this machine honours incoming Control packets from connected + /// peers — adjusts the local volume slider or toggles mute. Default false: receiving + /// remote control is opt-in even though the audio allow-list already gates who's + /// connected. Lets a user have one profile that's controllable (home setup, single + /// trusted peer) and another that's not (one-off jam session, public-ish peer). + public bool AcceptRemoteVolumeCommands { get; set; } + + // === JSON-friendly accessors (so callers don't deal with the raw int casts) === + // AudioMode accessor + AudioModeRaw backing field retired 2026-05-11. The runtime mode is + // now derived from AsioDriverName; there is no separate persisted enum. + [JsonIgnore] + public AudioTransportCodec Codec + { + get => (AudioTransportCodec)CodecRaw; + set => CodecRaw = (int)value; + } + + [JsonIgnore] + public SendRate SendRate + { + get => (SendRate)SendRateRaw; + set => SendRateRaw = (int)value; + } + + /// Returns a defaults-only profile — same shape as the "blank template" + /// the user picks at startup. Title is empty (caller assigns when saving). + public static Profile NewBlank() => new(); +} + +/// JSON-serialisable hotkey representation. Mirrors +/// but stores Key as a string to keep the JSON robust to enum reorganisations. +public sealed class HotkeyRecord +{ + public string Key { get; set; } = "M"; + public bool Control { get; set; } + public bool Shift { get; set; } + public bool Alt { get; set; } + + public static HotkeyRecord From(HotkeyInfo hotkey) => new() + { + Key = hotkey.Key.ToString(), + Control = hotkey.Control, + Shift = hotkey.Shift, + Alt = hotkey.Alt, + }; + + public HotkeyInfo ToHotkeyInfo() + { + if (!Enum.TryParse(Key, out var parsedKey)) return HotkeyInfo.Default; + var hotkey = new HotkeyInfo(parsedKey, Control, Shift, Alt); + if (hotkey.IsUnset) return HotkeyInfo.Unset; + return hotkey.IsValid ? hotkey : HotkeyInfo.Default; + } +} diff --git a/src/RemSound.Core/ProfileStore.cs b/src/RemSound.Core/ProfileStore.cs new file mode 100644 index 0000000..964faaa --- /dev/null +++ b/src/RemSound.Core/ProfileStore.cs @@ -0,0 +1,191 @@ +using System.Text.Json; + +namespace RemSound.Core; + +/// +/// File-backed store for instances. One profile = one JSON file +/// under <exe>\profiles\<machine name>\<title>.json. +/// +/// Profile names are user-supplied "plain English" strings; the store sanitises them +/// for the filesystem (replaces invalid chars with underscores) but keeps the original +/// string as the in-file Title. Two profiles whose sanitised filenames collide will +/// overwrite each other — fine in practice; very rare. +/// +/// Per-machine subfolder: profiles\<machine>\ — keeps each machine's profiles +/// separate by default. To share a profile between machines, copy the .json file from +/// one machine's folder into the other machine's folder. The profile content is fully +/// portable; device IDs that don't exist on the loading machine are silently dropped +/// at apply time. +/// +public sealed class ProfileStore +{ + private readonly string baseDir; + + public ProfileStore() + { + var machineFolder = SanitiseFsName(Environment.MachineName); + baseDir = Path.Combine(AppContext.BaseDirectory, "profiles", machineFolder); + try { Directory.CreateDirectory(baseDir); } + catch { /* permissions; List/Save will surface this when actually used */ } + } + + /// Construct a profile store pointing at an explicit directory. Used when the + /// user has picked a custom profiles folder via the "Browse for profile folder" button + /// — typically a Dropbox / OneDrive / shared-drive path, or a per-project folder. + /// No per-machine subfolder is appended; the supplied path IS the profiles folder, so + /// the same path on multiple machines shares profiles. Throws if the path is null or + /// empty (caller should validate before constructing). + public ProfileStore(string customDirectory) + { + if (string.IsNullOrWhiteSpace(customDirectory)) + throw new ArgumentException("Custom profile directory cannot be null or empty", nameof(customDirectory)); + baseDir = customDirectory; + try { Directory.CreateDirectory(baseDir); } + catch { /* permissions; List/Save will surface this when actually used */ } + } + + /// Folder this store reads from and writes into. + public string BaseDirectory => baseDir; + + /// Returns the user-facing titles of every profile in the folder, sorted + /// alphabetically (case-insensitive). Excludes the synthetic blank-template; the + /// caller decides whether to surface that. + public IReadOnlyList ListProfileTitles() + { + if (!Directory.Exists(baseDir)) return []; + try + { + return Directory.GetFiles(baseDir, "*.json") + .Select(p => TryReadTitle(p) ?? Path.GetFileNameWithoutExtension(p)) + .Where(static t => !string.IsNullOrWhiteSpace(t)) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + return []; + } + } + + /// Loads a profile by title. Returns null if the file is missing or + /// unreadable. Malformed JSON is treated as "not found" rather than throwing — + /// the caller can surface a diagnostic and fall back to a blank template. + public Profile? Load(string title) + { + if (string.IsNullOrWhiteSpace(title)) return null; + var path = PathFor(title); + if (!File.Exists(path)) return null; + try + { + var json = File.ReadAllText(path); + var profile = JsonSerializer.Deserialize(json); + // Force the in-file Title to whatever was on disk; defends against the user + // renaming the .json filename without editing the JSON. + if (profile is not null && string.IsNullOrWhiteSpace(profile.Title)) + { + profile.Title = title; + } + return profile; + } + catch + { + return null; + } + } + + /// Writes a profile to disk. Title must be non-empty; caller is responsible + /// for prompting the user or generating one. Throws if filesystem write fails. + public void Save(Profile profile) + { + if (profile is null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrWhiteSpace(profile.Title)) + throw new ArgumentException("Profile title cannot be empty", nameof(profile)); + Directory.CreateDirectory(baseDir); + var path = PathFor(profile.Title); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + } + + /// Deletes the profile by title. Returns true if a file was removed, + /// false if it didn't exist or couldn't be deleted. + public bool Delete(string title) + { + if (string.IsNullOrWhiteSpace(title)) return false; + var path = PathFor(title); + if (!File.Exists(path)) return false; + try + { + File.Delete(path); + return true; + } + catch + { + return false; + } + } + + /// True if a profile with the given title (sanitised filename) already exists. + public bool Exists(string title) => !string.IsNullOrWhiteSpace(title) && File.Exists(PathFor(title)); + + /// Rename a profile on disk. Loads the JSON, updates the in-file Title field, + /// writes it under the new sanitised filename, then deletes the old file. Returns true + /// on success. Fails (returns false, no changes made) if the source doesn't exist, the + /// new title is empty/identical, or a file already exists at the destination filename. + /// 2026-05-06. + public bool Rename(string oldTitle, string newTitle) + { + if (string.IsNullOrWhiteSpace(oldTitle) || string.IsNullOrWhiteSpace(newTitle)) return false; + if (string.Equals(oldTitle, newTitle, StringComparison.Ordinal)) return false; + var oldPath = PathFor(oldTitle); + var newPath = PathFor(newTitle); + if (!File.Exists(oldPath)) return false; + if (File.Exists(newPath) && !string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) return false; + try + { + var profile = Load(oldTitle); + if (profile is null) return false; + profile.Title = newTitle; + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(newPath, json); + if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(oldPath); + } + return true; + } + catch + { + return false; + } + } + + /// The on-disk path for a profile of the given title in this store's base + /// directory. Sanitises filesystem-invalid characters in the title before joining. + /// Public so callers (e.g. MainForm at startup) can record where a loaded profile + /// lives, which matters once Save As lets the user write outside . + public string PathFor(string title) => Path.Combine(baseDir, SanitiseFsName(title) + ".json"); + + /// Read just the Title field of a profile JSON to surface the user-supplied + /// name even if it differs from the sanitised filename. Cheap; the file is small. + private static string? TryReadTitle(string path) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.TryGetProperty(nameof(Profile.Title), out var titleProp) + && titleProp.ValueKind == JsonValueKind.String) + { + return titleProp.GetString(); + } + } + catch { /* fall through */ } + return null; + } + + private static string SanitiseFsName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "untitled"; + foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); + return name.Trim(); + } +} diff --git a/src/RemSound.Core/RemPacket.cs b/src/RemSound.Core/RemPacket.cs new file mode 100644 index 0000000..db9320e --- /dev/null +++ b/src/RemSound.Core/RemPacket.cs @@ -0,0 +1,358 @@ +using System.Buffers.Binary; + +namespace RemSound.Core; + +public enum RemPacketType : byte +{ + Format = 1, + Audio = 2, + KeepAlive = 3, + Heartbeat = 4, + /// + /// Remote-control message from one connected peer to another. Currently used to let a + /// peer adjust the receiver-side volume slider on a peer it's connected to (so a user + /// who's NVDA-Remote'd into another machine can still nudge the listening volume on + /// the machine they're physically at). Wire format: 1 byte + /// + 1 byte signed delta (interpreted as signed sbyte; range -128..127, percent points). + /// Old peers see "unknown packet type" and silently drop, so adding this is wire-safe. + /// + Control = 5, +} + +public enum HeartbeatKind : byte +{ + Ping = 0, + Pong = 1, +} + +/// +/// What a Control packet is asking the receiver to do. +/// +/// Two families of commands: +/// * / / — adjust +/// the receiver's RemSound app volume slider (in-app, only affects RemSound's own audio). +/// Delta byte carries a percent-point step (typically ±5). +/// * / / +/// — adjust the receiver's Windows default-output-device master volume (system-wide, +/// affects every app on the receiving machine, including its screen reader). Each call +/// issues exactly one Windows native volume-step (typically ~2%), matching what the +/// keyboard volume keys do. Delta byte ignored. +/// +/// Kept narrow on purpose: remote control is a small audio convenience, not a generic +/// "do anything to that peer" channel. Adding new commands later means adding enum values; +/// old receivers see them as invalid and ignore the packet. +/// +public enum RemoteControlKind : byte +{ + VolumeUp = 0, + VolumeDown = 1, + MuteToggle = 2, + SystemVolumeUp = 3, + SystemVolumeDown = 4, + SystemMuteToggle = 5, +} + +[Flags] +public enum KeepAliveCapabilities : byte +{ + None = 0, + CanSend = 1, + CanReceive = 2, +} + +public enum KeepAliveKind : byte +{ + Heartbeat = 1, + Ack = 2, +} + +public readonly record struct KeepAliveInfo( + Guid SessionId, + KeepAliveKind Kind, + KeepAliveCapabilities Capabilities, + AudioTransportCodec Codec, + long UnixTimeMilliseconds); + +/// +/// Wire format for RemSound packets. Header is 12 bytes; body length is implied by the UDP datagram. +/// Header layout (little-endian): +/// uint32 magic 'RMND' +/// uint8 version 1 +/// uint8 type RemPacketType +/// uint16 streamId +/// uint32 sequence +/// +public static class RemPacket +{ + public const int HeaderSize = 12; + /// Minimum format payload size. Builds older than 2026-05-11 only emit this + /// many bytes; readers must accept this length as a valid (but unextended) format + /// packet and default any post-32-byte fields. See . + public const int FormatPayloadSize = 32; + /// Extended format payload size (32 base + 4 extension). The extension carries + /// the byte at offset 32 plus 3 reserved-zero bytes for + /// future growth. Receivers must check payload.Length >= FormatPayloadExtendedSize + /// before reading the Lane field; payloads shorter than that default Lane to + /// . Senders newer than 2026-05-11 always write this size. + public const int FormatPayloadExtendedSize = 36; + public const int KeepAlivePayloadSize = 28; + /// + /// Heartbeat payload: 1 byte + 8 bytes originator-monotonic + /// timestamp (Stopwatch.ElapsedMilliseconds at the time the originating Ping was sent). + /// Pongs copy the originator's timestamp verbatim — sender computes RTT against its own + /// clock, so no clock sync is needed between peers (RFC 3550 RTCP DLSR pattern, simplified). + /// + public const int HeartbeatPayloadSize = 9; + /// + /// Control payload: 1 byte + 1 signed byte delta. Total + /// 2 bytes, plus the 12-byte header = 14 bytes on the wire. See + /// for the rationale. + /// + public const int ControlPayloadSize = 2; + /// + /// Single canonical port for everything: receiver bind, LAN peer-to-peer dials, and the + /// public RemSound relay. Was 47820 (audio receiver) + 47830 (relay) in the old design; + /// unified to 47830 on 2026-05-05 so users never have to type `:port` after a hostname or + /// IP. Any peer the user adds — Tailscale IP, LAN IP, or relay hostname — defaults to + /// this port. The +1 (discovery) and +2 (heartbeat) derived ports follow accordingly. + /// + public const int DefaultPort = 47830; + /// + /// Kept as an alias for the single canonical port so existing call sites that distinguish + /// "the local bind" from "the dial default" still compile. They point at the same value + /// now — there is no longer a separate dial port. + /// + public const int DefaultPeerDialPort = DefaultPort; + public const int Magic = 0x444E4D52; // 'RMND' little-endian + public const byte Version = 1; + + /// + /// Maximum payload bytes guaranteed to fit a typical Ethernet path without IP fragmentation + /// (1500 - 20 IP - 8 UDP - 12 RemPacket header - 6 PCM-multipart sub-header). + /// + public const int MaxAudioPayloadBytes = 1454; + + public static int WriteHeader(Span destination, RemPacketType type, ushort streamId, uint sequence) + { + if (destination.Length < HeaderSize) + { + throw new ArgumentException("Header destination too small", nameof(destination)); + } + + BinaryPrimitives.WriteInt32LittleEndian(destination, Magic); + destination[4] = Version; + destination[5] = (byte)type; + BinaryPrimitives.WriteUInt16LittleEndian(destination[6..], streamId == 0 ? (ushort)1 : streamId); + BinaryPrimitives.WriteUInt32LittleEndian(destination[8..], sequence); + return HeaderSize; + } + + /// + /// Writes the format payload. Always emits bytes + /// (36): the 32 legacy fields followed by a Lane byte and 3 reserved-zero bytes. Old + /// receivers that only read 32 bytes will still parse the legacy block correctly and + /// ignore the trailing 4 — see the doc comment for the + /// compatibility contract. + /// + public static int WriteFormatPayload(Span destination, AudioFormatInfo format) + { + if (destination.Length < FormatPayloadExtendedSize) + { + throw new ArgumentException("Format payload destination too small", nameof(destination)); + } + + BinaryPrimitives.WriteInt32LittleEndian(destination, format.SampleRate); + BinaryPrimitives.WriteInt32LittleEndian(destination[4..], format.Channels); + BinaryPrimitives.WriteInt32LittleEndian(destination[8..], format.BitsPerSample); + BinaryPrimitives.WriteInt32LittleEndian(destination[12..], format.Encoding); + BinaryPrimitives.WriteInt32LittleEndian(destination[16..], format.BlockAlign); + BinaryPrimitives.WriteInt32LittleEndian(destination[20..], format.AverageBytesPerSecond); + BinaryPrimitives.WriteInt32LittleEndian(destination[24..], format.Codec); + BinaryPrimitives.WriteInt32LittleEndian(destination[28..], format.FrameDurationMilliseconds); + // Extension: 1 byte Lane + 3 reserved-zero bytes. Zero-fill the reserved slot so a + // future receiver doesn't accidentally read stale stack data if WriteFormatPayload + // is called on an uninitialised buffer. + destination[32] = (byte)format.Lane; + destination[33] = 0; + destination[34] = 0; + destination[35] = 0; + return FormatPayloadExtendedSize; + } + + public static int WriteKeepAlivePayload(Span destination, KeepAliveInfo info) + { + if (destination.Length < KeepAlivePayloadSize) + { + throw new ArgumentException("KeepAlive payload destination too small", nameof(destination)); + } + + destination[0] = (byte)info.Kind; + destination[1] = (byte)info.Codec; + destination[2] = (byte)info.Capabilities; + destination[3] = 0; + BinaryPrimitives.WriteInt64LittleEndian(destination[4..], info.UnixTimeMilliseconds); + if (!info.SessionId.TryWriteBytes(destination.Slice(12, 16))) + { + return 0; + } + return KeepAlivePayloadSize; + } + + public static bool TryReadHeader(ReadOnlySpan packet, out RemPacketType type, out ushort streamId, out uint sequence) + { + type = default; + streamId = 0; + sequence = 0; + if (packet.Length < HeaderSize) return false; + if (BinaryPrimitives.ReadInt32LittleEndian(packet) != Magic) return false; + if (packet[4] != Version) return false; + type = (RemPacketType)packet[5]; + streamId = BinaryPrimitives.ReadUInt16LittleEndian(packet[6..]); + if (streamId == 0) streamId = 1; + sequence = BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]); + return true; + } + + /// + /// Reads the format payload. Accepts both the legacy 32-byte and the extended 36-byte + /// layouts: the legacy layout defaults to + /// , which is exactly what an old sender (pre-2026-05-11) + /// would have meant. Lane values outside the defined enum range are clamped to Mixed + /// rather than rejected — better to play the audio in the default route than drop a + /// stream because a future sender sent an unknown value. + /// + public static bool TryReadFormat(ReadOnlySpan payload, out AudioFormatInfo format) + { + format = new AudioFormatInfo(48000, 2, 32, 3, 8, 384000); + if (payload.Length < FormatPayloadSize) return false; + + var lane = RenderRoute.Mixed; + if (payload.Length >= FormatPayloadExtendedSize) + { + var laneRaw = payload[32]; + lane = laneRaw switch + { + (byte)RenderRoute.Mixed => RenderRoute.Mixed, + (byte)RenderRoute.WasapiLane => RenderRoute.WasapiLane, + (byte)RenderRoute.AsioLane => RenderRoute.AsioLane, + _ => RenderRoute.Mixed, // forward-compat: unknown lane → safe default + }; + } + + format = new AudioFormatInfo( + BinaryPrimitives.ReadInt32LittleEndian(payload), + BinaryPrimitives.ReadInt32LittleEndian(payload[4..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[8..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[12..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[16..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[20..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[24..]), + BinaryPrimitives.ReadInt32LittleEndian(payload[28..]), + lane); + return true; + } + + public static int WriteHeartbeatPayload(Span destination, HeartbeatKind kind, long originatorTickMs) + { + if (destination.Length < HeartbeatPayloadSize) + { + throw new ArgumentException("Heartbeat payload destination too small", nameof(destination)); + } + destination[0] = (byte)kind; + BinaryPrimitives.WriteInt64LittleEndian(destination[1..], originatorTickMs); + return HeartbeatPayloadSize; + } + + public static bool TryReadHeartbeat(ReadOnlySpan payload, out HeartbeatKind kind, out long originatorTickMs) + { + kind = HeartbeatKind.Ping; + originatorTickMs = 0; + if (payload.Length < HeartbeatPayloadSize) return false; + var raw = payload[0]; + if (raw != (byte)HeartbeatKind.Ping && raw != (byte)HeartbeatKind.Pong) return false; + kind = (HeartbeatKind)raw; + originatorTickMs = BinaryPrimitives.ReadInt64LittleEndian(payload[1..]); + return true; + } + + public static int WriteControlPayload(Span destination, RemoteControlKind kind, sbyte delta) + { + if (destination.Length < ControlPayloadSize) + { + throw new ArgumentException("Control payload destination too small", nameof(destination)); + } + destination[0] = (byte)kind; + destination[1] = (byte)delta; + return ControlPayloadSize; + } + + public static bool TryReadControl(ReadOnlySpan payload, out RemoteControlKind kind, out sbyte delta) + { + kind = RemoteControlKind.VolumeUp; + delta = 0; + if (payload.Length < ControlPayloadSize) return false; + var raw = payload[0]; + // Reject unknown kinds rather than coercing — keeps the door open to future kinds + // without an old receiver guessing wrong on an unfamiliar value. + if (raw != (byte)RemoteControlKind.VolumeUp + && raw != (byte)RemoteControlKind.VolumeDown + && raw != (byte)RemoteControlKind.MuteToggle + && raw != (byte)RemoteControlKind.SystemVolumeUp + && raw != (byte)RemoteControlKind.SystemVolumeDown + && raw != (byte)RemoteControlKind.SystemMuteToggle) return false; + kind = (RemoteControlKind)raw; + delta = (sbyte)payload[1]; + return true; + } + + public static bool TryReadKeepAlive(ReadOnlySpan payload, out KeepAliveInfo info) + { + info = default; + if (payload.Length < KeepAlivePayloadSize) return false; + if (!Enum.IsDefined((KeepAliveKind)payload[0])) return false; + info = new KeepAliveInfo( + new Guid(payload.Slice(12, 16)), + (KeepAliveKind)payload[0], + (KeepAliveCapabilities)payload[2], + Enum.IsDefined((AudioTransportCodec)payload[1]) ? (AudioTransportCodec)payload[1] : AudioTransportCodec.Pcm, + BinaryPrimitives.ReadInt64LittleEndian(payload[4..])); + return true; + } +} + +/// +/// PCM transport sub-header. PCM frames are larger than a UDP datagram (10 ms × 48 kHz × 2 ch × 3 byte = 2880 B) +/// so they're split into multi-part chunks. The receiver assembles parts back into a complete frame +/// before queueing for playout. Sub-header (6 bytes) is prepended to the audio bytes: +/// uint32 frameId +/// uint8 partIndex +/// uint8 totalParts +/// +public static class RemPcmFrame +{ + public const int SubHeaderSize = 6; + + public static int WriteSubHeader(Span destination, uint frameId, byte partIndex, byte totalParts) + { + if (destination.Length < SubHeaderSize) + { + throw new ArgumentException("PCM sub-header destination too small", nameof(destination)); + } + BinaryPrimitives.WriteUInt32LittleEndian(destination, frameId); + destination[4] = partIndex; + destination[5] = totalParts; + return SubHeaderSize; + } + + public static bool TryReadSubHeader(ReadOnlySpan source, out uint frameId, out byte partIndex, out byte totalParts) + { + frameId = 0; + partIndex = 0; + totalParts = 0; + if (source.Length < SubHeaderSize) return false; + frameId = BinaryPrimitives.ReadUInt32LittleEndian(source); + partIndex = source[4]; + totalParts = source[5]; + return totalParts > 0 && partIndex < totalParts; + } +} diff --git a/src/RemSound.Core/RemSound.Core.csproj b/src/RemSound.Core/RemSound.Core.csproj new file mode 100644 index 0000000..e245e1e --- /dev/null +++ b/src/RemSound.Core/RemSound.Core.csproj @@ -0,0 +1,11 @@ + + + net10.0-windows + enable + enable + true + RemSound.Core + RemSound.Core + true + + diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs new file mode 100644 index 0000000..a26925f --- /dev/null +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -0,0 +1,523 @@ +using System.Windows.Forms; + +namespace RemSound.Core; + +/// +/// In-memory cache of UI/runtime preferences. As of 2026-05-02 this no longer persists to +/// disk — RemSound's persistence layer is the profile system ( / +/// ), and this class is just an intra-process holding area that +/// the active profile populates on app startup and reads back from when the user saves a +/// profile. Old configs/ folders from prior builds are ignored. Constructor still +/// takes an appName for backwards compatibility but it's unused. +/// +public sealed class RemSoundSettingsStore +{ + public RemSoundSettingsStore(string appName) { } + + public HotkeyInfo LoadReceiveMuteHotkey() => + Try(() => Load()?.ReceiveMuteHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.R, true, true, true); + + public void SaveReceiveMuteHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.ReceiveMuteHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadSendMuteHotkey() => + Try(() => Load()?.SendMuteHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.S, true, true, true); + + public void SaveSendMuteHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.SendMuteHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadTrayHotkey() => + Try(() => Load()?.TrayHotkey?.ToHotkeyInfo()) ?? new HotkeyInfo(Keys.F10, true, true, false); + + public void SaveTrayHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.TrayHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadVolumeUpHotkey() => + Try(() => Load()?.VolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveVolumeUpHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.VolumeUpHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadVolumeDownHotkey() => + Try(() => Load()?.VolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveVolumeDownHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.VolumeDownHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadRemoteVolumeUpHotkey() => + Try(() => Load()?.RemoteVolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveRemoteVolumeUpHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.RemoteVolumeUpHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadRemoteVolumeDownHotkey() => + Try(() => Load()?.RemoteVolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveRemoteVolumeDownHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.RemoteVolumeDownHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadRemoteMuteToggleHotkey() => + Try(() => Load()?.RemoteMuteToggleHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveRemoteMuteToggleHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.RemoteMuteToggleHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadSystemVolumeUpHotkey() => + Try(() => Load()?.SystemVolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveSystemVolumeUpHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.SystemVolumeUpHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadSystemVolumeDownHotkey() => + Try(() => Load()?.SystemVolumeDownHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveSystemVolumeDownHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.SystemVolumeDownHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public HotkeyInfo LoadSystemMuteToggleHotkey() => + Try(() => Load()?.SystemMuteToggleHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset; + + public void SaveSystemMuteToggleHotkey(HotkeyInfo hotkey) + { + var s = Load() ?? new Settings(); + s.SystemMuteToggleHotkey = HotkeySetting.From(hotkey); + Save(s); + } + + public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) => + Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue; + + public void SaveAcceptRemoteVolumeCommands(bool value) + { + var s = Load() ?? new Settings(); + s.AcceptRemoteVolumeCommands = value; + Save(s); + } + + public int LoadMaxLatencyMs(int defaultValue = 80) => + Try(() => Load()?.MaxLatencyMs is int v ? Math.Clamp(v, 5, 500) : (int?)null) ?? defaultValue; + + public void SaveMaxLatencyMs(int value) + { + var s = Load() ?? new Settings(); + s.MaxLatencyMs = Math.Clamp(value, 1, 500); + Save(s); + } + + /// + /// Per-route latency settings used only in BothIndependent audio mode. The existing + /// / govern the WASAPI lane + /// (which is what the existing slider has always controlled — every classic mode reads + /// it the same way pre-Stage-4.5). The ASIO companion below stores the ASIO lane's + /// target. Default 10 ms because the whole point of the new mode is to let ASIO run at + /// its native low latency; if the user has picked BothIndependent they almost certainly + /// want ASIO closer to 10 than to 80. + /// + public int LoadMaxLatencyMsAsio(int defaultValue = 10) => + Try(() => Load()?.MaxLatencyMsAsio is int v ? Math.Clamp(v, 5, 500) : (int?)null) ?? defaultValue; + + public void SaveMaxLatencyMsAsio(int value) + { + var s = Load() ?? new Settings(); + s.MaxLatencyMsAsio = Math.Clamp(value, 1, 500); + Save(s); + } + + /// Continuous auto-tune enabled for the ASIO lane. Defaults false to match the + /// WASAPI-lane default — having one lane auto-adjusting and the other fixed produces + /// confusingly asymmetric latency where the auto-tuning lane sits noticeably higher + /// because it's reacting to network jitter the fixed lane just rides through. User can + /// enable per lane explicitly; in BothIndependent both lanes' enable checkboxes are + /// visible side-by-side. + public bool LoadContinuousAutoTuneAsioEnabled(bool defaultValue = false) => + Try(() => Load()?.ContinuousAutoTuneAsioEnabled) ?? defaultValue; + + public void SaveContinuousAutoTuneAsioEnabled(bool value) + { + var s = Load() ?? new Settings(); + s.ContinuousAutoTuneAsioEnabled = value; + Save(s); + } + + public AudioTransportCodec LoadCodec(AudioTransportCodec defaultValue = AudioTransportCodec.Pcm) => + Try(() => Load()?.Codec) ?? defaultValue; + + public void SaveCodec(AudioTransportCodec value) + { + var s = Load() ?? new Settings(); + s.Codec = value; + Save(s); + } + + public int LoadOpusFrameMilliseconds(int defaultValue = 10) => + Try(() => Load()?.OpusFrameMilliseconds is int v && (v == 10 || v == 20) ? v : (int?)null) ?? defaultValue; + + public void SaveOpusFrameMilliseconds(int value) + { + var s = Load() ?? new Settings(); + s.OpusFrameMilliseconds = value == 20 ? 20 : 10; + Save(s); + } + + public bool LoadContinuousAutoTuneEnabled(bool defaultValue = false) => + Try(() => Load()?.ContinuousAutoTuneEnabled) ?? defaultValue; + + public void SaveContinuousAutoTuneEnabled(bool value) + { + var s = Load() ?? new Settings(); + s.ContinuousAutoTuneEnabled = value; + Save(s); + } + + public int LoadContinuousAutoTuneIntervalSec(int defaultValue = 5) => + Try(() => Load()?.ContinuousAutoTuneIntervalSec is int v && v >= 5 && v <= 60 ? v : (int?)null) ?? defaultValue; + + public void SaveContinuousAutoTuneIntervalSec(int value) + { + var s = Load() ?? new Settings(); + s.ContinuousAutoTuneIntervalSec = Math.Clamp(value, 5, 60); + Save(s); + } + + public IReadOnlyList LoadRememberedPeers() => + Try(() => Load()?.RememberedPeers? + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList()) + ?? []; + + public void SaveRememberedPeers(IEnumerable peers) + { + var s = Load() ?? new Settings(); + s.RememberedPeers = peers + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Save(s); + } + + // LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a + // per-profile setting. LoadLoggingEnabled / SaveLoggingEnabled were retired here; + // callers go to AppConfig.LoggingEnabled directly. + + /// + /// Audio mode is derived from whether an ASIO driver is selected. Pre-2026-05-11 this was + /// a user-facing setting with its own listbox; now the UI is simpler — the user just picks + /// an ASIO driver (or "(none)" to disable ASIO) and the mode follows. A real driver chosen + /// means BothIndependent (WASAPI + ASIO running side by side, each at its own latency); + /// no driver means WasapiOnly. The AudioMode field still exists on the persisted Settings + /// JSON purely for backward compat with old profiles — its value is ignored on load. The + /// matching SaveAudioMode setter was deleted with the listbox in the 2026-05-11 cleanup; + /// callers that used to invoke it have been removed. + /// + public AudioMode LoadAudioMode(AudioMode defaultValue = AudioMode.WasapiOnly) => + string.IsNullOrWhiteSpace(LoadAsioDriverName()) ? AudioMode.WasapiOnly : AudioMode.BothIndependent; + + // BothModeWarningSuppressed used to live here. Moved to AppConfig (remsound.config.json, + // machine-local) on 2026-05-07 — a "do not show me this again" decision shouldn't be + // tied to which profile is active. The accessors were removed; callers go to AppConfig + // directly. Profile.BothModeWarningSuppressed is left in place to deserialise old JSONs + // (one-shot migrated to AppConfig in MainForm's constructor). + + public SendRate LoadSendRate(SendRate defaultValue = SendRate.Standard) => + Try(() => Load()?.SendRate is SendRate v ? v : (SendRate?)null) ?? defaultValue; + + public void SaveSendRate(SendRate value) + { + var s = Load() ?? new Settings(); + s.SendRate = value; + Save(s); + } + + /// Tight-latency mode toggle. Sender-side only as of 2026-05-06 (the receiver no + /// longer has a resampler to bypass). In WasapiOnly + single source mode the sender swaps + /// from the timer-driven MixingEngine to the audio-clock-locked PushModeWasapiBackend; in + /// AsioOnly + PCM mode the sender emits one packet per ASIO callback instead of accumulating + /// to the chosen frame size. Saves a few ms of send-side latency at the cost of brief + /// clicks if the link can't keep up. Off by default. + public bool LoadTightLatencyMode(bool defaultValue = false) => + Try(() => Load()?.TightLatencyMode) ?? defaultValue; + + public void SaveTightLatencyMode(bool value) + { + var s = Load() ?? new Settings(); + s.TightLatencyMode = value; + Save(s); + } + + /// Suppresses the connect/disconnect sound cues that play when a peer's health + /// transitions to/from Healthy. Off by default — cues are on. Saved per-profile so users + /// who don't want them in a given setup don't have to remember to mute every session. + /// 2026-05-06. + public bool LoadMuteConnectionCues(bool defaultValue = false) => + Try(() => Load()?.MuteConnectionCues) ?? defaultValue; + + public void SaveMuteConnectionCues(bool value) + { + var s = Load() ?? new Settings(); + s.MuteConnectionCues = value; + Save(s); + } + + /// How aggressively the receiver pulls the playout queue back to the user's + /// target latency under network jitter. 1 = stupid aggressive (~10 % playback rate change, + /// audible pitch shift on drift, sub-second recovery). 10 = perfectly smooth (gentle + /// controller, no audible artefacts, slow recovery — buffer can creep up over a long + /// session). Lower is faster-but-less-stable, like the latency slider. Default is 3 — + /// quite aggressive but not the extreme; user dials down for tighter, up for smoother. + public int LoadSmoothness(int defaultValue = 3) => + Try(() => Load()?.Smoothness is int v ? Math.Clamp(v, 1, 10) : (int?)null) ?? defaultValue; + + public void SaveSmoothness(int value) + { + var s = Load() ?? new Settings(); + s.Smoothness = Math.Clamp(value, 1, 10); + Save(s); + } + + /// Receiver-side concealment artifact pick. See + /// for what each value sounds like. Default is + /// — the cosine-tone defaults were removed in Phase 3 cleanup (they sounded harsh on + /// orchestral content). Old profiles holding a CosineTone* enum value still load fine; + /// the dialog dropdown coerces them to NoiseBurst on display. + public ConcealmentArtifact LoadConcealmentArtifact(ConcealmentArtifact defaultValue = ConcealmentArtifact.NoiseBurst) => + Try(() => Load()?.ConcealmentArtifact is ConcealmentArtifact v ? v : (ConcealmentArtifact?)null) ?? defaultValue; + + public void SaveConcealmentArtifact(ConcealmentArtifact value) + { + var s = Load() ?? new Settings(); + s.ConcealmentArtifact = value; + Save(s); + } + + // ResamplerBypassWhenTight (load/save + Settings field) removed 2026-05-06 in Phase 3 + // cleanup. The receiver no longer has a resampler in the steady-state path, so the + // bypass switch had nothing left to toggle. Existing profile JSON with the old key + // is silently ignored by the deserialiser. + + public string? LoadAsioDriverName() => Try(() => Load()?.AsioDriverName); + + public void SaveAsioDriverName(string? value) + { + var s = Load() ?? new Settings(); + s.AsioDriverName = string.IsNullOrWhiteSpace(value) ? null : value; + Save(s); + } + + private static T? Try(Func action) where T : class + { + try { return action(); } catch { return null; } + } + + private static T? Try(Func action, T? unused = null) where T : struct + { + try { return action(); } catch { return null; } + } + + // 2026-05-02: persistence moved out of this class. RemSound now manages settings via the + // profile system (RemSound.Core.Profile / ProfileStore), and the settings store has become + // a per-process in-memory cache that the active profile populates on load and reads back + // from on save. Disk IO from this class is intentionally a no-op now: the old configs/ + // folder is no longer written to. If a configs/ folder exists from a previous build, it's + // ignored — users are expected to re-create their setup as a Profile via the new dialog. + private Settings cache = new(); + + private Settings? Load() => cache; + + private void Save(Settings settings) => cache = settings; + + /// Replace the in-memory settings cache from a loaded . + /// Called once at app startup after the user picks a profile (or never, if they pick + /// the blank template — in which case defaults remain). + public void ApplyProfile(Profile profile) + { + if (profile is null) throw new ArgumentNullException(nameof(profile)); + cache = new Settings + { + ReceiveMuteHotkey = profile.ReceiveMuteHotkey is null ? null : HotkeySettingFromRecord(profile.ReceiveMuteHotkey), + SendMuteHotkey = profile.SendMuteHotkey is null ? null : HotkeySettingFromRecord(profile.SendMuteHotkey), + TrayHotkey = profile.TrayHotkey is null ? null : HotkeySettingFromRecord(profile.TrayHotkey), + VolumeUpHotkey = profile.VolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeUpHotkey), + VolumeDownHotkey = profile.VolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeDownHotkey), + RemoteVolumeUpHotkey = profile.RemoteVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeUpHotkey), + RemoteVolumeDownHotkey = profile.RemoteVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeDownHotkey), + RemoteMuteToggleHotkey = profile.RemoteMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteMuteToggleHotkey), + SystemVolumeUpHotkey = profile.SystemVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeUpHotkey), + SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey), + SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey), + AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands, + MaxLatencyMs = profile.MaxLatencyMs, + Codec = profile.Codec, + OpusFrameMilliseconds = profile.OpusFrameMilliseconds, + ContinuousAutoTuneEnabled = profile.ContinuousAutoTuneEnabled, + ContinuousAutoTuneIntervalSec = profile.ContinuousAutoTuneIntervalSec, + MaxLatencyMsAsio = profile.MaxLatencyMsAsio, + ContinuousAutoTuneAsioEnabled = profile.ContinuousAutoTuneAsioEnabled, + RememberedPeers = profile.RememberedPeers is null ? null : new List(profile.RememberedPeers), + AsioDriverName = profile.AsioDriverName, + // Profile.AudioModeRaw and Profile.BothModeWarningSuppressed are no longer carried + // through the settings cache. Both fields are retired (2026-05-07 / 2026-05-11); + // mode is derived from AsioDriverName and the Both-mode warning popup is gone. + SendRate = profile.SendRate, + TightLatencyMode = profile.TightLatencyMode, + Smoothness = profile.Smoothness, + ConcealmentArtifact = (ConcealmentArtifact)profile.ConcealmentArtifactRaw, + MuteConnectionCues = profile.MuteConnectionCues, + }; + } + + /// Copies the current in-memory settings cache into a Profile. Note: this only + /// covers the fields the settings store has historically known about — the device-tick + /// state, send/receive checkbox state, audio port, volume slider, and selected-peer + /// state live on the form itself and are gathered by the form when saving a profile. + public void CopyTo(Profile profile) + { + if (profile is null) throw new ArgumentNullException(nameof(profile)); + var s = cache; + profile.ReceiveMuteHotkey = s.ReceiveMuteHotkey is null ? null : HotkeyRecordFromSetting(s.ReceiveMuteHotkey); + profile.SendMuteHotkey = s.SendMuteHotkey is null ? null : HotkeyRecordFromSetting(s.SendMuteHotkey); + profile.TrayHotkey = s.TrayHotkey is null ? null : HotkeyRecordFromSetting(s.TrayHotkey); + profile.VolumeUpHotkey = s.VolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeUpHotkey); + profile.VolumeDownHotkey = s.VolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeDownHotkey); + profile.RemoteVolumeUpHotkey = s.RemoteVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeUpHotkey); + profile.RemoteVolumeDownHotkey = s.RemoteVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeDownHotkey); + profile.RemoteMuteToggleHotkey = s.RemoteMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteMuteToggleHotkey); + profile.SystemVolumeUpHotkey = s.SystemVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeUpHotkey); + profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey); + profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey); + if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc; + if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml; + if (s.Codec is AudioTransportCodec c) profile.Codec = c; + if (s.OpusFrameMilliseconds is int op) profile.OpusFrameMilliseconds = op; + if (s.ContinuousAutoTuneEnabled is bool cae) profile.ContinuousAutoTuneEnabled = cae; + if (s.ContinuousAutoTuneIntervalSec is int cai) profile.ContinuousAutoTuneIntervalSec = cai; + if (s.MaxLatencyMsAsio is int mla) profile.MaxLatencyMsAsio = mla; + if (s.ContinuousAutoTuneAsioEnabled is bool cata) profile.ContinuousAutoTuneAsioEnabled = cata; + if (s.RememberedPeers is { } rp) profile.RememberedPeers = new List(rp); + profile.AsioDriverName = s.AsioDriverName; + // AudioMode and BothModeWarningSuppressed are not copied — both Profile fields were + // retired in the 2026-05-11 cleanup. Mode is derived from AsioDriverName and the + // popup that owned the suppression flag is gone. + if (s.SendRate is SendRate sr) profile.SendRate = sr; + if (s.TightLatencyMode is bool tl) profile.TightLatencyMode = tl; + if (s.Smoothness is int sm) profile.Smoothness = sm; + if (s.ConcealmentArtifact is ConcealmentArtifact ca) profile.ConcealmentArtifactRaw = (int)ca; + if (s.MuteConnectionCues is bool mc) profile.MuteConnectionCues = mc; + } + + private static HotkeySetting HotkeySettingFromRecord(HotkeyRecord r) => new() + { + Key = r.Key, + Control = r.Control, + Shift = r.Shift, + Alt = r.Alt, + }; + + private static HotkeyRecord HotkeyRecordFromSetting(HotkeySetting s) => new() + { + Key = s.Key, + Control = s.Control, + Shift = s.Shift, + Alt = s.Alt, + }; + + private sealed class Settings + { + public HotkeySetting? ReceiveMuteHotkey { get; set; } + public HotkeySetting? SendMuteHotkey { get; set; } + public HotkeySetting? TrayHotkey { get; set; } + public HotkeySetting? VolumeUpHotkey { get; set; } + public HotkeySetting? VolumeDownHotkey { get; set; } + public HotkeySetting? RemoteVolumeUpHotkey { get; set; } + public HotkeySetting? RemoteVolumeDownHotkey { get; set; } + public HotkeySetting? RemoteMuteToggleHotkey { get; set; } + public HotkeySetting? SystemVolumeUpHotkey { get; set; } + public HotkeySetting? SystemVolumeDownHotkey { get; set; } + public HotkeySetting? SystemMuteToggleHotkey { get; set; } + public bool? AcceptRemoteVolumeCommands { get; set; } + public int? MaxLatencyMs { get; set; } + public AudioTransportCodec? Codec { get; set; } + public int? OpusFrameMilliseconds { get; set; } + public bool? ContinuousAutoTuneEnabled { get; set; } + public int? ContinuousAutoTuneIntervalSec { get; set; } + // Per-route latency settings used in AudioMode.BothIndependent only. MaxLatencyMs + // above continues to govern the WASAPI lane (= the only lane in classic modes), so + // existing profiles keep their current slider value untouched on upgrade. Asio + // companion below holds the ASIO lane's slider; the auto-tune enable companion lets + // the user opt either lane in or out independently. + public int? MaxLatencyMsAsio { get; set; } + public bool? ContinuousAutoTuneAsioEnabled { get; set; } + public List? RememberedPeers { get; set; } + public string? AsioDriverName { get; set; } + // AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is + // derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone. + public SendRate? SendRate { get; set; } + public bool? TightLatencyMode { get; set; } + public int? Smoothness { get; set; } + public ConcealmentArtifact? ConcealmentArtifact { get; set; } + public bool? MuteConnectionCues { get; set; } + } + + private sealed class HotkeySetting + { + public string Key { get; set; } = "M"; + public bool Control { get; set; } + public bool Shift { get; set; } + public bool Alt { get; set; } + + public static HotkeySetting From(HotkeyInfo hotkey) => new() + { + Key = hotkey.Key.ToString(), + Control = hotkey.Control, + Shift = hotkey.Shift, + Alt = hotkey.Alt, + }; + + public HotkeyInfo ToHotkeyInfo() + { + if (!Enum.TryParse(Key, out var parsedKey)) return HotkeyInfo.Default; + var hotkey = new HotkeyInfo(parsedKey, Control, Shift, Alt); + if (hotkey.IsUnset) return HotkeyInfo.Unset; + return hotkey.IsValid ? hotkey : HotkeyInfo.Default; + } + } +} diff --git a/src/RemSound.Core/RenderRoute.cs b/src/RemSound.Core/RenderRoute.cs new file mode 100644 index 0000000..19fdb09 --- /dev/null +++ b/src/RemSound.Core/RenderRoute.cs @@ -0,0 +1,39 @@ +namespace RemSound.Core; + +/// +/// Tag carried in the per-stream wire field, telling the +/// receiver which render backend a particular stream's audio belongs to. In the three classic +/// audio modes (WasapiOnly, AsioOnly, Both) every stream from a sender carries +/// and the receiver's PlayoutEngine mixes them all into one bus +/// that is fanned out to every configured render backend — identical to the pre-2026-05-11 +/// behaviour. +/// +/// The BothIndependent mode (added 2026-05-11) is the reason this exists: in that mode the +/// sender emits *two* streams in parallel — a WASAPI lane at WASAPI's native latency and an +/// ASIO lane at ASIO's native latency. The sender tags each lane with +/// or ; the receiver routes each lane's audio to a separate +/// SessionPlayout group, and each render backend reads only the group it owns. No +/// cross-clock resampler, no tee — each lane stays at its own native latency end-to-end. +/// +/// Wire format: stored as a single byte at offset 32 of the format payload. Receivers that +/// don't understand the field (pre-2026-05-11 builds) parse only the first 32 bytes and +/// behave exactly as before — the new field is purely additive. Receivers that do understand +/// it but receive a 32-byte payload (because the sender is old) default to , +/// also matching the pre-2026-05-11 behaviour. +/// +public enum RenderRoute : byte +{ + /// Legacy / classic behaviour: stream is mixed with every other stream and sent + /// to all render backends. Used by every classic-mode sender lane and is the default + /// when the format-packet Lane field is missing or zero. + Mixed = 0, + + /// Stream belongs to the WASAPI render lane and should only reach WASAPI output + /// devices, bypassing the cross-backend mix. Only emitted by senders in BothIndependent + /// mode. + WasapiLane = 1, + + /// Stream belongs to the ASIO render lane and should only reach ASIO outputs, + /// bypassing the cross-backend mix. Only emitted by senders in BothIndependent mode. + AsioLane = 2, +} diff --git a/src/RemSound.Core/SendRate.cs b/src/RemSound.Core/SendRate.cs new file mode 100644 index 0000000..83a5c69 --- /dev/null +++ b/src/RemSound.Core/SendRate.cs @@ -0,0 +1,22 @@ +namespace RemSound.Core; + +/// +/// How often the sender cuts the audio stream into a packet for transmission. Smaller frames +/// = more packets per second = lower send-side latency, but more network/CPU overhead per +/// second. +/// +/// Mapping per codec: +/// * PCM: Standard = 5 ms (240 samples), Tight = 2.5 ms (120 samples). +/// * Opus 20 ms: Standard = 20 ms, Tight = 10 ms. +/// * Opus 10 ms: Standard = 10 ms, Tight = 5 ms. +/// +/// "Tight" is documented as LAN-only because the smaller frame size means less time for the +/// network to absorb jitter before the next packet arrives. On a stable LAN it cuts ~2.5 ms +/// off the send-side accumulator latency without audible cost; over WAN with typical jitter +/// it'll glitch. +/// +public enum SendRate +{ + Standard = 0, + Tight = 1, +} diff --git a/src/RemSound.Core/WindowsAudioThreadBoost.cs b/src/RemSound.Core/WindowsAudioThreadBoost.cs new file mode 100644 index 0000000..ba6caa5 --- /dev/null +++ b/src/RemSound.Core/WindowsAudioThreadBoost.cs @@ -0,0 +1,65 @@ +using System.Runtime.InteropServices; + +namespace RemSound.Core; + +/// +/// Boosts the calling thread to MMCSS Pro Audio class plus ThreadPriority.Highest. +/// Dispose on the same thread that constructed it. Designed for capture/render/network audio threads. +/// +public sealed class WindowsAudioThreadBoost : IDisposable +{ + private readonly IntPtr avrtHandle; + private readonly ThreadPriority previousPriority; + private readonly int ownerThreadId; + + public WindowsAudioThreadBoost(string taskName) + { + ownerThreadId = Environment.CurrentManagedThreadId; + previousPriority = Thread.CurrentThread.Priority; + Thread.CurrentThread.Priority = ThreadPriority.Highest; + Mode = "ThreadPriority.Highest"; + + if (!OperatingSystem.IsWindows()) return; + + avrtHandle = AvSetMmThreadCharacteristics(taskName, out _); + if (avrtHandle == IntPtr.Zero && !string.Equals(taskName, "Audio", StringComparison.OrdinalIgnoreCase)) + { + avrtHandle = AvSetMmThreadCharacteristics("Audio", out _); + if (avrtHandle != IntPtr.Zero) taskName = "Audio"; + } + + if (avrtHandle != IntPtr.Zero) + { + AvSetMmThreadPriority(avrtHandle, AvrtPriority.High); + Mode = $"MMCSS {taskName}"; + } + } + + public string Mode { get; } + + public void Dispose() + { + if (Environment.CurrentManagedThreadId != ownerThreadId) return; + if (avrtHandle != IntPtr.Zero) AvRevertMmThreadCharacteristics(avrtHandle); + Thread.CurrentThread.Priority = previousPriority; + } + + [DllImport("avrt.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "AvSetMmThreadCharacteristicsW")] + private static extern IntPtr AvSetMmThreadCharacteristics(string taskName, out uint taskIndex); + + [DllImport("avrt.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AvSetMmThreadPriority(IntPtr avrtHandle, AvrtPriority priority); + + [DllImport("avrt.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AvRevertMmThreadCharacteristics(IntPtr avrtHandle); + + private enum AvrtPriority + { + Low = -1, + Normal = 0, + High = 1, + Critical = 2, + } +} diff --git a/src/RemSound.Harness/Program.cs b/src/RemSound.Harness/Program.cs new file mode 100644 index 0000000..5d8e064 --- /dev/null +++ b/src/RemSound.Harness/Program.cs @@ -0,0 +1,180 @@ +using System.Net; +using NAudio.CoreAudioApi; +using RemSound.Core; +using RemSound.Receiver; +using RemSound.Sender; + +if (args.Length == 0 || args[0] is "-h" or "--help" or "help") +{ + PrintUsage(); + return 0; +} + +return args[0].ToLowerInvariant() switch +{ + "send" => RunSend(args), + "recv" or "receive" => RunReceive(args), + "devices" => ListDevices(), + "loopback" => RunLoopback(args), + _ => Unknown(args[0]), +}; + +static int Unknown(string verb) +{ + Console.Error.WriteLine($"Unknown verb '{verb}'."); + PrintUsage(); + return 1; +} + +static void PrintUsage() +{ + Console.WriteLine("RemSound.Harness — minimal command-line test for the new audio engine."); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine(" RemSound.Harness devices"); + Console.WriteLine(" List Windows render devices and their IDs."); + Console.WriteLine(); + Console.WriteLine(" RemSound.Harness send [--opus] [--device ]"); + Console.WriteLine(" Capture the default (or selected) render device and send to the receiver."); + Console.WriteLine(); + Console.WriteLine(" RemSound.Harness recv [--port N] [--device ] [--max-latency N]"); + Console.WriteLine(" Listen for RemSound packets and play them through the default (or selected) device."); + Console.WriteLine(); + Console.WriteLine(" RemSound.Harness loopback [--opus] [--max-latency N]"); + Console.WriteLine(" Run sender + receiver on localhost. Useful for sanity checks; will feed the"); + Console.WriteLine(" default output back into the system, so use headphones and a different render device for the receiver."); + Console.WriteLine(); + Console.WriteLine("Press Ctrl+C to stop in any mode."); +} + +static int ListDevices() +{ + var enumerator = new MMDeviceEnumerator(); + var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active); + Console.WriteLine($"{"State",-8}{"Default",-9}{"Name"}"); + foreach (var device in devices) + { + var marker = device.ID == defaultRender.ID ? "yes" : ""; + Console.WriteLine($"{device.State,-8}{marker,-9}{device.FriendlyName}"); + Console.WriteLine($" ID: {device.ID}"); + device.Dispose(); + } + defaultRender.Dispose(); + return 0; +} + +static int RunSend(string[] args) +{ + if (args.Length < 2) + { + Console.Error.WriteLine("Missing destination. Example: RemSound.Harness send 192.168.1.42:47830"); + return 1; + } + if (!TryParseEndpoint(args[1], out var target)) + { + Console.Error.WriteLine($"Could not parse '{args[1]}' as ip:port."); + return 1; + } + + var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm; + var deviceId = ParseOption(args, "--device"); + + using var sender = new AudioSender(); + if (deviceId is not null) + { + sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, deviceId) }); + } + sender.ConfigureCodec(codec); + sender.SetReceivers(new[] { target }); + sender.Start(); + + Console.WriteLine($"Sending {codec} from \"{sender.CaptureDeviceName}\" → {target}. Press Ctrl+C to stop."); + using var quit = new ManualResetEventSlim(false); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); }; + var lastPackets = 0L; + while (!quit.Wait(1000)) + { + var p = sender.PacketsSent; + var rate = p - lastPackets; + lastPackets = p; + Console.WriteLine($"[send] packets={p} /sec={rate} bytes={sender.BytesSent} uptime={sender.Uptime:hh\\:mm\\:ss}"); + } + sender.Stop(); + return 0; +} + +static int RunReceive(string[] args) +{ + var port = int.TryParse(ParseOption(args, "--port"), out var p) ? p : RemPacket.DefaultPort; + var deviceId = ParseOption(args, "--device"); + var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80; + + using var receiver = new AudioReceiver(); + if (deviceId is not null) receiver.SetOutputDevices(new[] { deviceId }); + receiver.MaxLatencyMs = maxLatency; + receiver.Start(port); + + Console.WriteLine($"Listening on UDP :{port}, output \"{receiver.OutputDeviceName}\", max latency {maxLatency} ms. Press Ctrl+C to stop."); + using var quit = new ManualResetEventSlim(false); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); }; + var lastPackets = 0L; + while (!quit.Wait(1000)) + { + var pk = receiver.PacketsReceived; + var rate = pk - lastPackets; + lastPackets = pk; + Console.WriteLine($"[recv] packets={pk} /sec={rate} buffer={receiver.CurrentBufferMs}ms underruns={receiver.Underruns} drops={receiver.Drops}"); + } + receiver.Stop(); + return 0; +} + +static int RunLoopback(string[] args) +{ + var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm; + var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80; + + using var receiver = new AudioReceiver(); + receiver.MaxLatencyMs = maxLatency; + receiver.Start(); + + using var sender = new AudioSender(); + sender.ConfigureCodec(codec); + sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, RemPacket.DefaultPort) }); + sender.Start(); + + Console.WriteLine($"Loopback running, codec={codec}, max latency={maxLatency} ms. Ctrl+C to stop."); + using var quit = new ManualResetEventSlim(false); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); }; + while (!quit.Wait(1000)) + { + Console.WriteLine($"send pkt={sender.PacketsSent} recv pkt={receiver.PacketsReceived} buf={receiver.CurrentBufferMs}ms under={receiver.Underruns} drop={receiver.Drops}"); + } + sender.Stop(); + receiver.Stop(); + return 0; +} + +static bool TryParseEndpoint(string text, out IPEndPoint endpoint) +{ + endpoint = new IPEndPoint(IPAddress.Loopback, 0); + var split = text.Split(':'); + if (split.Length != 2) return false; + if (!IPAddress.TryParse(split[0], out var ip)) return false; + if (!int.TryParse(split[1], out var port) || port is <= 0 or > 65535) return false; + endpoint = new IPEndPoint(ip, port); + return true; +} + +static string? ParseOption(string[] args, string optionName) +{ + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], optionName, StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; +} diff --git a/src/RemSound.Harness/RemSound.Harness.csproj b/src/RemSound.Harness/RemSound.Harness.csproj new file mode 100644 index 0000000..f9b4096 --- /dev/null +++ b/src/RemSound.Harness/RemSound.Harness.csproj @@ -0,0 +1,19 @@ + + + Exe + net10.0-windows + enable + enable + true + RemSound.Harness + RemSound.Harness + true + + + + + + + + + diff --git a/src/RemSound.Receiver/AsioRenderBackend.cs b/src/RemSound.Receiver/AsioRenderBackend.cs new file mode 100644 index 0000000..f7260a7 --- /dev/null +++ b/src/RemSound.Receiver/AsioRenderBackend.cs @@ -0,0 +1,244 @@ +using NAudio.Wave; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// ASIO render backend. Drives a single for the chosen ASIO driver, +/// pulling the receiver's mixed stereo audio from and broadcasting +/// it across one or more output channel pairs of the driver. Same shape as +/// : doesn't care which is active. +/// +/// Spec identity: each output ID is a synthetic "asio:<channel-pair-index>". Pair 0 +/// = ASIO output channels 0+1, pair 1 = 2+3, etc. The driver itself is locked at construction. +/// +/// Same simplifications as : 48 kHz fixed; driver is single +/// per session. Always opens the AsioOut with the driver's full output channel count so that +/// adding/removing channel pairs never requires reopening the driver — important when the +/// sender and receiver are both holding the same single-client driver (Komplete Audio etc.): +/// reopening one while the other is alive caused 15-second freezes. +/// +internal sealed class AsioRenderBackend : IRenderBackend +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + + // Same reasoning as MultiOutputPlayout — source typed as IWaveProvider so the composite + // backend can hand us a tee'd buffer. + private readonly IWaveProvider source; + private readonly Action? onDiagnostic; + private readonly string driverName; + private readonly object gate = new(); + + private AsioOut? asio; + private List activeChannelPairs = []; + private BroadcastProvider? broadcaster; + + public AsioRenderBackend(string driverName, IWaveProvider source, Action? onDiagnostic = null) + { + this.driverName = driverName; + this.source = source; + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => asio is not null; + + public string ActiveDeviceSummary + { + get + { + lock (gate) + { + if (activeChannelPairs.Count == 0) return "(none)"; + var names = activeChannelPairs.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}").ToList(); + if (names.Count <= 3) return string.Join(", ", names); + return $"({names.Count} ASIO outputs)"; + } + } + } + + public IReadOnlyList ActiveDeviceIds + { + get { lock (gate) return activeChannelPairs.Select(AsioDeviceId.Format).ToList(); } + } + + public void Start() + { + // ASIO render starts lazily when SetOutputDevices is given a non-empty list. There's no + // useful "open driver but render to nothing" state — that just locks the device with no + // benefit. The MixingEngine equivalent (producer loop) for WASAPI runs continuously + // even with zero outputs to keep state alive; ASIO doesn't need that since the AsioOut + // *is* the output and there's nothing to keep alive when no channels are wanted. + // Caller is expected to call SetOutputDevices first; this method is a no-op when empty. + lock (gate) + { + if (IsRunning) return; + if (activeChannelPairs.Count == 0) return; + OpenAsioLocked(); + } + } + + public void Stop() + { + lock (gate) StopInternal(); + } + + public void SetOutputDevices(IReadOnlyList deviceIds) + { + lock (gate) + { + var newPairs = ParsePairs(deviceIds); + if (newPairs.Count == 0) + { + if (IsRunning) StopInternal(); + activeChannelPairs = newPairs; + return; + } + + activeChannelPairs = newPairs; + + // First time we have any pairs → open the driver. Otherwise we never reopen on a + // pair-set change, because we already opened with the driver's full channel count + // at Start time. Just update the broadcaster's pair list and we're done. + if (asio is null) + { + OpenAsioLocked(); + return; + } + broadcaster?.SetActivePairs(activeChannelPairs); + onDiagnostic?.Invoke($"asio render: pairs updated to {string.Join(",", activeChannelPairs)} (no driver restart)"); + } + } + + private void OpenAsioLocked() + { + try + { + asio = new AsioOut(driverName); + // Always open with the driver's full output channel count. Channels we don't + // immediately broadcast to are zero-filled by BroadcastProvider, which is + // essentially free. Trades a tiny bit of buffer memory for a big stability win: + // adding or removing an output pair never reopens the driver — see the type + // doc-comment for why this matters with single-client drivers. + var outputChannelCount = asio.DriverOutputChannelCount; + if (outputChannelCount <= 0) + { + onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" reports zero output channels"); + StopInternal(); + return; + } + // Sanity-check requested pairs are in range; warn if not but continue (out-of-range + // pairs simply get no audio). + var maxPair = activeChannelPairs.Max(); + var highestNeededChannel = (maxPair + 1) * 2; + if (highestNeededChannel > outputChannelCount) + { + onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" only has {outputChannelCount} output channels, but spec requests pair {maxPair} (channels {maxPair * 2 + 1}/{maxPair * 2 + 2})"); + } + broadcaster = new BroadcastProvider(source, outputChannelCount, activeChannelPairs); + asio.ChannelOffset = 0; + asio.Init(broadcaster); + asio.Play(); + onDiagnostic?.Invoke($"asio render started \"{driverName}\" {MixSampleRate} Hz, {outputChannelCount} output channel(s); pairs={string.Join(",", activeChannelPairs)}"); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"asio render start failed: {ex.GetType().Name}: {ex.Message}"); + StopInternal(); + } + } + + private void StopInternal() + { + if (asio is not null) + { + try { asio.Stop(); } catch { /* ignore */ } + try { asio.Dispose(); } catch { /* ignore */ } + asio = null; + } + broadcaster = null; + } + + public void Dispose() => Stop(); + + private static List ParsePairs(IReadOnlyList deviceIds) + { + var result = new List(); + foreach (var id in deviceIds) + { + if (AsioDeviceId.TryParse(id, out var pair) && pair >= 0) + { + result.Add(pair); + } + } + result.Sort(); + return result.Distinct().ToList(); + } + + /// + /// Wave provider that pulls stereo audio from and writes it to + /// a multi-channel ASIO buffer at the requested channel pair positions, zero-filling the + /// channels that aren't selected. Output is interleaved 32-bit float at 48 kHz, exactly + /// what NAudio's AsioOut wants. + /// + private sealed class BroadcastProvider : IWaveProvider + { + private readonly IWaveProvider source; + private readonly int outputChannelCount; + private byte[] sourceScratchBytes = new byte[16384]; + private List activePairs; + + public WaveFormat WaveFormat { get; } + + public BroadcastProvider(IWaveProvider source, int outputChannelCount, List activePairs) + { + this.source = source; + this.outputChannelCount = outputChannelCount; + this.activePairs = new List(activePairs); + WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, outputChannelCount); + } + + public void SetActivePairs(IEnumerable pairs) + { + // Atomic swap. Read side reads activePairs once per Read so a partial swap is + // tolerable — at worst we get one tick of stale routing. + activePairs = pairs.ToList(); + } + + public int Read(byte[] buffer, int offset, int count) + { + // Frame size in BYTES on the output side. + var bytesPerOutputFrame = outputChannelCount * sizeof(float); + var frames = count / bytesPerOutputFrame; + if (frames <= 0) return 0; + + // Pull stereo from PlayoutEngine — its WaveFormat is 48k stereo float, so 8 bytes + // per frame. + var sourceBytes = frames * MixChannels * sizeof(float); + if (sourceScratchBytes.Length < sourceBytes) sourceScratchBytes = new byte[sourceBytes]; + source.Read(sourceScratchBytes, 0, sourceBytes); + + // Interpret source bytes as float array, output bytes as float array, broadcast. + var srcFloats = System.Runtime.InteropServices.MemoryMarshal.Cast(sourceScratchBytes.AsSpan(0, sourceBytes)); + var dstFloats = System.Runtime.InteropServices.MemoryMarshal.Cast(buffer.AsSpan(offset, count)); + dstFloats.Clear(); + + var pairs = activePairs; + for (var f = 0; f < frames; f++) + { + var l = srcFloats[f * MixChannels]; + var r = srcFloats[f * MixChannels + 1]; + var dstFrameStart = f * outputChannelCount; + foreach (var pair in pairs) + { + var lCh = pair * 2; + var rCh = pair * 2 + 1; + if (lCh < outputChannelCount) dstFloats[dstFrameStart + lCh] = l; + if (rCh < outputChannelCount) dstFloats[dstFrameStart + rCh] = r; + } + } + + return count; + } + } +} diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs new file mode 100644 index 0000000..f0c0632 --- /dev/null +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -0,0 +1,803 @@ +using System.Diagnostics; +using System.Net; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Public façade for the receiver pipeline. Routes raw packets from +/// to one per remote sender, all of which write to their own +/// ; the then mixes those at render time. +/// +/// Multi-source rationale: the previous design held a single activeSession and reset the +/// playout buffer whenever a Format packet arrived from a different endpoint. With two senders +/// transmitting to the same receiver simultaneously (peer-to-peer plus a localhost-monitor, or +/// a future conferencing setup), Format packets alternated and the buffer flushed several times +/// per second — the crackle the WAN test surfaced. Now each endpoint gets its own session and +/// playout state, all summed at the render output. +/// +/// Idle sessions are pruned: any session that hasn't received audio data in +/// is removed by , called by the +/// App's snapshot tick. +/// +/// Responsibilities deliberately scoped: +/// * Lifecycle (Start / Stop / Dispose). +/// * Public configuration (max latency, volume, mute, output device). +/// * Routing packets to the right session, creating sessions for new endpoints. +/// +public sealed class AudioReceiver : IDisposable +{ + public const int MixSampleRate = 48000; + public const int MixChannels = 2; + private const int MixBytesPerSecond = MixSampleRate * MixChannels * sizeof(float); + + /// How big each session's AudioRingBuffer is sized — enough to absorb burst arrival + /// over the maximum supported latency without dropping. Values much above the user-set max + /// latency just waste memory; below it can drop on a deep WAN burst. + private const int CapacityHeadroomMultiplier = 8; + private const int MaxLatencyForSizingMs = 500; + + /// Sessions that have received nothing for this long are pruned. Long enough that a + /// brief silent gap (mute / no input) doesn't kill the session, short enough that a peer that + /// truly stops sending doesn't keep occupying state forever (and inflating the underrun + /// counter — every render read of an empty-but-armed session bumps the underrun count even + /// though the mix output is unaffected). + public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(4); + + private readonly Stopwatch uptime = new(); + private readonly ReceiverDiagnostics diagnostics = new(); + private readonly PlayoutEngine playoutEngine; + private IRenderBackend multiOutput; + private readonly NetworkListener listener; + private Action? diagnosticSink; + + private readonly object sessionsLock = new(); + // Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. A peer can produce + // multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native- + // independent audio mode). For single-lane modes the sender emits one streamId so + // the dict still has one entry per peer, identical to the pre-refactor behaviour. + private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), StreamSession> sessions = new(); + + /// When false (the default), a Format packet arriving with a NEW streamId from + /// a peer that already has a session under a DIFFERENT streamId triggers immediate + /// disposal of the old session — preserves the pre-refactor "one peer = one active + /// session" behaviour. The sender legitimately rotates streamId on codec changes / + /// engine restarts; without this, the old SessionPlayout sits empty for 4 seconds + /// until fires, racking up phantom underrun counts + /// from the render thread polling its empty buffer (~100 per second). + /// + /// Set true in the native-independent audio mode (Stage 4) where two streamIds from + /// the same peer are expected to coexist (WASAPI lane + ASIO lane). In that mode the + /// auto-dispose-old-on-new-streamId is wrong — both lanes are continuously active. + public bool AllowMultipleStreamsPerPeer { get; set; } + + // True when audio playback is enabled — i.e. multiOutput is started and Format/Audio + // packets should be processed into sessions. False means the listener stays bound + // (so the single-port heartbeat path keeps working) but audio packets are discarded + // before any decode/buffer work, and no SessionPlayout is created. Volatile because + // packet handlers run on the network thread and may observe a SetPlaybackEnabled + // toggle at any moment. See the single-port unification (2026-05-06): the listener + // is bound for the duration of a connection so heartbeat packets always reach + // OnHeartbeatReceived, regardless of the user's "Receive audio" tick state. + private volatile bool playbackEnabled; + + // Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches + // the playout. A null set means "no filter" (legacy behaviour). An empty set means "block + // everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry + // the sender's *outbound* (ephemeral) source port, not the port we'd see in their + // announcement — comparing port-included would always fail. The peer is identified by + // machine IP; we accept audio from any source port on that IP. Read on the network thread, + // updated from the UI thread via SetAllowedSenders. + private volatile HashSet? allowedSenders; + + private long packetsReceived; + private long bytesReceived; + private long packetsDropped; + private long packetsRejectedNotAllowed; + + public AudioReceiver() + { + playoutEngine = new PlayoutEngine(diagnostics); + multiOutput = new CompositeRenderBackend(AudioMode.WasapiOnly, null, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}")); + listener = new NetworkListener(HandleRawPacket, msg => diagnosticSink?.Invoke($"network: {msg}")); + } + + /// + /// Sets the audio backend mode (and ASIO driver, when ASIO is involved) for the render side. + /// Mirrors AudioSender.SetAudioMode. The App should re-issue SetOutputDevices afterwards with + /// the current device-id selection. + /// + public void SetAudioMode(AudioMode mode, string? asioDriverName) + { + var wasRunning = multiOutput.IsRunning; + try { multiOutput.Stop(); } catch { /* ignore */ } + try { multiOutput.Dispose(); } catch { /* ignore */ } + multiOutput = new CompositeRenderBackend(mode, asioDriverName, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}")); + if (wasRunning) multiOutput.Start(); + } + + public bool IsAsioBackend => multiOutput is CompositeRenderBackend; + + /// Sets the Buffer-smoothness knob (1 = aggressive — clicks the buffer back + /// to target on any drift, holds the user's latency tightly; 10 = smooth — no clicks + /// but the queue can creep up under jitter or sustained clock drift). Knob drives a + /// click-based DropOldest trim in . As of the + /// 2026-05-06 cleanup (Phase 3) this is mostly a safety knob — the Phase-2 drift + /// corrector keeps the buffer near target so the trim should rarely fire regardless of + /// this value. + public void SetSmoothness(int value) => playoutEngine.SetSmoothness(value); + + /// Sets the concealment artifact used when the playout buffer comes up empty + /// on a render-side read. Pure receiver-side cosmetic — sender doesn't see this. + /// Live: takes effect on the next underrun, no need to restart playback. + public void SetConcealmentArtifact(ConcealmentArtifact artifact) => + playoutEngine.SetConcealmentArtifact(artifact); + + /// + /// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set + /// to block all (the user has selected no peers); pass null to disable filtering and accept + /// everyone (test/diagnostic only — production UI always passes a real set). + /// + /// Why this exists: without an allow-list, anyone who can reach our UDP port (e.g. a peer + /// who has us in *their* selected list, or a stale broadcast announcement that another + /// instance starts honouring) gets their audio rendered to our speakers automatically. The + /// user expects audio to play only after they explicitly tick a peer's checkbox; this gate + /// implements that contract. + /// + /// The filter is applied at packet receipt — Format and Audio packets from non-allowed + /// endpoints are counted but discarded, no SessionPlayout is created, no playout buffer + /// fills. Discovery and heartbeat (separate UDP ports) are unaffected, so non-allowed + /// peers still appear as "discovered" in the UI ready to be ticked. + /// + public void SetAllowedSenders(IEnumerable? allowed) + { + // Reduce IPEndPoint inputs to bare IPAddress for the gate; see field-comment for why. + var snapshot = allowed is null ? null : new HashSet(allowed.Select(ep => ep.Address)); + allowedSenders = snapshot; + // Tear down sessions for endpoints that just got removed from the allow-list — without + // this, audio would keep playing from a session that was opened before the user + // unticked its checkbox. Match by IP since that's how the gate works. + if (snapshot is not null) + { + List toClose = []; + lock (sessionsLock) + { + foreach (var (key, session) in sessions) + { + if (!snapshot.Contains(key.Endpoint.Address)) + { + toClose.Add(session); + } + } + foreach (var session in toClose) + { + sessions.Remove((session.Endpoint, session.StreamId)); + } + } + foreach (var session in toClose) + { + playoutEngine.RemoveSession(session.Endpoint, session.StreamId); + session.Dispose(); + diagnosticSink?.Invoke($"stream session closed (sender no longer in selected peers): {session.Endpoint} stream={session.StreamId}"); + } + } + } + + /// Cumulative count of audio/format packets dropped because the sender wasn't in + /// the allow-list. Surfaced via diagnostics so we can confirm the filter is working. + public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed); + + private bool IsSenderAllowed(IPEndPoint remote) + { + var snapshot = allowedSenders; + if (snapshot is null) return true; // null = no filter + return snapshot.Contains(remote.Address); + } + + /// Optional diagnostic sink (App writes to log file). + public Action? Diagnostic { get => diagnosticSink; set => diagnosticSink = value; } + + /// True when audio playback is active — i.e. + /// has been called with true and the underlying render backend is running. This + /// matches the previous semantic of "the user has Receive audio on and we're rendering". + /// The UDP listener socket is NOT covered by this flag — see . + /// In single-port mode (post-2026-05-06) the listener stays bound for the whole connection + /// so heartbeat packets always reach us; this flag tracks only the playback half. + public bool IsRunning => multiOutput.IsRunning; + /// True when the UDP listener socket is bound. Independent of playback state. + /// Surfaced for diagnostic/symmetry only — most callers want . + public bool IsListenerRunning => listener.IsRunning; + /// Max time-in-user-handler (the work between Socket.ReceiveFrom returning and + /// onPacket finishing) observed since the last call. The SNAP loop reads this each + /// second to split observed inter-packet jitter into network vs receiver-processing + /// contributions. Resets on read. + public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs(); + + /// Worst FanOutSource cache-occupancy seen since the last call, expressed in + /// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the + /// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a + /// render lane is holding samples (slow consumer holding back compaction, or the fast + /// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets + /// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly + /// inflating latency on either lane. + public int TakeMaxFanOutCacheMs() + { + // 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec. + const int MixBytesPerSecond = 48000 * 2 * 4; + var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0; + return bytes * 1000 / MixBytesPerSecond; + } + public string OutputDeviceName => multiOutput.ActiveDeviceSummary; + public int CurrentBufferMs => playoutEngine.CurrentBufferMs; + public int TargetLatencyMs => playoutEngine.TargetLatencyMs; + + /// + /// Frame duration of the most-recently-active stream (10 ms PCM, 20 ms Opus). null when no + /// stream is active. With multiple senders this picks the largest frame duration as the + /// codec floor — most conservative for the auto-tune. + /// + public int? ActiveStreamFrameMs + { + get + { + lock (sessionsLock) + { + if (sessions.Count == 0) return null; + var maxFrame = 0; + foreach (var s in sessions.Values) + { + if (s.Format.FrameDurationMilliseconds > maxFrame) maxFrame = s.Format.FrameDurationMilliseconds; + } + return maxFrame; + } + } + } + + /// Aggregate count across all active PCM sessions of frames the assembler rejected. + /// Resets per-session when a session ends; the receiver-level number is the live sum. + public long PcmFrameRejections + { + get + { + lock (sessionsLock) + { + long total = 0; + foreach (var s in sessions.Values) total += s.PcmFrameRejections; + return total; + } + } + } + + public long PcmFrameDiscardedPartials + { + get + { + lock (sessionsLock) + { + long total = 0; + foreach (var s in sessions.Values) total += s.PcmFrameDiscardedPartials; + return total; + } + } + } + + public int MaxLatencyMs + { + get => playoutEngine.MaxLatencyMs; + set => playoutEngine.SetMaxLatencyMs(value); + } + + /// Soft variant: same as setting MaxLatencyMs, but on a LOWER does not drain + /// the buffer / disarm the session. The drift corrector's adaptive gain shrinks the + /// buffer gradually over a few seconds instead. Used by auto-tune so its slider + /// adjustments are inaudible — the user didn't ask for an immediate change and shouldn't + /// hear one. On a RAISE behaves identically to the regular setter (no drain ever fires + /// on raise). + public void SetMaxLatencyMsSoft(int value) => + playoutEngine.SetMaxLatencyMs(value, drainOnLower: false); + + /// Per-route latency accessors — used in BothIndependent mode where the WASAPI + /// lane and the ASIO lane each have their own slider. In classic modes only the Mixed + /// route has sessions, so the route-specific values are configured but never observed. + public int MaxLatencyMsFor(RenderRoute route) => playoutEngine.MaxLatencyMsFor(route); + public int TargetLatencyMsFor(RenderRoute route) => playoutEngine.TargetLatencyMsFor(route); + public void SetMaxLatencyMsFor(RenderRoute route, int value) => + playoutEngine.SetMaxLatencyMs(route, value); + public void SetMaxLatencyMsSoftFor(RenderRoute route, int value) => + playoutEngine.SetMaxLatencyMs(route, value, drainOnLower: false); + /// Per-route underrun count for the auto-tune skip-while-underrunning gate. In + /// BothIndependent the WASAPI lane's underruns should not make the ASIO auto-tune defer + /// (and vice versa); reading per-route fixes that. + public long UnderrunsFor(RenderRoute route) => playoutEngine.AggregateUnderrunsFor(route); + /// True when at least one stream session is currently tagged for this route — + /// used by MainForm's continuous auto-tune to skip routes with no audio in flight, so a + /// lane's auto-tune can't pre-inflate its target by reacting to shared network-gap data + /// from a different lane's packets. + public bool HasSessionsForRoute(RenderRoute route) => playoutEngine.HasSessionsForRoute(route); + + public long Underruns => playoutEngine.AggregateUnderruns; + public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped); + + /// Per-cause split of the legacy `Drops` rollup. Useful in the diag log to tell + /// "we deliberately trimmed the buffer to track the latency target" (TrimDropBytes) from + /// "we got malformed packets" (PacketsRejectedMalformed) from "ringbuffer overflowed and + /// the producer dropped oldest" (RingbufferOverflowDropBytes). Without this split a single + /// "Drops" value couldn't tell us which mechanism was firing. + public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes; + public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes; + public long TrimFireCount => playoutEngine.AggregateTrimFireCount; + /// Phase-2 drift correction counters: how many single stereo frames have been + /// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout + /// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible. + public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames; + public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames; + /// RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain + /// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or + /// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't + /// keep up", as opposed to "we deliberately reshaped the buffer". + public long RingbufferOverflowDropBytes + => Math.Max(0, playoutEngine.AggregateDrops - TrimDropBytes - DrainDropBytes); + public long PacketsRejectedMalformed => Interlocked.Read(ref packetsDropped); + + public long PacketsReceived => Interlocked.Read(ref packetsReceived); + public long BytesReceived => Interlocked.Read(ref bytesReceived); + public TimeSpan Uptime => uptime.Elapsed; + + /// Total times we used Opus inband FEC to recover a single-packet gap, across all active sessions. + public long OpusFecRecoveries + { + get + { + long total = 0; + lock (sessionsLock) + { + foreach (var s in sessions.Values) total += s.OpusFecRecoveries; + } + return total; + } + } + + /// Total times we saw a multi-packet gap that FEC could not fill, across all active sessions. + public long OpusUnrecoveredGaps + { + get + { + long total = 0; + lock (sessionsLock) + { + foreach (var s in sessions.Values) total += s.OpusUnrecoveredGaps; + } + return total; + } + } + + public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; } + public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; } + + /// + /// Sets the list of output devices to render received audio to. The receiver mixes once and + /// fans out to every device in this list — pass an empty list to mute all output without + /// stopping the receive path. Per session policy, the App does NOT persist this selection; + /// every session starts with no outputs ticked. + /// + public void SetOutputDevices(IReadOnlyList deviceIds) => multiOutput.SetOutputDevices(deviceIds); + + /// Take a snapshot of the rolling diagnostic counters. Caller drives at 1 Hz. + public ReceiverDiagnostics.DiagSnapshot TakeDiagnosticsSnapshot() => diagnostics.Take(MixBytesPerSecond); + + /// + /// Bind the UDP listener socket on . Does NOT start audio + /// playback — call (true) for that. Splitting these + /// lets the single-port heartbeat path keep working while the user has "Receive audio" + /// off: the socket stays bound so heartbeat packets reach , + /// but Format/Audio packets are discarded at receipt (no decode, no buffer growth). + /// + public void Start(int udpPort = RemPacket.DefaultPort) + { + if (listener.IsRunning) return; + + Interlocked.Exchange(ref packetsReceived, 0); + Interlocked.Exchange(ref bytesReceived, 0); + Interlocked.Exchange(ref packetsDropped, 0); + + // Tear down any sessions left over from a previous Start (in case Stop wasn't called). + DisposeAllSessionsLocked(); + playoutEngine.ResetAll(); + + listener.Start(udpPort); + uptime.Restart(); + } + + /// + /// Toggles audio playback on or off. When goes false, the + /// render backend is stopped and any open sessions are disposed (so a re-enable doesn't + /// drain stale audio). Heartbeat packet routing is unaffected — the listener stays + /// bound either way as long as has been called. Idempotent. + /// + public void SetPlaybackEnabled(bool enabled) + { + if (enabled == multiOutput.IsRunning) + { + playbackEnabled = enabled; + return; + } + if (enabled) + { + // Reset packet handlers' gate before starting the backend, so packets that arrive + // between multiOutput.Start and the next handler invocation aren't misrouted. + playbackEnabled = true; + multiOutput.Start(); + } + else + { + // Flip the gate first so HandleFormat/HandleAudio stop opening new sessions, then + // tear down the backend and any in-flight sessions. Order matters — if we stopped + // the backend first, in-flight packets could open a fresh session that nothing + // would ever drain. + playbackEnabled = false; + multiOutput.Stop(); + lock (sessionsLock) + { + DisposeAllSessionsLocked(); + } + playoutEngine.ResetAll(); + } + } + + public void Stop() + { + listener.Stop(); + playbackEnabled = false; + multiOutput.Stop(); + uptime.Stop(); + lock (sessionsLock) + { + DisposeAllSessionsLocked(); + } + playoutEngine.ResetAll(); + } + + public void Dispose() + { + Stop(); + listener.Dispose(); + multiOutput.Dispose(); + } + + /// + /// Drop sessions that haven't received audio data in . Caller + /// (the App's snapshot tick) drives this so it stays serialised with the network thread on + /// the same lock the packet handlers use. + /// + public void PruneIdleSessions() + { + var now = DateTime.UtcNow; + List<(IPEndPoint Endpoint, ushort StreamId)>? toRemove = null; + lock (sessionsLock) + { + foreach (var (key, session) in sessions) + { + // Match SessionPlayout by full key so two streams from the same peer don't + // share a single SessionPlayout entry. ActiveSessions iteration is small + // (one per active stream). + var sp = playoutEngine.ActiveSessions.FirstOrDefault(x => + x.Endpoint.Equals(key.Endpoint) && x.StreamId == key.StreamId); + if (sp is null) continue; + if (now - sp.LastWriteUtc <= SessionIdleTimeout) continue; + toRemove ??= []; + toRemove.Add(key); + } + if (toRemove is not null) + { + foreach (var key in toRemove) + { + if (sessions.Remove(key, out var session)) session.Dispose(); + playoutEngine.RemoveSession(key.Endpoint, key.StreamId); + diagnosticSink?.Invoke($"stream session pruned (idle): {key.Endpoint} stream={key.StreamId}"); + } + } + } + } + + private void DisposeAllSessionsLocked() + { + foreach (var s in sessions.Values) s.Dispose(); + sessions.Clear(); + } + + /// + /// Whether we have a recent audio stream session from the given peer IP. "Recent" matches the + /// playout-engine's idle-prune timeout — i.e. a session whose last write is within + /// . Compares on IP only, not port (incoming packets carry the + /// sender's outbound source port, which won't equal their announced audio port). Lockless and + /// safe to call from any thread. + /// + public bool IsReceivingFromAddress(IPAddress address) + { + var now = DateTime.UtcNow; + foreach (var sp in playoutEngine.ActiveSessions) + { + if (!sp.Endpoint.Address.Equals(address)) continue; + if (now - sp.LastWriteUtc <= SessionIdleTimeout) return true; + } + return false; + } + + /// + /// The codec format being received from the given peer IP, or null if no recent session. + /// Useful for surfacing "we're receiving Opus 10ms from this peer" in the UI. + /// + public AudioFormatInfo? ActiveFormatFromAddress(IPAddress address) + { + var now = DateTime.UtcNow; + SessionPlayout? freshest = null; + foreach (var sp in playoutEngine.ActiveSessions) + { + if (!sp.Endpoint.Address.Equals(address)) continue; + if (now - sp.LastWriteUtc > SessionIdleTimeout) continue; + if (freshest is null || sp.LastWriteUtc > freshest.LastWriteUtc) freshest = sp; + } + if (freshest is null) return null; + lock (sessionsLock) + { + if (sessions.TryGetValue((freshest.Endpoint, freshest.StreamId), out var session)) + { + return session.Format; + } + } + return null; + } + + // === Packet routing (called on network thread) === + + /// Hook for Heartbeat packets that arrive on the audio receiver's socket. The + /// App wires this to . Set this *before* + /// starting the receiver, otherwise heartbeats arriving on this socket will be silently + /// dropped as unknown packet type. In single-port mode (the only mode since 2026-05-06) + /// every heartbeat reaches us via this hook — the audio sender writes to the peer's + /// audio port, which is this receiver's bound socket; there is no separate heartbeat + /// socket on either end any more. + public Action? OnHeartbeatReceived { get; set; } + + /// Hook for Control packets that arrive on the audio receiver's socket. The + /// App wires this to a handler that validates the source against the allow-list (the + /// peer must be in the user's selected-peers set), checks the user's "accept remote + /// volume commands" preference, and applies the requested change to the local volume + /// slider. Set this BEFORE starting the receiver; null = packet is silently dropped. + /// Travels on the same UDP socket as audio + heartbeat (single-port model 2026-05-07). + public Action? OnRemoteControlReceived { get; set; } + + private void HandleRawPacket(byte[] packet, int length, IPEndPoint remote) + { + Interlocked.Increment(ref packetsReceived); + Interlocked.Add(ref bytesReceived, length); + + var packetSpan = packet.AsSpan(0, length); + if (!RemPacket.TryReadHeader(packetSpan, out var type, out var streamId, out var sequence)) + { + Interlocked.Increment(ref packetsDropped); + return; + } + + var payload = packetSpan[RemPacket.HeaderSize..]; + switch (type) + { + case RemPacketType.Format: + HandleFormat(remote, streamId, payload); + break; + case RemPacketType.Audio: + HandleAudio(remote, streamId, sequence, payload); + break; + case RemPacketType.KeepAlive: + // Informational only at this layer. + break; + case RemPacketType.Heartbeat: + // Route to the heartbeat service via the App-supplied delegate. In single-port + // mode this is the primary inbound path for heartbeats (the heartbeat service + // no longer binds its own socket). The hook MUST be wired before Start(); + // otherwise heartbeats are dropped and peer health stays "unreachable". + OnHeartbeatReceived?.Invoke(packet, length, remote); + break; + case RemPacketType.Control: + // Remote-control message (volume up/down, mute toggle). Parse the payload + // here so the handler doesn't need to know about RemPacket layout. Caller + // is expected to gate on allow-list AND the user's opt-in preference. + if (RemPacket.TryReadControl(payload, out var ctrlKind, out var ctrlDelta)) + { + OnRemoteControlReceived?.Invoke(ctrlKind, ctrlDelta, remote); + } + else + { + Interlocked.Increment(ref packetsDropped); + } + break; + default: + Interlocked.Increment(ref packetsDropped); + break; + } + } + + /// + /// Inject a packet that arrived on a non-listener socket (e.g. the AudioSender's socket + /// in relay mode). Runs the same dispatch logic as the listener thread. Caller is + /// responsible for filtering out packet types it has handled itself (typically Heartbeat, + /// which goes to ) — passing a Heartbeat packet here is + /// safe (it'll be counted and dropped) but wasteful. + /// + public void InjectExternalPacket(byte[] packet, int length, IPEndPoint remote) + { + HandleRawPacket(packet, length, remote); + } + + private void HandleFormat(IPEndPoint remote, ushort streamId, ReadOnlySpan payload) + { + // Single-port mode: the listener stays bound when playback is off (so heartbeats + // keep flowing on the same socket), but Format/Audio are dropped without opening a + // session. Doing this BEFORE the format-parse keeps the malformed-packet counter + // honest — disabled-playback drops aren't a malformedness signal. + if (!playbackEnabled) return; + + if (!RemPacket.TryReadFormat(payload, out var format)) + { + Interlocked.Increment(ref packetsDropped); + return; + } + + if (!IsSenderAllowed(remote)) + { + // Sender isn't in the user's selected-peers set. Don't open a session, don't play + // their audio. They'll appear in discovery / heartbeat as a peer the user can tick + // if they want; until then, silence on our side. Counted separately so it shows in + // diagnostics without inflating the generic "drops" stat. + Interlocked.Increment(ref packetsRejectedNotAllowed); + return; + } + + SessionPlayout sp; + StreamSession? newSession = null; + bool isNewSession = false; + bool isFormatChange = false; + // Older sessions from the same peer that are being replaced because we're in + // single-stream mode (AllowMultipleStreamsPerPeer=false) and the sender rotated + // its streamId (codec change / engine restart). Disposed AFTER releasing the + // sessionsLock so their tear-down doesn't extend the critical section. + List? supersededByStreamIdChange = null; + + var key = (remote, streamId); + lock (sessionsLock) + { + sessions.TryGetValue(key, out var existing); + if (existing is not null && existing.MatchesFormat(remote, streamId, format)) + { + return; // same session; nothing to do + } + + sp = playoutEngine.GetOrCreateSession(remote, streamId, MaxBufferCapacityBytes(MaxLatencyForSizingMs)); + // Tag the session with the wire-announced render route. For classic-mode senders + // (or pre-2026-05-11 builds) this is always Mixed and PlayoutEngine treats the + // session exactly as it always did. BothIndependent senders will tag their two + // lanes with WasapiLane / AsioLane so the per-route surfaces direct each lane to + // the matching render backend without mixing. Updated unconditionally so an + // in-place format change can re-route a session (e.g. a sender that mistakenly + // started in classic mode and re-announces with the right lane mid-stream). + sp.Route = format.Lane; + + if (existing is null) + { + isNewSession = true; + } + else + { + // Same (endpoint, streamId), different format (codec change within the same lane). + // Replace the StreamSession but keep its SessionPlayout — buffered audio drains + // naturally and avoids a gap. Matches the behaviour the single-source code + // preserved for codec switches. + existing.Dispose(); + isFormatChange = true; + } + + newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs)); + sessions[key] = newSession; + + // Same-lane streamId rotation: drop other sessions from this peer that share the + // SAME render route as the new format. The sender rotates streamId on codec + // changes and engine restarts; the old session sits empty otherwise, racking up + // phantom underruns from render-thread polling. The lane-match qualifier is + // critical for BothIndependent mode (added 2026-05-11) where the same peer + // legitimately produces TWO concurrent streamIds — one per lane — and each lane's + // Format-resend packets must NOT supersede the other lane's session. Without the + // lane match, the two lanes' 250 ms format announces took turns killing each + // other 8× per second, neither lane could stay alive long enough to arm, and + // BothIndependent appeared to "produce no audio" on the receiver. AllowMultiple- + // StreamsPerPeer is preserved as an override knob (default false) for unusual + // setups; even with it true, lane-mismatched sessions would still coexist, so the + // flag now only governs same-lane-different-streamId behaviour. + if (!AllowMultipleStreamsPerPeer) + { + foreach (var (otherKey, otherSession) in sessions) + { + if (otherKey.Endpoint.Equals(remote) + && otherKey.StreamId != streamId + && otherSession.Format.Lane == format.Lane) + { + supersededByStreamIdChange ??= []; + supersededByStreamIdChange.Add(otherSession); + } + } + if (supersededByStreamIdChange is not null) + { + foreach (var s in supersededByStreamIdChange) + { + sessions.Remove((s.Endpoint, s.StreamId)); + } + } + } + } + + if (supersededByStreamIdChange is not null) + { + foreach (var s in supersededByStreamIdChange) + { + playoutEngine.RemoveSession(s.Endpoint, s.StreamId); + s.Dispose(); + diagnosticSink?.Invoke($"stream session superseded (sender rotated streamId): {s.Endpoint} oldStream={s.StreamId} newStream={streamId}"); + } + } + + if (isNewSession) + { + // Reset the global inter-packet / inter-render-callback gap timers. If we don't, + // the first audio packet of this new session records a gap measured from the LAST + // packet of the previous session — which on a mode switch or codec change can be + // tens of seconds of user-idle time. That bogus gap then feeds the auto-tune's + // recent-gap window and makes it recommend an absurd latency target (e.g. 27 s + // observed → recommendation clamped to 200 ms hard cap → fresh session never + // arms because its buffer can't reach 200 ms before underrun). 2026-05-11 fix. + diagnostics.ResetGapMeasurements(); + Interlocked.Increment(ref sessionsOpenedCount); + diagnosticSink?.Invoke($"stream session opened: {remote} stream={streamId} {format}"); + } + else if (isFormatChange) + { + diagnosticSink?.Invoke($"stream format changed: {remote} stream={streamId} {format}"); + } + } + + private long sessionsOpenedCount; + /// + /// Monotonic count of new StreamSession instances opened since this receiver + /// started. Exposed so the App can detect a fresh session and reset its rolling + /// observation windows (recentMaxGaps etc.) — see the matching reset in MainForm's + /// SNAP loop. Increments only on truly-new sessions, not on format-change-keep-buffer. + /// + public long SessionsOpenedCount => Interlocked.Read(ref sessionsOpenedCount); + + private void HandleAudio(IPEndPoint remote, ushort streamId, uint sequence, ReadOnlySpan payload) + { + // See HandleFormat — same single-port gate. We drop Audio packets silently when + // playback is off; the underlying NAT pinhole / heartbeat path isn't affected since + // Heartbeat packets are dispatched in HandleRawPacket before reaching here. + if (!playbackEnabled) return; + if (!IsSenderAllowed(remote)) + { + Interlocked.Increment(ref packetsRejectedNotAllowed); + return; + } + StreamSession? session; + lock (sessionsLock) + { + sessions.TryGetValue((remote, streamId), out session); + } + // Key lookup guarantees streamId match — kept the defensive check anyway in case of + // future restructuring (cheap and clarifies intent). + if (session is null) return; + if (session.StreamId != streamId) return; + if (!session.HandleAudioPayload(sequence, payload)) + { + Interlocked.Increment(ref packetsDropped); + } + } + + private static int MaxBufferCapacityBytes(int maxLatencyMs) => + Math.Max(maxLatencyMs * CapacityHeadroomMultiplier * MixBytesPerSecond / 1000, 64 * 1024); +} diff --git a/src/RemSound.Receiver/CompositeRenderBackend.cs b/src/RemSound.Receiver/CompositeRenderBackend.cs new file mode 100644 index 0000000..d1000e1 --- /dev/null +++ b/src/RemSound.Receiver/CompositeRenderBackend.cs @@ -0,0 +1,193 @@ +using NAudio.Wave; + +namespace RemSound.Receiver; + +/// +/// Render backend that runs a WASAPI and an +/// in parallel. Two pipeline shapes are reachable today: +/// +/// WasapiOnly: WASAPI child reads directly; no ASIO in +/// the path. Used when no ASIO driver is selected. +/// BothIndependent: WASAPI and ASIO children each get their own consumer view from a +/// shared . The FanOut pulls from PlayoutEngine on demand +/// and caches so both views see the same samples without one consumer slowing the +/// other. Neither backend pays the classic-Both master-producer tee's ~5–10 ms +/// buffer headroom — each lane runs at its native callback rate. +/// +/// The legacy AudioMode.Both tee mode and AudioMode.AsioOnly values are no +/// longer reachable from the UI and are not produced here. +/// +internal sealed class CompositeRenderBackend : IRenderBackend +{ + private readonly PlayoutEngine source; + private readonly Action? onDiagnostic; + private readonly object gate = new(); + + // BothIndependent no longer uses a shared FanOut between the two render backends — each + // backend reads directly from its own lane-filtered source (PlayoutEngine.WasapiLaneOutput / + // AsioLaneOutput). Those surfaces filter PlayoutEngine's session snapshot by RenderRoute, + // so the WASAPI consumer's Read only advances WasapiLane sessions and the ASIO consumer's + // Read only advances AsioLane sessions. The two lanes are fully independent — no shared + // cache, no cross-lane interference, neither lane pays a cache-age penalty when the other + // is also playing. + private readonly MultiOutputPlayout? wasapi; + private readonly AsioRenderBackend? asio; + private readonly string? asioDriverName; + private readonly RemSound.Core.AudioMode mode; + + private bool started; + + public CompositeRenderBackend(RemSound.Core.AudioMode mode, string? asioDriverName, PlayoutEngine source, Action? onDiagnostic = null) + { + this.source = source; + this.onDiagnostic = onDiagnostic; + this.asioDriverName = asioDriverName; + this.mode = mode; + + // Coerce legacy enum values (AsioOnly, Both) into a reachable mode. Anything non- + // WASAPI without a driver demotes to WasapiOnly; anything non-WASAPI with a driver + // is treated as BothIndependent (the only ASIO-using render mode now). + if (mode != RemSound.Core.AudioMode.WasapiOnly) + { + if (string.IsNullOrEmpty(asioDriverName)) + { + this.mode = mode = RemSound.Core.AudioMode.WasapiOnly; + } + else if (mode != RemSound.Core.AudioMode.BothIndependent) + { + this.mode = mode = RemSound.Core.AudioMode.BothIndependent; + } + } + + if (mode == RemSound.Core.AudioMode.WasapiOnly) + { + // MultiOutputPlayout reads PlayoutEngine directly — no master producer, no tee. + // Sessions in WasapiOnly mode are all on RenderRoute.Mixed (the legacy single-knob + // world), and the all-sessions Read does the right thing. + wasapi = new MultiOutputPlayout(source, msg => onDiagnostic?.Invoke($"wasapi out: {msg}")); + } + else + { + // BothIndependent. Each backend reads its OWN lane-filtered source from + // PlayoutEngine — no FanOut, no shared cache, no inter-lane interference. The + // WasapiLaneOutput surface filters PlayoutEngine's session snapshot down to + // route=WasapiLane sessions; AsioLaneOutput does the same for route=AsioLane. + // Each consumer's Read only advances its own lane's sessions, so the two + // consumers can run on independent threads at independent rates without one + // starving the other. Crucially: neither lane pays a cache-age overhead. ASIO + // reads its own audio at its native callback latency, exactly as it would in a + // hypothetical AsioOnly setup — even when WASAPI is also actively playing. + // The previous implementation wrapped a single FanOut around the whole engine, + // which (a) made both lanes play the combined mix instead of per-lane audio and + // (b) added up to one WASAPI tick (~10 ms) of cache-age latency to whichever + // consumer was the slower of the two. + wasapi = new MultiOutputPlayout(source.WasapiLaneOutput, msg => onDiagnostic?.Invoke($"wasapi out: {msg}")); + asio = new AsioRenderBackend(asioDriverName!, source.AsioLaneOutput, msg => onDiagnostic?.Invoke($"asio out: {msg}")); + } + } + + public bool IsRunning => started; + + /// Legacy probe from the FanOut era — always 0 now that BothIndependent reads + /// per-lane sources directly with no intermediate cache. Kept on the surface so the + /// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero + /// rather than disappearing. Can be removed once we're confident the per-lane wiring + /// is the right shape long-term. + public int TakeMaxFanOutCacheBytes() => 0; + + public string ActiveDeviceSummary + { + get + { + var parts = new List(); + if (wasapi is not null) + { + var wSummary = wasapi.ActiveDeviceSummary; + if (wSummary != "(none)") parts.Add(wSummary); + } + if (asio is not null) + { + var aSummary = asio.ActiveDeviceSummary; + if (aSummary != "(none)") parts.Add(aSummary); + } + return parts.Count == 0 ? "(none)" : string.Join(" + ", parts); + } + } + + public IReadOnlyList ActiveDeviceIds + { + get + { + var combined = new List(); + if (wasapi is not null) combined.AddRange(wasapi.ActiveDeviceIds); + if (asio is not null) combined.AddRange(asio.ActiveDeviceIds); + return combined; + } + } + + public void Start() + { + lock (gate) + { + if (started) return; + wasapi?.Start(); + asio?.Start(); + started = true; + onDiagnostic?.Invoke($"composite render started (mode={ModeLabel()})"); + } + } + + public void Stop() + { + lock (gate) + { + if (!started) return; + try { wasapi?.Stop(); } catch { /* ignore */ } + try { asio?.Stop(); } catch { /* ignore */ } + started = false; + } + } + + public void SetOutputDevices(IReadOnlyList deviceIds) + { + // Split by id format: ASIO ids start with "asio:". WASAPI ids are MMDevice strings. + var wasapiIds = new List(); + var asioIds = new List(); + foreach (var id in deviceIds) + { + if (RemSound.Core.AsioDeviceId.TryParse(id, out _)) + { + asioIds.Add(id); + } + else + { + wasapiIds.Add(id); + } + } + if (wasapi is not null) wasapi.SetOutputDevices(wasapiIds); + if (asio is not null) asio.SetOutputDevices(asioIds); + // No FanOut bookkeeping any more — each lane's source is independent, so consumer + // activity / inactivity doesn't affect the other lane's read path. The "skip the + // pull when no outputs are ticked" behaviour now lives inside MultiOutputPlayout's + // producer loop, which short-circuits source.Read when outputs.Count == 0. + } + + public void Dispose() + { + Stop(); + try { wasapi?.Dispose(); } catch { /* ignore */ } + try { asio?.Dispose(); } catch { /* ignore */ } + } + + private string ModeLabel() => mode switch + { + RemSound.Core.AudioMode.WasapiOnly => "fast (WASAPI direct)", + RemSound.Core.AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)", + _ => mode.ToString(), + }; + + // FanOutSource and SwitchableSource have been removed (2026-05-13). The BothIndependent + // rewiring put each lane on its own filtered PlayoutEngine.{Wasapi,Asio}LaneOutput + // surface, so there is no shared source for two consumers to fight over and no cache + // to manage. Either class can be reintroduced if a future routing shape needs them. +} diff --git a/src/RemSound.Receiver/IRenderBackend.cs b/src/RemSound.Receiver/IRenderBackend.cs new file mode 100644 index 0000000..4a9a68c --- /dev/null +++ b/src/RemSound.Receiver/IRenderBackend.cs @@ -0,0 +1,31 @@ +namespace RemSound.Receiver; + +/// +/// Abstraction over the render-side audio backend so can be wired +/// to either a WASAPI implementation (today's ) or an ASIO +/// implementation () without caring which is in use. +/// +/// Both backends pull mixed audio from 's +/// surface and route it to one or more output destinations. WASAPI destinations are MMDevice +/// IDs; ASIO destinations are synthetic IDs of the form +/// "asio:<driver-name>|<channel-pair-index>". +/// +internal interface IRenderBackend : IDisposable +{ + bool IsRunning { get; } + + /// Friendly summary for the snapshot log column. "(none)" when nothing is + /// configured, comma-joined names for ≤3 outputs, "(N outputs)" otherwise. + string ActiveDeviceSummary { get; } + + IReadOnlyList ActiveDeviceIds { get; } + + void Start(); + + void Stop(); + + /// Live-update of the output set. Devices already present stay live; removed ones + /// are torn down; new ones are opened. Empty list = render to nothing without stopping the + /// mixer (so receive-side state stays alive). + void SetOutputDevices(IReadOnlyList deviceIds); +} diff --git a/src/RemSound.Receiver/MultiOutputPlayout.cs b/src/RemSound.Receiver/MultiOutputPlayout.cs new file mode 100644 index 0000000..a241e56 --- /dev/null +++ b/src/RemSound.Receiver/MultiOutputPlayout.cs @@ -0,0 +1,233 @@ +using System.Diagnostics; +using NAudio.CoreAudioApi; +using NAudio.Wave; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Drives N WASAPI output devices from a single shared . A master +/// producer task running on a Stopwatch-based 10 ms tick reads mixed audio from the engine and +/// fans it out to each device's ; each +/// consumes from its own buffer at its own device clock. +/// +/// Why a master producer loop instead of letting one WasapiOut drive PlayoutEngine.Read directly: +/// - With multiple WasapiOuts, each render thread would call Read independently and only one +/// output would get each frame; the others would starve. +/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one +/// device's clock. Per-device drift is absorbed by the BufferedWaveProvider's headroom. +/// +/// Output-device set is diffed on : existing devices stay live, +/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones. +/// +internal sealed class MultiOutputPlayout : IRenderBackend +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int MixBytesPerFrame = MixChannels * sizeof(float); + private const int FrameMs = 10; + private const int FrameBytes = MixSampleRate * MixBytesPerFrame * FrameMs / 1000; // 3840 bytes + private const int OutputBufferMs = 100; // per-device BufferedWaveProvider capacity + + // Source typed as IWaveProvider (rather than concrete PlayoutEngine) so the composite + // backend can hand us a tee'd buffer instead of the engine directly. Single-backend usage + // still passes the engine in unchanged. + private readonly IWaveProvider source; + private readonly Action? onDiagnostic; + private readonly object gate = new(); + private readonly Dictionary outputs = new(StringComparer.OrdinalIgnoreCase); + private readonly byte[] frameScratch = new byte[FrameBytes]; + private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); + + private CancellationTokenSource? cts; + private Task? produceTask; + + public MultiOutputPlayout(IWaveProvider source, Action? onDiagnostic = null) + { + this.source = source; + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => produceTask is { IsCompleted: false }; + + /// + /// Friendly names of currently-active output devices, comma-joined. "(none)" when no + /// device is enabled. Used by the snapshot log column. + /// + public string ActiveDeviceSummary + { + get + { + lock (gate) + { + if (outputs.Count == 0) return "(none)"; + if (outputs.Count <= 3) return string.Join(", ", outputs.Values.Select(o => o.Name)); + return $"({outputs.Count} outputs)"; + } + } + } + + public IReadOnlyList ActiveDeviceIds + { + get { lock (gate) return outputs.Keys.ToList(); } + } + + public void Start() + { + lock (gate) + { + if (IsRunning) return; + cts = new CancellationTokenSource(); + produceTask = Task.Run(() => ProduceLoop(cts.Token)); + onDiagnostic?.Invoke("multi-output producer started"); + } + } + + public void Stop() + { + lock (gate) + { + try { cts?.Cancel(); } catch { /* ignore */ } + try { produceTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ } + cts?.Dispose(); + cts = null; + produceTask = null; + + foreach (var o in outputs.Values) DisposeOutput(o); + outputs.Clear(); + } + } + + public void Dispose() => Stop(); + + /// + /// Live-update of the output device set. Devices already present stay live (no audio + /// interruption); removed devices are stopped + disposed; new devices are opened. Caller + /// supplies device IDs (from MMDeviceEnumerator). An empty set means "render to nothing" + /// — the producer loop keeps running so receive-side mixing/auto-tune state stays alive. + /// + public void SetOutputDevices(IReadOnlyList deviceIds) + { + lock (gate) + { + var desired = new HashSet(deviceIds, StringComparer.OrdinalIgnoreCase); + + // Remove outputs no longer wanted. + foreach (var id in outputs.Keys.Where(k => !desired.Contains(k)).ToList()) + { + if (outputs.Remove(id, out var o)) + { + onDiagnostic?.Invoke($"output removed: \"{o.Name}\""); + DisposeOutput(o); + } + } + + // Add new outputs. + using var enumerator = new MMDeviceEnumerator(); + foreach (var id in deviceIds) + { + if (outputs.ContainsKey(id)) continue; + MMDevice? device = null; + WasapiOut? wasapi = null; + try + { + device = enumerator.GetDevice(id); + var name = device.FriendlyName; + var buffer = new BufferedWaveProvider(sharedFormat) + { + ReadFully = true, + DiscardOnBufferOverflow = true, + BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs), + }; + wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 15); + wasapi.Init(buffer); + wasapi.Play(); + outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Name = name }; + onDiagnostic?.Invoke($"output added: \"{name}\""); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"failed to open output \"{id}\": {ex.GetType().Name}: {ex.Message}"); + try { wasapi?.Dispose(); } catch { /* ignore */ } + try { device?.Dispose(); } catch { /* ignore */ } + } + } + } + } + + private static void DisposeOutput(OutputEntry o) + { + try { o.Output.Stop(); } catch { /* ignore */ } + try { o.Output.Dispose(); } catch { /* ignore */ } + try { o.Device.Dispose(); } catch { /* ignore */ } + } + + private async Task ProduceLoop(CancellationToken ct) + { + // Pro Audio MMCSS for the producer thread — it's the one feeding all WASAPI outputs. + using var threadBoost = new WindowsAudioThreadBoost("Pro Audio"); + + var ticksPerFrame = Stopwatch.Frequency * FrameMs / 1000; + var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame; + + while (!ct.IsCancellationRequested) + { + try + { + var now = Stopwatch.GetTimestamp(); + if (nextTickStopwatch > now) + { + var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50); + if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break; + continue; + } + + if (now - nextTickStopwatch > ticksPerFrame * 4) + { + nextTickStopwatch = now; + } + nextTickStopwatch += ticksPerFrame; + + // Snapshot the buffers under the gate so we don't iterate a mid-mutation dict. + // Also skip the source.Read entirely when no outputs are ticked: in + // BothIndependent mode the source is a FanOutSource view shared with the ASIO + // lane, and pulling here when WASAPI has nothing ticked makes the FanOut + // consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the + // ASIO lane permanently reading from a cache 10 ms behind the source. That + // showed up in test logs as fanCacheMs sustained at 12–14 ms with bufAvg=0, + // and audibly as an extra 10 ms baked into the ASIO lane's perceived latency. + // The gate-then-read order matters; the previous order (read first, then + // check outputs.Count) was the bug. + BufferedWaveProvider[] targets; + lock (gate) + { + if (outputs.Count == 0) continue; + targets = outputs.Values.Select(o => o.Buffer).ToArray(); + } + + var produced = source.Read(frameScratch, 0, FrameBytes); + if (produced <= 0) continue; + + foreach (var buffer in targets) + { + try { buffer.AddSamples(frameScratch, 0, produced); } + catch { /* per-output failure shouldn't kill the loop */ } + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + onDiagnostic?.Invoke($"producer loop error: {ex.GetType().Name}: {ex.Message}"); + await Task.Delay(50, ct).ConfigureAwait(false); + } + } + } + + private sealed class OutputEntry + { + public required MMDevice Device { get; init; } + public required WasapiOut Output { get; init; } + public required BufferedWaveProvider Buffer { get; init; } + public required string Name { get; init; } + } +} diff --git a/src/RemSound.Receiver/NetworkListener.cs b/src/RemSound.Receiver/NetworkListener.cs new file mode 100644 index 0000000..2127043 --- /dev/null +++ b/src/RemSound.Receiver/NetworkListener.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Owns the UDP receive socket and a single dedicated foreground thread that drains it. +/// Hands raw packets (byte buffer + length + remote endpoint) up to a callback supplied by the +/// owner — has no idea what's inside the packets. +/// +/// Allocation-free in steady state: one fixed receive buffer reused across calls, +/// with avoids the per-call +/// IPEndPoint boxing that incurred. +/// +internal sealed class NetworkListener : IDisposable +{ + private readonly Action onPacket; + private readonly Action onDiagnostic; + private CancellationTokenSource? cts; + private Socket? socket; + private Thread? thread; + + // Time-in-user-handler instrumentation. We measure from "ReceiveFrom returned" to + // "onPacket returned" so the SNAP can split observed inter-packet jitter at the + // receiver. If this metric is consistently in the multi-ms range, the receiver's + // own processing chain is the source of the gap (lock contention with the audio + // thread, GC, decode work backing up) rather than the network or the sender. + private long maxOnPacketTicks; + public int TakeMaxOnPacketMs() => + (int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency); + + public NetworkListener(Action onPacket, Action onDiagnostic) + { + this.onPacket = onPacket; + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => socket is not null; + + public void Start(int udpPort) + { + Stop(); + cts = new CancellationTokenSource(); + socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.ReceiveBufferSize = 512 * 1024; + socket.Bind(new IPEndPoint(IPAddress.Any, udpPort)); + + var startedSocket = socket; + var token = cts.Token; + thread = new Thread(() => ReceiveLoop(startedSocket, token)) + { + IsBackground = true, + Name = "RemSound.Receive", + }; + thread.Start(); + onDiagnostic($"network listener bound to UDP :{udpPort}"); + } + + public void Stop() + { + cts?.Cancel(); + try { socket?.Close(); } catch { /* ignore */ } + socket = null; + try { thread?.Join(500); } catch { /* ignore */ } + thread = null; + cts?.Dispose(); + cts = null; + } + + public void Dispose() => Stop(); + + private void ReceiveLoop(Socket activeSocket, CancellationToken token) + { + using var threadBoost = new WindowsAudioThreadBoost("Capture"); + var buffer = new byte[2048]; + EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0); + + while (!token.IsCancellationRequested) + { + int received; + try + { + received = activeSocket.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref anyEndpoint); + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + catch (OperationCanceledException) { break; } + + if (received <= 0) continue; + if (anyEndpoint is not IPEndPoint remote) continue; + + try + { + // Dispatch timing feeds the SNAP's rxDispMs column. Skipped when diagnostics + // are off so the receive loop isn't paying two Stopwatch reads + a CAS loop + // per packet for a number nobody is going to log. + if (RemSound.Core.DiagnosticsGate.Enabled) + { + var dispatchStart = Stopwatch.GetTimestamp(); + onPacket(buffer, received, remote); + var elapsed = Stopwatch.GetTimestamp() - dispatchStart; + long current; + do { current = Volatile.Read(ref maxOnPacketTicks); } + while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current); + } + else + { + onPacket(buffer, received, remote); + } + } + catch (Exception ex) + { + onDiagnostic($"packet handler threw: {ex.GetType().Name}: {ex.Message}"); + } + } + } +} diff --git a/src/RemSound.Receiver/PcmFrameAssembler.cs b/src/RemSound.Receiver/PcmFrameAssembler.cs new file mode 100644 index 0000000..4f8ede1 --- /dev/null +++ b/src/RemSound.Receiver/PcmFrameAssembler.cs @@ -0,0 +1,98 @@ +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Assembles multi-part PCM transport frames back into a single contiguous payload. +/// PCM frames at 48 kHz × 24-bit × 2 ch × 10 ms = 2880 bytes, split into 2 UDP parts. +/// +/// On a healthy LAN, parts arrive in order. If a part is missed we drop the whole frame +/// rather than wait — at 10 ms cadence, waiting more than ~5 ms is worse than a single dropped frame. +/// +internal sealed class PcmFrameAssembler +{ + private uint pendingFrameId; + private byte pendingPartIndex; // index of the NEXT expected part + private byte pendingTotalParts; + private readonly byte[] assemblyBuffer = new byte[8192]; // largest reasonable PCM frame + private int assemblyWritten; + private long rejectionCount; + private long discardedPartialCount; + + /// + /// Number of incoming parts that were rejected outright (out-of-order, malformed, overflow). + /// Each rejection means at least one PCM frame's audio is lost. + /// + public long RejectionCount => Interlocked.Read(ref rejectionCount); + + /// + /// Number of partially-assembled frames discarded because a new frame started before the + /// previous one's parts all arrived. Each discard means a half-finished frame's audio is lost. + /// + public long DiscardedPartialCount => Interlocked.Read(ref discardedPartialCount); + + public bool TryAssemble(ReadOnlySpan partBytes, uint frameId, byte partIndex, byte totalParts, out ReadOnlySpan assembled) + { + assembled = default; + + if (totalParts == 0) + { + Interlocked.Increment(ref rejectionCount); + return false; + } + + // First part of a new frame? Start fresh, regardless of whether the previous one finished. + if (partIndex == 0) + { + // If we had a partial frame waiting, count it as a discard — its audio is lost. + if (pendingTotalParts != 0 && assemblyWritten > 0) + { + Interlocked.Increment(ref discardedPartialCount); + } + pendingFrameId = frameId; + pendingPartIndex = 0; + pendingTotalParts = totalParts; + assemblyWritten = 0; + } + else if (frameId != pendingFrameId || partIndex != pendingPartIndex || totalParts != pendingTotalParts) + { + // Mismatch — we missed the start, or this is from a different frame. Discard. + assemblyWritten = 0; + pendingTotalParts = 0; + Interlocked.Increment(ref rejectionCount); + return false; + } + + if (assemblyWritten + partBytes.Length > assemblyBuffer.Length) + { + // Frame larger than expected — defensive, should never happen with our packetization. + assemblyWritten = 0; + pendingTotalParts = 0; + Interlocked.Increment(ref rejectionCount); + return false; + } + + partBytes.CopyTo(assemblyBuffer.AsSpan(assemblyWritten)); + assemblyWritten += partBytes.Length; + pendingPartIndex++; + + if (pendingPartIndex == pendingTotalParts) + { + assembled = assemblyBuffer.AsSpan(0, assemblyWritten); + // Reset for next frame after the caller consumes. + pendingTotalParts = 0; + assemblyWritten = 0; + return true; + } + + return false; + } + + public void Reset() + { + pendingFrameId = 0; + pendingPartIndex = 0; + pendingTotalParts = 0; + assemblyWritten = 0; + } +} diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs new file mode 100644 index 0000000..e9742dc --- /dev/null +++ b/src/RemSound.Receiver/PlayoutEngine.cs @@ -0,0 +1,611 @@ +using System.Net; +using NAudio.Wave; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Multi-source playout coordinator. Holds one per active sender and +/// implements by reading from all of them per WASAPI render callback +/// and summing into the output buffer. Volume / mute / clipping live here; per-session adaptive +/// rate lives inside each SessionPlayout. +/// +/// Why multi-source: the previous design supported only one sender at a time and reset the +/// playout buffer on every endpoint change. With two senders simultaneously sending to the same +/// receiver (e.g. a peer and your own loopback for monitoring), Format packets arrived alternately +/// from each endpoint and the buffer was flushed several times per second — horrible crackle. +/// Now each sender owns its own buffer + drift corrector, and the mix bus sums them. +/// +/// Concurrent-modification safety: / +/// take a lock and mutate the dictionary; snapshots the current values list +/// (no allocation in steady state once the snapshot array has stabilised) before iterating, so it +/// never iterates a mid-mutation collection. The per-session Read is lock-free. +/// +internal sealed class PlayoutEngine : IWaveProvider +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int MixBytesPerFrame = MixChannels * sizeof(float); + private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame; + + // Soft-limiter parameters. Below the threshold, samples pass through untouched. Above it, + // a tanh-based soft-knee smoothly compresses excess so the output asymptotes to ±1 without + // ever clipping hard. This replaces the previous straight `Math.Clamp(-1, +1)` which slams + // peaks into a square wave on transient summation. Standard pattern in audio mixers — see + // research notes on conferencing-mixer clipping (NetEQ uses similar; PJSIP / RTP mixers too). + private const float LimiterThreshold = 0.9f; + private const float LimiterKnee = 1.0f - LimiterThreshold; + + private readonly ReceiverDiagnostics diagnostics; + private readonly object sessionsLock = new(); + // Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. One peer can produce + // multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native- + // independent audio mode). For the existing single-lane modes (WasapiOnly / AsioOnly / + // Both) the sender emits a single streamId so the dict still has one entry per peer, + // identical to the pre-refactor behaviour. The new mode adds a second entry per peer. + private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), SessionPlayout> sessions = new(); + private SessionPlayout[] sessionsSnapshot = []; + // Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on + // its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render + // thread, independent). They must not share scratch arrays — concurrent writes would + // garble output. The Mixed-route scratch keeps the original field names because that's + // what the legacy Read still uses; the lane-route surfaces own their own copies. + private float[] mixScratch = new float[8192]; + private float[] sessionScratch = new float[8192]; + private readonly LaneOutput wasapiLaneOutput; + private readonly LaneOutput asioLaneOutput; + + // Per-route latency state. Stage 4.5 (2026-05-11): added so BothIndependent mode can run + // each lane at its own target/max without one lane's auto-tune dragging the other up. + // In classic modes only the Mixed route is ever read from; the others sit at defaults + // and consume no resources. Each LaneLatency's fields are volatile so UI-thread writes + // are visible to the audio render thread without locks. + private sealed class LaneLatency + { + public volatile int TargetMs = 30; + public volatile int MaxMs = 80; + } + private readonly LaneLatency mixedLatency = new(); + private readonly LaneLatency wasapiLaneLatency = new(); + private readonly LaneLatency asioLaneLatency = new(); + private volatile bool muted; + private volatile float volume = 1f; + // 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI. + // Now mostly a safety-knob for the click-trim catastrophic path; in normal operation the + // Phase-2 drift corrector (in SessionPlayout) keeps the buffer near target so the trim + // never fires regardless of this value. + private volatile int smoothness = 3; + // User-pickable artifact for underrun gaps. Stored as raw int because volatile doesn't + // play with enum types directly. Push to existing SessionPlayouts on change so already- + // running streams pick the new artifact up on the very next gap. + private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst; + + public void SetSmoothness(int value) => smoothness = Math.Clamp(value, 1, 10); + + /// Sets the concealment artifact for every active session and for any future + /// session created after this call. Live-updates: the next time a session sees an + /// underrun, it uses the new artifact. + public void SetConcealmentArtifact(ConcealmentArtifact artifact) + { + concealmentArtifactRaw = (int)artifact; + var snap = sessionsSnapshot; + foreach (var s in snap) s.SetConcealmentArtifact(artifact); + } + + public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); + + /// Legacy property returning the Mixed route's target. Used by code paths that + /// don't care about per-route routing (every classic mode, plus diagnostics that report + /// "the" target latency in non-BothIndependent setups). + public int TargetLatencyMs => mixedLatency.TargetMs; + /// Legacy property returning the Mixed route's max. + public int MaxLatencyMs => mixedLatency.MaxMs; + + /// Per-route target accessor. In BothIndependent the WASAPI and ASIO routes have + /// independent targets so each lane can settle at its native latency without the other + /// pulling it. In classic modes only Mixed is meaningful; the other two routes return + /// their defaults. + public int TargetLatencyMsFor(RenderRoute route) => LatencyFor(route).TargetMs; + public int MaxLatencyMsFor(RenderRoute route) => LatencyFor(route).MaxMs; + + private LaneLatency LatencyFor(RenderRoute route) => route switch + { + RenderRoute.WasapiLane => wasapiLaneLatency, + RenderRoute.AsioLane => asioLaneLatency, + _ => mixedLatency, + }; + + /// Aggregate buffered ms across all active sessions. Used by the App's diagnostic + /// snapshot row. Per-session levels are not currently exposed (single number is enough for + /// the existing snapshot column; the auto-tune doesn't depend on it). + public int CurrentBufferMs + { + get + { + var snap = sessionsSnapshot; + if (snap.Length == 0) return 0; + var totalBytes = 0; + foreach (var s in snap) totalBytes += s.BufferedBytes; + return totalBytes / MixBytesPerFrame * 1000 / MixSampleRate; + } + } + + public bool IsArmed + { + get + { + var snap = sessionsSnapshot; + foreach (var s in snap) if (s.IsArmed) return true; + return false; + } + } + + public float Volume + { + get => volume; + set => volume = Math.Clamp(value, 0f, 1f); + } + + public bool IsMuted + { + get => muted; + set => muted = value; + } + + public PlayoutEngine(ReceiverDiagnostics diagnostics) + { + this.diagnostics = diagnostics; + wasapiLaneOutput = new LaneOutput(this, RenderRoute.WasapiLane); + asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane); + } + + /// + /// IWaveProvider surface for sessions tagged . Only + /// used in BothIndependent mode where the WASAPI render backend reads its own lane + /// independently of the ASIO render. In the three classic modes (WasapiOnly / AsioOnly / + /// Both) nothing ever reads from this surface and no session is ever tagged WasapiLane, + /// so it returns silence and consumes no resources. + /// + public IWaveProvider WasapiLaneOutput => wasapiLaneOutput; + + /// + /// IWaveProvider surface for sessions tagged . Same + /// contract as ; only used in BothIndependent mode. + /// + public IWaveProvider AsioLaneOutput => asioLaneOutput; + + /// + /// Sets the user's delay knob. Slider value drives the playout target directly so the change + /// is audible immediately. + /// + /// LOWER: by default disarms + drains every session. The buffer is now above the new target + /// and has to actually shrink before playback resumes. Brief silence is unavoidable on this + /// path; the user is asking for tighter latency and accepting the cost. Set + /// = false to take the SOFT path instead — the buffer keeps + /// playing and the drift corrector's adaptive gain ramps it down over a few seconds. Used + /// for auto-tune-driven lowers, where the user didn't ask for an immediate change and + /// shouldn't hear one. + /// + /// RAISE (2026-05-06 change): NO disarm, NO drain regardless of . + /// The buffer is now below the new target but audio keeps playing — the drift corrector's + /// adaptive-gain term (see SessionPlayout) ramps the buffer up to the new target within + /// seconds without the user ever hearing silence. Previously every raise produced an + /// audible stop-start because the always-drain path blew the buffer away. Tweaking the + /// slider in tiny increments is now silent. + /// + /// Equal value: no-op. + /// + /// Legacy single-route setter — operates on the Mixed route. Every classic-mode + /// call site continues to use this and behaves identically to pre-2026-05-11. + public void SetMaxLatencyMs(int value, bool drainOnLower = true) => + SetMaxLatencyMs(RenderRoute.Mixed, value, drainOnLower); + + /// + /// Per-route setter. Identical algorithm to the legacy one but only drains sessions + /// tagged with the matching route — so lowering the WASAPI lane's target won't disarm + /// the ASIO lane's session (and vice versa). In BothIndependent the WASAPI/ASIO routes + /// have their own slider in the UI driving each call. + /// + public void SetMaxLatencyMs(RenderRoute route, int value, bool drainOnLower = true) + { + var clamped = Math.Clamp(value, 1, 500); + var lane = LatencyFor(route); + var previousTarget = lane.TargetMs; + lane.MaxMs = clamped; + lane.TargetMs = clamped; + if (clamped < previousTarget && drainOnLower) + { + // Only drain sessions on THIS route — leaves other-route sessions playing. + var snap = sessionsSnapshot; + foreach (var s in snap) + { + if (s.Route != route) continue; + s.DisarmAndRequestDrain(); + } + } + } + + public SessionPlayout GetOrCreateSession(IPEndPoint endpoint, ushort streamId, int capacityBytes) + { + var key = (endpoint, streamId); + lock (sessionsLock) + { + if (!sessions.TryGetValue(key, out var sp)) + { + sp = new SessionPlayout(endpoint, streamId, capacityBytes); + // Inherit the engine-wide artifact selection so a session created mid-stream + // gets the right artifact from frame zero (rather than the SessionPlayout + // default, which would only get overridden on the next SetConcealmentArtifact). + sp.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw); + sessions[key] = sp; + sessionsSnapshot = sessions.Values.ToArray(); + } + return sp; + } + } + + public bool RemoveSession(IPEndPoint endpoint, ushort streamId) + { + var key = (endpoint, streamId); + lock (sessionsLock) + { + if (sessions.Remove(key, out var sp)) + { + sp.Dispose(); + sessionsSnapshot = sessions.Values.ToArray(); + return true; + } + return false; + } + } + + public IReadOnlyList ActiveSessions + { + get { lock (sessionsLock) return sessions.Values.ToList(); } + } + + public void ResetAll() + { + lock (sessionsLock) + { + foreach (var s in sessions.Values) s.Dispose(); + sessions.Clear(); + sessionsSnapshot = []; + } + } + + public long AggregateUnderruns + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.UnderrunCount; + return total; + } + } + + /// Per-route underrun aggregator. The continuous auto-tune uses this in + /// BothIndependent mode so the WASAPI lane's underruns don't make the ASIO auto-tune + /// skip a tick (and vice versa). In classic modes only the Mixed route has sessions, + /// so AggregateUnderrunsFor(Mixed) == AggregateUnderruns. + public long AggregateUnderrunsFor(RenderRoute route) + { + long total = 0; + foreach (var s in sessionsSnapshot) + { + if (s.Route == route) total += s.UnderrunCount; + } + return total; + } + + /// True if at least one session is currently tagged for this route. Used by + /// the auto-tune to skip ticking a lane that has nobody to tune — without this gate + /// the ASIO auto-tune (for example) would react to the shared network-gap signal + /// populated by WASAPI-lane traffic and silently inflate its own target before the + /// user has even started an ASIO source, so the next time ASIO actually goes live the + /// receiver would already be pre-loaded with a high target. + public bool HasSessionsForRoute(RenderRoute route) + { + foreach (var s in sessionsSnapshot) + { + if (s.Route == route) return true; + } + return false; + } + + public long AggregateDrops + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.DropCount; + return total; + } + } + + /// Sum of click-trim drop bytes across all active sessions. + public long AggregateTrimDropBytes + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.TrimDropBytes; + return total; + } + } + + /// Sum of slider-drain drop bytes across all active sessions. + public long AggregateDrainDropBytes + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.DrainDropBytes; + return total; + } + } + + /// Total click-trim fires (one per trim event, regardless of bytes dropped). + public long AggregateTrimFireCount + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.TrimFireCount; + return total; + } + } + + /// Cumulative count of single-frame drops the Phase-2 drift corrector has applied. + public long AggregateDriftDropFrames + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal; + return total; + } + } + + /// Cumulative count of single-frame repeats the Phase-2 drift corrector has applied. + public long AggregateDriftRepeatFrames + { + get + { + long total = 0; + foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal; + return total; + } + } + + // === WASAPI render thread === + + /// + /// Render-side audio pull. Iterates every session regardless of lane tag and sums them + /// into one mixed bus. This is what every render backend (WasapiOnly, AsioOnly, classic + /// Both via the tee, and BothIndependent via the tee) reads from, so a user can pick any + /// output device for any received audio — independently of which capture technology the + /// sender used. Per-lane latency targets are still honoured: each session reads its own + /// route's TargetMs / MaxMs via , so the WASAPI-captured stream + /// can buffer at one latency and the ASIO-captured stream at another within the same + /// output mix. The lane-specific / + /// surfaces are kept around for future per-route routing options but are not used by the + /// default render path (see CompositeRenderBackend). 2026-05-11 revision: previous + /// implementation filtered by route, which made it impossible to route a WASAPI-captured + /// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a + /// long-standing cross-backend send/receive flow. + /// + public int Read(byte[] buffer, int offset, int count) => + ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true); + + /// + /// Shared per-route render pull. Iterates the session snapshot, summing only those + /// sessions whose matches the requested filter into + /// the caller's scratch buffers, applies volume/mute/limiter, and packs to bytes. The + /// Mixed route additionally feeds (output-step + buffer + /// level) — lane routes skip diagnostics to avoid double-counting in BothIndependent mode + /// where both lanes run their own ReadForRoute concurrently and the legacy single + /// per-tick stats columns are still the user-visible source of truth. + /// + internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics) + { + if (recordDiagnostics) diagnostics.RecordRenderRead(count); + + var outFrames = count / MixBytesPerFrame; + var outFloats = outFrames * MixChannels; + // Each route owns its scratch buffers; grow them in place if the consumer is asking + // for a bigger block than we've ever served before. Per-route ownership means the + // BothIndependent threads don't fight over one buffer. + if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats) + { + (mixBuf, sessionBuf) = GrowScratch(route, outFloats); + } + Array.Clear(mixBuf, 0, outFloats); + + // Snapshot — local copy so the iteration is safe against concurrent dict mutations. + var snap = sessionsSnapshot; + + // Pull this route's target/max from the per-route state. In Mixed (classic modes) + // this reads mixedLatency, identical to pre-Stage-4.5 behaviour. In BothIndependent + // the WASAPI and ASIO route reads pick up their respective LaneLatency entries so + // each lane's session is paced against its own slider value. + var routeLatency = LatencyFor(route); + var routeTargetMs = routeLatency.TargetMs; + var routeMaxMs = routeLatency.MaxMs; + + var aggregateBufferedBytes = 0; + var anyContributed = false; + foreach (var session in snap) + { + if (session.Route != route) continue; + aggregateBufferedBytes += session.BufferedBytes; + var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness); + if (produced <= 0) continue; + anyContributed = true; + var summed = produced * MixChannels; + for (var i = 0; i < summed; i++) + { + mixBuf[i] += sessionBuf[i]; + } + } + + if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes); + + if (!anyContributed) + { + Array.Clear(buffer, offset, count); + return count; + } + + // Apply volume / mute and the soft-knee tanh limiter before packing. Both the volume + // knob and the limiter are receiver-engine-wide concerns, so they apply equally to + // every route (matching the principle that a single user-set volume affects every + // output device regardless of which lane it belongs to). + var localVolume = muted ? 0f : volume; + for (var i = 0; i < outFloats; i++) + { + var v = mixBuf[i] * localVolume; + var sign = v < 0f ? -1f : 1f; + var abs = v * sign; + if (abs > LimiterThreshold) + { + var excess = abs - LimiterThreshold; + var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee); + v = sign * (LimiterThreshold + compressed); + } + mixBuf[i] = v; + } + + if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats)); + + Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); + return count; + } + + /// + /// Read all sessions, regardless of lane tag, into a single mixed bus. Each session is + /// paced against ITS OWN lane's target/max latency, so a WASAPI-captured session and an + /// ASIO-captured session in BothIndependent mode each maintain their independent buffer + /// depths even though they end up in the same output mix. This is the path every render + /// backend reads from in normal operation — the lane surfaces above are kept for + /// potential per-output-device routing in a future revision but are not used today. + /// + private int ReadAllSessions(byte[] buffer, int offset, int count, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics) + { + if (recordDiagnostics) diagnostics.RecordRenderRead(count); + + var outFrames = count / MixBytesPerFrame; + var outFloats = outFrames * MixChannels; + if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats) + { + (mixBuf, sessionBuf) = GrowScratch(RenderRoute.Mixed, outFloats); + } + Array.Clear(mixBuf, 0, outFloats); + + var snap = sessionsSnapshot; + var aggregateBufferedBytes = 0; + var anyContributed = false; + foreach (var session in snap) + { + // Per-session latency: each session's own lane governs its buffer behaviour, so a + // WASAPI-captured stream can sit at one target depth and an ASIO-captured stream + // at another. Mixing them at the output level doesn't collapse those targets. + var laneLatency = LatencyFor(session.Route); + aggregateBufferedBytes += session.BufferedBytes; + var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, laneLatency.TargetMs, laneLatency.MaxMs, smoothness); + if (produced <= 0) continue; + anyContributed = true; + var summed = produced * MixChannels; + for (var i = 0; i < summed; i++) + { + mixBuf[i] += sessionBuf[i]; + } + } + + if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes); + + if (!anyContributed) + { + Array.Clear(buffer, offset, count); + return count; + } + + var localVolume = muted ? 0f : volume; + for (var i = 0; i < outFloats; i++) + { + var v = mixBuf[i] * localVolume; + var sign = v < 0f ? -1f : 1f; + var abs = v * sign; + if (abs > LimiterThreshold) + { + var excess = abs - LimiterThreshold; + var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee); + v = sign * (LimiterThreshold + compressed); + } + mixBuf[i] = v; + } + + if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats)); + Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float)); + return count; + } + + /// + /// Grow the per-route scratch buffers in place when a render backend asks for a bigger + /// block than we've previously served. Writes the new arrays back to the route-owning + /// fields (so subsequent reads from this route see the larger buffer) and returns them + /// to the caller for use in the current Read. The Mixed route's buffers live on the + /// PlayoutEngine itself for legacy reasons; the two lane routes own their buffers on + /// the corresponding LaneOutput instance. Each route is single-threaded (one consumer + /// per surface) so we don't need to lock around the realloc. + /// + private (float[] mix, float[] session) GrowScratch(RenderRoute route, int neededFloats) + { + switch (route) + { + case RenderRoute.Mixed: + if (mixScratch.Length < neededFloats) mixScratch = new float[neededFloats]; + if (sessionScratch.Length < neededFloats) sessionScratch = new float[neededFloats]; + return (mixScratch, sessionScratch); + case RenderRoute.WasapiLane: + if (wasapiLaneOutput.MixScratch.Length < neededFloats) wasapiLaneOutput.MixScratch = new float[neededFloats]; + if (wasapiLaneOutput.SessionScratch.Length < neededFloats) wasapiLaneOutput.SessionScratch = new float[neededFloats]; + return (wasapiLaneOutput.MixScratch, wasapiLaneOutput.SessionScratch); + case RenderRoute.AsioLane: + if (asioLaneOutput.MixScratch.Length < neededFloats) asioLaneOutput.MixScratch = new float[neededFloats]; + if (asioLaneOutput.SessionScratch.Length < neededFloats) asioLaneOutput.SessionScratch = new float[neededFloats]; + return (asioLaneOutput.MixScratch, asioLaneOutput.SessionScratch); + default: + return (mixScratch, sessionScratch); + } + } + + /// + /// Per-lane IWaveProvider. Each instance filters PlayoutEngine's session snapshot down + /// to sessions tagged with a specific and runs the standard + /// volume/mute/limiter pipeline against just that subset. Only meaningful in + /// BothIndependent mode; in classic modes nothing reads from these surfaces. + /// + private sealed class LaneOutput : IWaveProvider + { + private readonly PlayoutEngine owner; + private readonly RenderRoute route; + // Each lane owns its own scratch (fields exposed to the owner so ReadForRoute can + // grow them via the same helper). Public-internal exposure rather than method-call + // because the grow path needs a ref to the slot, and there's exactly one caller per + // field — the owner. Keeping these private to the outer class via internal access. + internal float[] MixScratch = new float[8192]; + internal float[] SessionScratch = new float[8192]; + + public WaveFormat WaveFormat => owner.WaveFormat; + + public LaneOutput(PlayoutEngine owner, RenderRoute route) + { + this.owner = owner; + this.route = route; + } + + public int Read(byte[] buffer, int offset, int count) => + owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false); + } +} diff --git a/src/RemSound.Receiver/ReceiverDiagnostics.cs b/src/RemSound.Receiver/ReceiverDiagnostics.cs new file mode 100644 index 0000000..2a2a7a7 --- /dev/null +++ b/src/RemSound.Receiver/ReceiverDiagnostics.cs @@ -0,0 +1,265 @@ +using System.Diagnostics; + +namespace RemSound.Receiver; + +/// +/// Sub-second telemetry that the App pulls once per second for the log file. +/// Tracks rolling stats so a 1 Hz snapshot reveals what's actually happening +/// at audio-rate resolution. All counters are interlocked or volatile so the +/// network thread, render thread, and App thread can read/write without locks. +/// +/// Naming convention: +/// *PerSecond → reset on every second-boundary read +/// *Rolling → averaged over the last second +/// *Cumulative → since session start +/// +public sealed class ReceiverDiagnostics +{ + // Network arrival timing. + private long lastPacketTicks; + private long maxArrivalGapTicks; + private long packetCountSinceLastReport; + + // Buffer sampling. Each Read call records the buffer level it observed. + // We keep a tiny rolling window so the App can show min/avg/max for the last second. + private long bufferSampleSumBytes; + private int bufferSampleCount; + private int bufferSampleMinBytes = int.MaxValue; + private int bufferSampleMaxBytes; + + // WASAPI render Read sizes. + private int maxRenderReadBytes; + private int renderReadCount; + + // Render-callback timing — the parallel of sender's capture-callback gap. PlayoutEngine.Read + // is invoked by the audio device's render callback (NAudio's WASAPI or ASIO output wrapper). + // Healthy systems show sub-ms variance from a strict period (= ASIO buffer / sample rate, or + // WASAPI engine period). Spikes here mean the audio output thread is being scheduled with + // jitter — which manifests as audible discontinuities even when RemSound's playout buffer is + // healthy, because the audio HARDWARE expects samples on a rigid clock and gets them late. + // RemSound's "Underruns" counter measures whether RemSound's buffer ran dry; this measures + // whether the audio device's own buffer was being fed punctually. + private long lastRenderCallbackTicks; + private long maxRenderCallbackGapTicks; + + // Sample-step diagnostic. Two quantities: + // + // 1. maxSampleStep — the largest |sample[n] - sample[n-1]| in the diag window. Peak + // indicator, prone to false positives on bright music. Kept for visibility. + // + // 2. spikeCount — adaptive second-derivative outlier detector. A click manifests as a + // second derivative |s[i+1] - 2*s[i] + s[i-1]| that's anomalously large *relative to + // its recent typical value*. Smooth music has consistent (low) second-derivative + // energy. Bright music has consistent (medium) second-derivative energy. A click has + // *suddenly* much larger second-derivative energy than recent norm, regardless of + // overall content level. + // + // The detector tracks an EMA of |second derivative| over ~64 samples (~1.3 ms at 48 kHz) + // and flags samples whose own second-derivative exceeds that EMA by a multiplier. + // Multiplier of 5× = "this sample's discontinuity is 5× louder than the recent local + // discontinuity baseline." Plus an absolute floor so quiet-content noise doesn't trip it. + // + // Behaviour by signal type: + // - Silence: zero second derivative → spikeCount stays 0. + // - Smooth tone: low, consistent 2nd derivative → ratio ~1 → spikeCount stays 0. + // - Bright tone: high but consistent 2nd derivative → ratio ~1 → spikeCount stays 0. + // - Click on top of any of the above: 2nd derivative spikes for one sample, ratio >> 5 + // → spikeCount increments by 1 per click sample. + private const float SpikeEnvAlpha = 1f / 64f; // ~1.3 ms half-life at 48 kHz + private const float SpikeRatioThreshold = 5.0f; // sample's 2nd-deriv must be 5× recent norm + private const float SpikeAbsoluteFloor = 0.02f; // ignore near-silence noise + private float maxSampleStep; + private int spikeCount; + private float secondDerivEMA; // running average of |2nd derivative| + private bool spikeStateSeeded; + private float prevPrevSample; // s[i-2] when computing s[i] + // Last sample of the previous Read call — used as the seed for the first sample of the + // next Read so step measurement spans Read boundaries (otherwise we'd miss clicks that + // sit on the boundary between two Reads, which is exactly where buffer-edge clicks live). + private float lastWrittenSample; + private bool lastSampleSeeded; + + public void RecordPacketArrived() + { + // Diagnostics-gate first. packetCountSinceLastReport feeds the SNAP diag line too so + // it isn't worth the audio-thread cost to keep incrementing it when nobody is reading. + if (!RemSound.Core.DiagnosticsGate.Enabled) return; + var now = Stopwatch.GetTimestamp(); + var prev = Interlocked.Exchange(ref lastPacketTicks, now); + if (prev != 0) + { + var gap = now - prev; + // Track max gap (lock-free max via CAS). + long currentMax; + do { currentMax = Volatile.Read(ref maxArrivalGapTicks); } + while (gap > currentMax && Interlocked.CompareExchange(ref maxArrivalGapTicks, gap, currentMax) != currentMax); + } + Interlocked.Increment(ref packetCountSinceLastReport); + } + + /// + /// Zeros the inter-packet and render-callback timestamps so the next sample taken doesn't + /// measure a gap across a stream-session boundary. Called from AudioReceiver + /// whenever a new StreamSession opens — without this, the first packet of the new + /// session would record a gap equal to the entire idle duration between the previous + /// session ending and this one starting (potentially tens of seconds), poisoning the + /// auto-tune's recent-gap window and causing it to recommend an absurdly large latency + /// target. The same applies to the render-callback timing: a new audio output device or + /// re-opened ASIO driver should start its own gap measurement, not inherit one from the + /// previous backend's last render. + /// + public void ResetGapMeasurements() + { + Interlocked.Exchange(ref lastPacketTicks, 0); + Interlocked.Exchange(ref maxArrivalGapTicks, 0); + Interlocked.Exchange(ref lastRenderCallbackTicks, 0); + Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0); + } + + public void RecordBufferLevel(int bufferedBytes) + { + if (!RemSound.Core.DiagnosticsGate.Enabled) return; + Interlocked.Add(ref bufferSampleSumBytes, bufferedBytes); + Interlocked.Increment(ref bufferSampleCount); + // Track min/max via CAS. + int curMin; + do { curMin = Volatile.Read(ref bufferSampleMinBytes); } + while (bufferedBytes < curMin && Interlocked.CompareExchange(ref bufferSampleMinBytes, bufferedBytes, curMin) != curMin); + int curMax; + do { curMax = Volatile.Read(ref bufferSampleMaxBytes); } + while (bufferedBytes > curMax && Interlocked.CompareExchange(ref bufferSampleMaxBytes, bufferedBytes, curMax) != curMax); + } + + public void RecordRenderRead(int bytesRequested) + { + if (!RemSound.Core.DiagnosticsGate.Enabled) return; + Interlocked.Increment(ref renderReadCount); + int curMax; + do { curMax = Volatile.Read(ref maxRenderReadBytes); } + while (bytesRequested > curMax && Interlocked.CompareExchange(ref maxRenderReadBytes, bytesRequested, curMax) != curMax); + + // Track the gap since the previous render callback. First call seeds the timestamp + // without recording a gap (no prior reference). Lock-free max-update via CAS. + var now = Stopwatch.GetTimestamp(); + var prev = Interlocked.Exchange(ref lastRenderCallbackTicks, now); + if (prev != 0) + { + var gap = now - prev; + long currentMax; + do { currentMax = Volatile.Read(ref maxRenderCallbackGapTicks); } + while (gap > currentMax && Interlocked.CompareExchange(ref maxRenderCallbackGapTicks, gap, currentMax) != currentMax); + } + } + + /// Scan a span of float samples that RemSound is about to hand to NAudio and + /// record (a) the peak sample-to-sample step and (b) an adaptive count of second- + /// derivative outliers — samples whose discontinuity is anomalously large relative to + /// recent local norm. The latter is the click-specific signal: it's content-INVARIANT, + /// triggering only when a sample really does break the local audio's predictability. + public void RecordOutputSampleSteps(ReadOnlySpan samples) + { + // Most expensive probe in the engine — per-sample second-derivative arithmetic on + // every render block. Gate it at the top so the render thread doesn't pay any of + // this when nobody is going to read the column. + if (!RemSound.Core.DiagnosticsGate.Enabled) return; + if (samples.IsEmpty) return; + var localMax = maxSampleStep; + var localSpikes = spikeCount; + var prev = lastSampleSeeded ? lastWrittenSample : samples[0]; + var prevPrev = spikeStateSeeded ? prevPrevSample : prev; + // Initial seed for the EMA on first-ever call: small positive value so the first + // few samples can't all register as anomalies before the EMA has a chance to learn. + var derivEMA = spikeStateSeeded ? secondDerivEMA : 0.01f; + var oneMinusAlpha = 1f - SpikeEnvAlpha; + for (var i = 0; i < samples.Length; i++) + { + var cur = samples[i]; + var step = cur - prev; + if (step < 0) step = -step; + if (step > localMax) localMax = step; + + // Second derivative: |s[i] - 2*s[i-1] + s[i-2]|. Smooth audio has low and + // consistent values; a click introduces a sudden large value at one sample. + var d2 = cur - 2f * prev + prevPrev; + if (d2 < 0f) d2 = -d2; + // Spike detector: anomalously high second derivative relative to recent norm. + // The absolute floor (0.02) prevents counting in near-silence where the EMA + // is tiny and any small sample noise would technically exceed N× the EMA. + // Math.Max ensures we don't divide-by-zero or trip on EMA close to 0. + var dynamicThreshold = Math.Max(derivEMA * SpikeRatioThreshold, SpikeAbsoluteFloor); + if (d2 > dynamicThreshold) + { + localSpikes++; + // Update the EMA WITHOUT folding this anomaly in (so a click doesn't poison + // the baseline and mask subsequent clicks). Re-feed the EMA with its current + // value, effectively a no-op update on click samples. + } + else + { + // Update EMA only on non-anomalous samples — keeps the baseline tracking + // smooth audio character, not click events. + derivEMA = derivEMA * oneMinusAlpha + d2 * SpikeEnvAlpha; + } + + prevPrev = prev; + prev = cur; + } + maxSampleStep = localMax; + spikeCount = localSpikes; + secondDerivEMA = derivEMA; + prevPrevSample = prevPrev; + spikeStateSeeded = true; + lastWrittenSample = prev; + lastSampleSeeded = true; + } + + /// + /// Snapshot the rolling counters and reset them. Called by the App once per second. + /// + public DiagSnapshot Take(int mixBytesPerSecond) + { + var maxGapTicks = Interlocked.Exchange(ref maxArrivalGapTicks, 0); + var pktCount = Interlocked.Exchange(ref packetCountSinceLastReport, 0); + var sumBytes = Interlocked.Exchange(ref bufferSampleSumBytes, 0); + var sampleCount = Interlocked.Exchange(ref bufferSampleCount, 0); + var minBytes = Interlocked.Exchange(ref bufferSampleMinBytes, int.MaxValue); + var maxBytes = Interlocked.Exchange(ref bufferSampleMaxBytes, 0); + var maxReadBytes = Interlocked.Exchange(ref maxRenderReadBytes, 0); + var readCount = Interlocked.Exchange(ref renderReadCount, 0); + var maxRenderCbGap = Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0); + // Sample-step is read from the render thread (which is the only writer); diag thread + // reads + zeroes. The reader sees a slightly stale value if a Read is in flight, which + // is fine — values will fold into the next snapshot. + var maxStep = maxSampleStep; + maxSampleStep = 0f; + var bigSteps = spikeCount; + spikeCount = 0; + + var ticksToMsScale = 1000.0 / Stopwatch.Frequency; + return new DiagSnapshot( + PacketCount: pktCount, + MaxArrivalGapMs: (int)(maxGapTicks * ticksToMsScale), + BufferAvgMs: sampleCount > 0 ? (int)(sumBytes / sampleCount * 1000.0 / mixBytesPerSecond) : 0, + BufferMinMs: minBytes == int.MaxValue ? 0 : (int)(minBytes * 1000.0 / mixBytesPerSecond), + BufferMaxMs: (int)(maxBytes * 1000.0 / mixBytesPerSecond), + BufferSampleCount: sampleCount, + MaxRenderReadMs: (int)(maxReadBytes * 1000.0 / mixBytesPerSecond), + MaxRenderCallbackGapMs: (int)(maxRenderCbGap * ticksToMsScale), + RenderReadCount: readCount, + MaxOutputSampleStep: maxStep, + EnvelopeSpikeCount: bigSteps); + } + + public readonly record struct DiagSnapshot( + long PacketCount, + int MaxArrivalGapMs, + int BufferAvgMs, + int BufferMinMs, + int BufferMaxMs, + int BufferSampleCount, + int MaxRenderReadMs, + int MaxRenderCallbackGapMs, + int RenderReadCount, + float MaxOutputSampleStep, + int EnvelopeSpikeCount); +} diff --git a/src/RemSound.Receiver/RemSound.Receiver.csproj b/src/RemSound.Receiver/RemSound.Receiver.csproj new file mode 100644 index 0000000..3d72ef4 --- /dev/null +++ b/src/RemSound.Receiver/RemSound.Receiver.csproj @@ -0,0 +1,17 @@ + + + net10.0-windows + enable + enable + true + RemSound.Receiver + RemSound.Receiver + true + + + + + + + + diff --git a/src/RemSound.Receiver/SessionPlayout.cs b/src/RemSound.Receiver/SessionPlayout.cs new file mode 100644 index 0000000..9e745e5 --- /dev/null +++ b/src/RemSound.Receiver/SessionPlayout.cs @@ -0,0 +1,749 @@ +using System.Diagnostics; +using System.Net; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// One incoming sender's playout state: its own SPSC ring buffer plus a small set of drift / +/// concealment / smoothness state. Each remote endpoint that's actively sending audio gets +/// exactly one SessionPlayout. owns the collection and reads from +/// all of them per render callback, summing into the mix bus. +/// +/// Drift correction: each sender has its own audio crystal that runs at slightly different +/// rate from the receiver's. This class compensates with a slow integrator that drops or +/// repeats one stereo frame at a time when sustained drift is detected, with a short cosine +/// crossfade across each splice for inaudibility. See DriftGain / DriftCrossfadeFrames. +/// +/// Threading: runs on the network thread (per-sender producer); +/// runs on the WASAPI/ASIO render thread (single consumer). The +/// AudioRingBuffer is SPSC-safe; drift / concealment state is only touched from the consumer. +/// +internal sealed class SessionPlayout : IDisposable +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int MixBytesPerFrame = MixChannels * sizeof(float); + private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame; + + private readonly AudioRingBuffer playout; + // Scratch buffer used by the drift-correction crossfade path. Sized as needed inside + // ReadFloats; persistent here so we don't reallocate per call. + private float[] driftScratch = new float[8192]; + + private volatile bool playbackArmed; + private volatile bool drainRequested; + + // Tracks the largest single Write's audio duration in ms — i.e. the active codec's + // packet-frame size as observed at the buffer level. Used to floor the click-trim + // margin so we don't false-trim during the natural sawtooth caused by packet + // arrival (each packet bumps the buffer by frame-ms, then render drains it down). + // Updated from the network thread; read from the audio thread. Volatile is enough + // because we only ever monotonically increase it within a session lifetime. + private volatile int largestWriteMs; + + // === Drop-cause split === + // Codex pointed out that the legacy `DropCount` on the ring buffer rolled up every reason + // we ever dropped audio bytes, making "Drops" in the diag opaque. These per-cause counters + // let the diag log distinguish: + // * trim drops — smoothness-knob click-trim trimming the buffer toward target + // * drain drops — one-shot drain when the user moves the latency slider + // * catastrophic — TrimFromProducer when the buffer crosses the 1s safety cap + // (Ring-buffer overflow on Write is still counted in playout.DropCount; we expose that + // separately.) Each counter is in BYTES so the magnitudes are comparable. + private long trimDropBytes; + private long drainDropBytes; + // Separate count of how many TIMES the click-trim fired (a tiny number tells us frequency, + // independent of the byte amount). + private long trimFireCount; + + // === Underrun concealment state === + // When the playout ring buffer comes up short on a render-side read, AudioRingBuffer + // silence-fills the missing portion with hard zero. The transient from the last real + // sample (amplitude X) to instant zero produces an audible click, especially on PCM + // (Opus has its own decoder-side PLC for packet loss but doesn't help with audio-thread + // starvation). We replace that hard zero with a brief envelope from the last real sample + // down to silence, and a matching envelope back up when audio resumes. The buffer is still + // silent during a sustained underrun — but the *edges* are smooth, which is where the + // human ear hears the click. ConcealFadeFramesShort at 32 = ~0.67 ms at 48 kHz. + // + // The artifact character is user-pickable (cosine tone short / cosine tone low / noise + // burst / raw click). Each option uses the same edge-smoothing principle but a different + // generator for the burst itself; see ApplyFadeOut / ApplyFadeIn. + private const int ConcealFadeFramesShort = 32; + private const int ConcealFadeFramesLow = 96; + // After this many consecutive empty-buffer reads, stop synthesising concealment and just + // emit silence. Concealment is meant to mask brief transient gaps (a packet late by a few + // ms); it should NOT fire forever when the sender has actually gone away. Without this + // guard, killing the sender produced a "shshshsh" tremolo for ~4 s on the receiver — every + // render callback wrote another noise burst into a buffer that never refilled, until the + // AudioReceiver's idle-prune (4 s) tore the session down. 8 consecutive empties at typical + // 5 ms ASIO render = 40 ms of repeated bursts before we give up; covers normal jitter + // without bleeding into "sender gone" pauses. + private const int ConcealmentMaxConsecutiveEmpties = 8; + private bool inUnderrunConcealment; + private int consecutiveEmptyReads; + private float lastConcealSampleL; + private float lastConcealSampleR; + private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst; + // Per-session RNG for noise concealment. Seeded from process-level Shared so each session + // gets a different sequence — but we don't care about reproducibility, just character. + private readonly Random concealRng = new(Random.Shared.Next()); + + // === Drift correction (Phase 2, 2026-05-06) === + // Continuous low-rate clock-drift correction. The receiver and sender each have their own + // audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB + // audio). Without correction, the playout buffer slowly drifts up (sender faster) or down + // (sender slower) and eventually clicks via either overflow or underrun. + // + // The previous design corrected via a continuously-modulated WdlResampler — which produced + // sample-level corruption and was the source of all the per-sample artefacts we hunted for + // weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern: + // **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO + // FRAME at a time when the integrator signals sustained drift.** A single-frame drop or + // repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal + // content, especially when timed by an integrator that fires only on sustained drift, not + // on packet-arrival jitter. + // + // Mechanism per Read: + // 1. Sample the current buffer level vs target. + // 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator. + // 3. If accumulator >= 1, drop one frame from the head of the playout buffer + // (sender faster — we've consumed less than it produced; speed up consumption by + // one frame). Decrement accumulator. + // 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower — + // stall consumption by one frame). Increment accumulator. + // + // Behaviour by drift rate: + // - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0, + // no corrections fire. Silent. + // - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator + // grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio + // dropped or repeated every ~4 seconds. Inaudible. + // - Higher transient drift (e.g. system load briefly): integrator catches up within + // seconds, brief burst of corrections, then settles. Still inaudible. + // + // The existing click-trim block above is kept as a safety net for catastrophic conditions + // (large step changes that the slow integrator can't keep up with). At normal drift rates + // the integrator never lets the buffer reach the click-trim threshold, so the trim should + // effectively never fire in steady-state operation. + private double driftAccumulatorFrames; + private long prevDriftSampleTicks; + private int pendingRepeatFrames; + private long driftDropFramesTotal; + private long driftRepeatFramesTotal; + // Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous + // gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running + // USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with + // single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades + // click rate for buffer drift; combined with the crossfade-on-splice change, each + // correction is also significantly less audible per event. + // + // At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm + // drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec + // steady-state — which combined with crossfaded splices should push perceived click rate + // toward inaudible. + // + // 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady- + // state clock-drift compensation but pathologically slow when the buffer is far from + // target — e.g. after a slider raise the buffer sits below target and drift correction + // takes minutes to fill it. Empirically observed in user testing as "every session sounds + // different": the buffer wandered for tens of seconds at whatever level the initial + // arming chaos left it at. Now the effective gain scales linearly with absolute error + // beyond the small-error band, capped, so: + // * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle) + // * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale) + // At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving + // a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with + // a barely-audible 0.2% rate offset during the fill. + private const double DriftGain = 0.005; + // Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz. + private const double DriftSmallErrorFrames = 50; + // Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch + // (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content). + private const double DriftMaxGainScale = 20.0; + // Number of stereo frames each side of a splice point that get blended when a drop or + // repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio + // characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz — + // shorter than a typical impulse response, so the smear doesn't blur transients audibly. + private const int DriftCrossfadeFrames = 8; + // Pending corrections (sample-aligned single-frame edits at the next Read). + private int pendingDropFrames; + // Public accessors for the diag log. + public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal); + public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal); + + public IPEndPoint Endpoint { get; } + /// The stream ID this session was opened for. Sessions are keyed by + /// (Endpoint, StreamId) so a single peer can produce multiple simultaneous streams + /// (e.g. WASAPI lane + ASIO lane in the native-independent mode). For single-lane + /// modes there's still one session per peer with whatever streamId the sender chose + /// (currently 1). + public ushort StreamId { get; } + /// Which render route this session's audio belongs to. Set by AudioReceiver + /// from the format packet's Lane byte at session-creation (and updated on the rare + /// in-place format change that keeps the same SessionPlayout alive). PlayoutEngine + /// uses this to decide which of its per-route IWaveProvider surfaces this session + /// contributes to. Defaults to — the value an old + /// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes. + public RenderRoute Route { get; set; } = RenderRoute.Mixed; + public int BufferedBytes => playout.BufferedBytes; + public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate; + public long UnderrunCount => playout.UnderrunCount; + public long DropCount => playout.DropCount; + public bool IsArmed => playbackArmed; + + /// Per-cause drop accessors (cumulative bytes / counts since session start). + /// Splits the previously-opaque DropCount so the diag log can distinguish click-trim + /// from drain-on-knob-change from ringbuffer overflow. AggregateDrops on the engine + /// continues to expose the rolled-up total for back-compat. + public long TrimDropBytes => Interlocked.Read(ref trimDropBytes); + public long DrainDropBytes => Interlocked.Read(ref drainDropBytes); + public long TrimFireCount => Interlocked.Read(ref trimFireCount); + + /// Sets the concealment artifact this session's playout uses on underrun gaps. + /// Takes effect on the very next gap; no need to restart playback. Receiver-side only — + /// the sender doesn't see this and wouldn't behave differently if it did. + public void SetConcealmentArtifact(ConcealmentArtifact value) => + concealmentArtifactRaw = (int)value; + + /// UTC time of the most recent successful audio write. Used by + /// to prune long-idle sessions so the dictionary doesn't grow unboundedly. + public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow; + + public SessionPlayout(IPEndPoint endpoint, ushort streamId, int capacityBytes) + { + Endpoint = endpoint; + StreamId = streamId; + playout = new AudioRingBuffer(capacityBytes); + } + + public void Write(ReadOnlySpan source) + { + var ms = source.Length * 1000 / MixBytesPerSecond; + if (ms > largestWriteMs) largestWriteMs = ms; + playout.Write(source); + LastWriteUtc = DateTime.UtcNow; + } + + /// + /// Network-thread callback after a frame has been queued. Arms playback the moment this + /// session's buffer first reaches the user's target; subsequent reads then engage the + /// drift corrector. Each session arms independently, so a newly-arrived sender can start + /// playing without waiting for already-armed sessions. + /// + /// Also enforces a CATASTROPHIC-only cap on buffer level: if audio piles up beyond 1 second + /// (because the render thread hasn't started yet, or got stuck), we trim down to 250 ms. + /// The threshold is intentionally far above any reasonable jitter cushion — earlier we used + /// 3× target which fought with the drift corrector on a noisy WAN (target 10 ms, real + /// jitter up to 76 ms ⇒ buffer was being trimmed every second, causing the very clicking + /// it was supposed to avoid). Now the cap is purely a safety net against catastrophic + /// backlogs (multi-second pile-ups while no consumer exists); ordinary jitter is absorbed + /// by the buffer + drift corrector + click-trim combo. + /// + public void NoteFramesQueued(int targetLatencyMs) + { + const int CatastrophicCapMs = 1000; + const int CatastrophicTrimToMs = 250; + if (playout.BufferedBytes > MillisecondsToBytes(CatastrophicCapMs)) + { + playout.TrimFromProducer(MillisecondsToBytes(CatastrophicTrimToMs)); + } + + if (playbackArmed) return; + if (playout.BufferedBytes >= MillisecondsToBytes(Math.Max(targetLatencyMs, 1))) + { + playbackArmed = true; + } + } + + /// Disarm and request a drain on the next read — used when the user raises or + /// lowers the latency knob. The mix bus continues with whatever's already armed. + public void DisarmAndRequestDrain() + { + playbackArmed = false; + drainRequested = true; + } + + /// Reset the buffer and per-session state. Used at start/stop. Arming will rebuild + /// from the next packets that arrive. + public void Reset() + { + playout.Reset(); + playbackArmed = false; + largestWriteMs = 0; + inUnderrunConcealment = false; + consecutiveEmptyReads = 0; + lastConcealSampleL = 0f; + lastConcealSampleR = 0f; + driftAccumulatorFrames = 0; + prevDriftSampleTicks = 0; + pendingDropFrames = 0; + pendingRepeatFrames = 0; + } + + public void Dispose() + { + // AudioRingBuffer is managed; nothing to free explicitly. Method present for symmetry + // with Stream/Capture sessions and to allow future per-session unmanaged state. + } + + /// + /// WASAPI/ASIO render thread. Pulls stereo frames from this + /// session's playout ring into , applying drift correction and + /// underrun concealment along the way. Returns the count of frames actually produced; if + /// the session is disarmed (or drained completely) the return is 0 and + /// is untouched (caller is responsible for zero-fill). + /// + public int ReadFloats(Span output, int outFrames, int targetLatencyMs, int currentMaxLatencyMs, int smoothness = 3) + { + // Drain on user knob change. + if (drainRequested) + { + drainRequested = false; + var targetBytes = MillisecondsToBytes(targetLatencyMs); + var buffered = playout.BufferedBytes; + if (buffered > targetBytes) + { + var bytes = buffered - targetBytes; + playout.DropOldest(bytes); + Interlocked.Add(ref drainDropBytes, bytes); + } + } + + if (!playbackArmed) + { + return 0; + } + + // NOTE: there used to be an "auto-disarm if buffer empty" block here. It was added + // 2026-04-30 to clean up phantom underrun counts after a peer disconnect (the + // 4-second idle-prune fires later, so without auto-disarm the underrun counter would + // climb at ~100/sec while we waited). The comment claimed "mix output unchanged + // either way" — but that was wrong at tight target latency. + // + // What auto-disarm did wrong: any time the buffer dipped to zero even momentarily + // (ordinary sender-side mix-tick jitter — a 16ms gap between packets is normal on + // Windows), it would disarm the session and return 0. ReadFloats then output silence + // until packets refilled the buffer ALL THE WAY BACK to the user's target latency + // and NoteFramesQueued re-armed. Each transient underrun became a 10-15ms silence + // gap instead of a few-ms pad. That's the audible click that Ed kept hearing on + // localhost at target=10 vs the older build that ran clean. + // + // Now: a momentary empty buffer just produces a small silence-pad on this Read (the + // underrun counter still increments via playout.ReadFloats's own silence-fill — fine, + // that's diagnostic noise not audio noise). The 4-second idle-prune in AudioReceiver + // still handles long-term disconnect by tearing the session down entirely. No auto- + // disarm needed. + + // === Click-based buffer-smoothness trim === + // + // The Buffer-smoothness knob (1 = aggressive, 10 = smooth) controls how aggressively + // we DROP oldest samples when the buffer drifts above target. When (bufferedMs > + // target + trimMargin) we drop the excess down to target. Causes a brief click at the + // drop point but holds the queue right at the user's chosen latency. + // + // Largely a safety net post-Phase 2: the drift corrector below keeps the buffer near + // target in normal operation, so the trim only fires under catastrophic conditions + // (large step changes the slow integrator can't keep up with). Replaced an earlier + // resampler-rate controller that pitch-shifted music while correcting drift — clicks + // turned out to be the lesser evil, and the trim itself is a direct DropOldest on the + // ring buffer (no resampler involved), so it's guaranteed to fire when needed. + // + // Margin and drop-destination computation. SPLIT BY KNOB: + // + // smoothness == 1 ("stupid aggressive" — used in ASIO Tight Latency mode for + // sub-10 ms target): + // floor = largestWriteMs * 2 + 4 (min 4) + // drop-to = target + largestWriteMs (one packet's cushion only) + // This is the original "reconnect-feel" tightness — tight threshold, + // tight drop, frequent clicks but glued to target. Don't touch this. It's + // what makes ASIO at target=1 ms snap back to target after every burst. + // + // smoothness >= 2: + // floor = largestWriteMs * 4 + 4 (min 15) + // drop-to = target + largestWriteMs * 2 + 5 (covers render-period + + // frame + jitter pad) + // The looser values prevent default-smoothness-3 from tripping on routine + // startup bursts (96 kHz device + Opus 5 ms saw a 40 ms initial buffer that + // exceeded the old default threshold of 32 ms — the rate controller would + // have handled it in a few seconds, but trim fired and dropped the buffer + // too low to absorb normal sender jitter, causing 20 underruns/sec for the + // rest of the session). + // + // Direct evidence: localhost 96 kHz Opus test 2026-05-02 06:37 with + // smoothness=3 — drops=9600 from one startup trim, then 20 underruns/sec. + // Subsequent 48 kHz test with same smoothness ran clean because trim never + // fired (buffer never quite reached the 32 ms threshold). The looser default + // floor lets the rate controller handle initial transients on either device + // rate. + // + // Examples at target=10 ms: + // smoothness=1: + // PCM Tight 2.5: floor=8. drop to 12.5. trims at >18. + // Opus 10: floor=24. drop to 20. trims at >34. + // smoothness=3 (default): + // PCM Tight 2.5: floor=15. drop to 19. trims at >33. + // PCM 5: floor=24. drop to 25. trims at >42. + // Opus 10: floor=44. drop to 35. trims at >62. + var clampedKnob = Math.Clamp(smoothness, 1, 10); + var aggressive = clampedKnob == 1; + var floorMarginMs = aggressive + ? Math.Max(largestWriteMs * 2 + 4, 4) + : Math.Max(largestWriteMs * 4 + 4, 15); + var dropToCushionMs = aggressive + ? largestWriteMs + : largestWriteMs * 2 + 5; + var knobExtraMs = clampedKnob switch + { + 1 => 0, + 2 => 3, + 3 => 8, + 4 => 16, + 5 => 28, + 6 => 45, + 7 => 70, + 8 => 110, + 9 => 200, + _ => -1, // 10 = no trim + }; + if (knobExtraMs >= 0) + { + var trimMarginMs = floorMarginMs + knobExtraMs; + var trimThresholdBytes = MillisecondsToBytes(targetLatencyMs + trimMarginMs); + if (playout.BufferedBytes > trimThresholdBytes) + { + var keepBytes = MillisecondsToBytes(Math.Max(targetLatencyMs + dropToCushionMs, 1)); + var dropBytes = playout.BufferedBytes - keepBytes; + if (dropBytes > 0) + { + playout.DropOldest(dropBytes); + Interlocked.Add(ref trimDropBytes, dropBytes); + Interlocked.Increment(ref trimFireCount); + } + } + } + + // === Drift correction (Phase 2) === + // + // Continuously integrate buffer-level error and drop / repeat single frames at low + // rate to keep buffer aligned with target despite clock-drift between sender and + // receiver crystals. Replaces the continuous adaptive resampling that produced + // sample-level artefacts (analysed 2026-05-06). See the field-block comment above + // for the design rationale. + // + // SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already + // be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g. + // one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on + // the capture side so the wire format is consistent — but if a future change emits + // at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate / + // 48000, computed once, never modulated). The continuous-modulation pattern was the + // bug; a fixed ratio is fine. + var driftTicks = Stopwatch.GetTimestamp(); + var driftTargetBytes = MillisecondsToBytes(targetLatencyMs); + if (prevDriftSampleTicks != 0) + { + var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency; + var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame; + // Adaptive gain: baseline at small errors (gentle steady-state compensation for + // clock drift) but accelerated at large errors (fast convergence after a slider + // raise or initial arming overshoot). Without this, the buffer can sit at any + // level between 0 and target+jitter for tens of seconds — making sessions feel + // randomly different. With this, the buffer reliably reaches target within a few + // seconds of any disturbance. + var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames; + var gainScale = absErrorFrames <= DriftSmallErrorFrames + ? 1.0 + : Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale); + driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale; + // Clamp to prevent runaway in pathological conditions (e.g. session pause). + if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0; + else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0; + } + prevDriftSampleTicks = driftTicks; + + // Queue at most one correction per Read so corrections spread evenly rather than burst. + if (driftAccumulatorFrames >= 1.0) + { + pendingDropFrames++; + driftAccumulatorFrames -= 1.0; + } + else if (driftAccumulatorFrames <= -1.0) + { + pendingRepeatFrames++; + driftAccumulatorFrames += 1.0; + } + + // === Read with optional crossfaded drop / repeat === + // + // The trick to audibly-clean drift correction: don't perform the splice as a hard + // cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then + // CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine + // window blends the audio either side of the splice into a smooth smear instead of + // a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than + // any audible transient and far less perceptible than the original sample-level + // discontinuity. + // + // Splice position: middle of the output buffer. Could choose a low-amplitude moment + // for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on + // typical content and keeps the code simple. + var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0; + var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0; + // Don't try to do both in the same Read; they'd cancel anyway. + if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; } + + if (dropThisCall > 0) + { + // Read outFrames + 1 frames into the output span by reading the first half, + // skipping the splice with crossfade, then reading the second half. We need + // a small extra-sample scratch for the splice. Reuse driftScratch as + // temp storage (it's already managed and grows with outFrames). + var extraFloats = (outFrames + 1) * MixChannels; + if (driftScratch.Length < extraFloats) + { + driftScratch = new float[extraFloats]; + } + var temp = driftScratch.AsSpan(0, extraFloats); + ReadInputWithConcealment(temp); + // Crossfade the splice. Splice position = midpoint of the output frame. + // Result: outFrames samples where one is "elided" via a cosine cross-blend. + ApplyDropCrossfade(temp, output, outFrames); + pendingDropFrames--; + Interlocked.Increment(ref driftDropFramesTotal); + } + else if (repeatThisCall > 0) + { + // Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded + // insertion at the splice point. + var shortFloats = (outFrames - 1) * MixChannels; + if (driftScratch.Length < shortFloats) + { + driftScratch = new float[shortFloats]; + } + var temp = driftScratch.AsSpan(0, shortFloats); + ReadInputWithConcealment(temp); + ApplyRepeatCrossfade(temp, output, outFrames); + pendingRepeatFrames--; + Interlocked.Increment(ref driftRepeatFramesTotal); + } + else + { + ReadInputWithConcealment(output); + } + return outFrames; + } + + /// Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames + /// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames + /// samples on each side. + private static void ApplyDropCrossfade(ReadOnlySpan temp, Span output, int outFrames) + { + // Splice at midpoint of output frames. The "skipped" sample in temp lives at index + // spliceIdx; either side of it gets cross-blended. + var spliceIdx = outFrames / 2; + var window = DriftCrossfadeFrames; + var halfWindow = window / 2; + + // Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim. + var preEnd = spliceIdx - halfWindow; + if (preEnd > 0) + { + temp.Slice(0, preEnd * MixChannels).CopyTo(output); + } + + // Window: cosine crossfade. As we walk through `window` output frames, blend from + // temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the + // "after-skip" sample). The blend mixes consecutive temp positions so the splice + // is spread out smoothly. + for (var k = 0; k < window; k++) + { + var t = (k + 1) / (double)(window + 1); + // Cosine-shaped smooth fade from 0 to 1 across the window. + var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5); + var fadeOut = 1f - fadeIn; + var beforeIdx = (preEnd + k) * MixChannels; + var afterIdx = (preEnd + 1 + k) * MixChannels; + var dstIdx = (preEnd + k) * MixChannels; + output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn; + output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn; + } + + // Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames]. + // The "+1" on the source side is the elision: we skip one frame from temp. + var postStartTemp = spliceIdx + halfWindow + 1; + var postStartOut = spliceIdx + halfWindow; + var postLen = outFrames - postStartOut; + if (postLen > 0) + { + temp.Slice(postStartTemp * MixChannels, postLen * MixChannels) + .CopyTo(output.Slice(postStartOut * MixChannels)); + } + } + + /// Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames + /// with one synthesised at the splice via a cosine blend that "stretches" temp by one + /// frame. + private static void ApplyRepeatCrossfade(ReadOnlySpan temp, Span output, int outFrames) + { + var spliceIdx = outFrames / 2; + var window = DriftCrossfadeFrames; + var halfWindow = window / 2; + + // Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim. + var preEnd = spliceIdx - halfWindow; + if (preEnd > 0) + { + temp.Slice(0, preEnd * MixChannels).CopyTo(output); + } + + // Window of (window + 1) output frames mapped to (window) temp frames. Cosine + // crossfade synthesizes the extra frame: each output sample in the window is a + // blend of two adjacent temp samples, with the blend weight progressing slower than + // the index, effectively inserting a "smoothed" extra sample. + for (var k = 0; k <= window; k++) + { + var t = k / (double)(window + 1); + var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5); + var fadeOut = 1f - fadeIn; + // Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k]. + // For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone. + var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels; + var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels; + var dstIdx = (preEnd + k) * MixChannels; + output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn; + output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn; + } + + // Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames]. + var postStartTemp = spliceIdx + halfWindow; + var postStartOut = spliceIdx + halfWindow + 1; + var postLen = outFrames - postStartOut; + if (postLen > 0) + { + temp.Slice(postStartTemp * MixChannels, postLen * MixChannels) + .CopyTo(output.Slice(postStartOut * MixChannels)); + } + } + + /// + /// Wraps with packet-loss-style concealment. + /// On a short read, replaces the silence-filled tail with a brief synthesised burst + /// (character chosen by ) decaying to zero. On the + /// next full read after a gap, applies a matching fade-in so the resumed audio doesn't + /// start with a hard discontinuity. The result is a smooth attack-and-release at the + /// edges of any gap — the human ear is much more forgiving of "dipped briefly then came + /// back" than of "instant click into silence and instant click back". + /// + /// Stereo-only (matches the rest of the audio path). Output flows through the mix bus + /// and limiter as usual. + /// + private void ReadInputWithConcealment(Span inSpan) + { + var requestedFloats = inSpan.Length; + var floatsRead = playout.ReadFloats(inSpan); + var requestedFrames = requestedFloats / MixChannels; + var framesRead = floatsRead / MixChannels; + + var artifact = (ConcealmentArtifact)concealmentArtifactRaw; + + if (framesRead < requestedFrames) + { + // Don't synthesise concealment forever during a sustained empty-buffer state — the + // sender has probably gone away. After N consecutive empty reads we just leave the + // buffer's hard-zero in place; result is true silence rather than a "shshshsh" + // tremolo as the noise/cosine artifact retriggers each render callback. + consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0; + if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties) + { + // AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the + // head of that silence with the chosen artifact, then leave the rest at zero. + var silenceFrameStart = framesRead; + var silenceFrameCount = requestedFrames - framesRead; + ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact); + } + inUnderrunConcealment = true; + } + else if (inUnderrunConcealment) + { + // First full read after a gap. Fade the new audio in from zero so we don't + // instantly jump back to whatever the new audio's amplitude is. + ApplyFadeIn(inSpan, requestedFrames, artifact); + inUnderrunConcealment = false; + consecutiveEmptyReads = 0; + } + else + { + consecutiveEmptyReads = 0; + } + + // Remember the last real sample for the next fade-out. Use the last frame of actual + // ring data, not anything we just synthesised. (Only meaningful if we read at least + // one real frame this call — i.e. framesRead > 0.) + if (framesRead > 0) + { + var lastIdx = (framesRead - 1) * MixChannels; + lastConcealSampleL = inSpan[lastIdx]; + lastConcealSampleR = inSpan[lastIdx + 1]; + } + } + + /// Synthesises the fade-out burst for the chosen artifact into the silence + /// region starting at . Click variant leaves the buffer's + /// hard-zero in place. + private void ApplyFadeOut(Span inSpan, int startFrame, int silenceFrameCount, ConcealmentArtifact artifact) + { + if (artifact == ConcealmentArtifact.Click) return; // Hard zero; produces the original click. + + var fadeLen = artifact == ConcealmentArtifact.CosineToneLow + ? ConcealFadeFramesLow + : ConcealFadeFramesShort; + var fadeFrames = Math.Min(fadeLen, silenceFrameCount); + for (var f = 0; f < fadeFrames; f++) + { + // Common envelope: cosine ramp from 1.0 → 0.0 across the fade region. + var t = (f + 1) / (double)fadeFrames; + var g = (float)((Math.Cos(Math.PI * t) + 1.0) * 0.5); + var idx = (startFrame + f) * MixChannels; + switch (artifact) + { + case ConcealmentArtifact.NoiseBurst: + // White noise at last-sample peak amplitude. Random per channel — broader + // stereo image than mono noise, and avoids correlated content the brain + // can latch onto as a tone. + var peak = Math.Max(Math.Abs(lastConcealSampleL), Math.Abs(lastConcealSampleR)); + inSpan[idx] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g; + inSpan[idx + 1] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g; + break; + default: + // Cosine-tone variants (short/low). Hold last sample, scaled by envelope. + inSpan[idx] = lastConcealSampleL * g; + inSpan[idx + 1] = lastConcealSampleR * g; + break; + } + } + } + + /// Fades the resumed audio in from zero with the same cosine envelope used on + /// the way out. Click variant skips the fade — the goal of "Click" is to expose the + /// original raw zero-fill behaviour, including its discontinuity at audio resumption. + private static void ApplyFadeIn(Span inSpan, int requestedFrames, ConcealmentArtifact artifact) + { + if (artifact == ConcealmentArtifact.Click) return; + + var fadeLen = artifact == ConcealmentArtifact.CosineToneLow + ? ConcealFadeFramesLow + : ConcealFadeFramesShort; + var fadeFrames = Math.Min(fadeLen, requestedFrames); + for (var f = 0; f < fadeFrames; f++) + { + var t = f / (double)fadeFrames; + var g = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5); + var idx = f * MixChannels; + inSpan[idx] *= g; + inSpan[idx + 1] *= g; + } + } + + private static int MillisecondsToBytes(int milliseconds) => + Math.Max(MixBytesPerFrame, milliseconds * MixBytesPerSecond / 1000); +} diff --git a/src/RemSound.Receiver/StreamSession.cs b/src/RemSound.Receiver/StreamSession.cs new file mode 100644 index 0000000..ecb4fc9 --- /dev/null +++ b/src/RemSound.Receiver/StreamSession.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Runtime.InteropServices; +using Concentus; +using RemSound.Core; + +namespace RemSound.Receiver; + +/// +/// Owns the per-sender decode pipeline. One sender = one StreamSession at a time. When a new +/// sender appears (different remote endpoint, or stream/codec change), the receiver swaps in a +/// new session — old buffered audio drains out of the playout buffer naturally during the +/// swap rather than being thrown away mid-playback. +/// +/// All work runs on the network listener's thread. No locks; the only cross-thread interaction +/// is writing decoded float frames to the SPSC . +/// +internal sealed class StreamSession : IDisposable +{ + private readonly SessionPlayout sessionPlayout; + private readonly ReceiverDiagnostics diagnostics; + private readonly Action onFramesQueued; + private readonly PcmFrameAssembler pcmAssembler = new(); + private IOpusDecoder? opusDecoder; + // Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally + // handled by the (current - expected == 1U) comparison at gap detection. + private uint? expectedNextSequence; + /// Number of single-packet gaps recovered using inband FEC from the next packet. + public long OpusFecRecoveries { get; private set; } + /// Number of multi-packet gaps where FEC could not help (only logs once per occurrence). + public long OpusUnrecoveredGaps { get; private set; } + + public IPEndPoint Endpoint { get; } + public ushort StreamId { get; } + public AudioFormatInfo Format { get; } + public AudioTransportCodec Codec => (AudioTransportCodec)Format.Codec; + + /// For PCM streams: number of incoming packets the assembler rejected outright. + public long PcmFrameRejections => pcmAssembler.RejectionCount; + /// For PCM streams: number of partially-assembled frames discarded mid-flight. + public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount; + + public StreamSession( + IPEndPoint endpoint, + ushort streamId, + AudioFormatInfo format, + SessionPlayout sessionPlayout, + ReceiverDiagnostics diagnostics, + Action onFramesQueued) + { + Endpoint = endpoint; + StreamId = streamId; + Format = format; + this.sessionPlayout = sessionPlayout; + this.diagnostics = diagnostics; + this.onFramesQueued = onFramesQueued; + + if (Codec == AudioTransportCodec.Opus) + { + opusDecoder = OpusCodecFactory.CreateDecoder(format.SampleRate, format.Channels, TextWriter.Null); + } + } + + /// Returns true if this session matches the given format identity (codec/rate/channels/frame). + public bool MatchesFormat(IPEndPoint endpoint, ushort streamId, AudioFormatInfo format) => + Endpoint.Equals(endpoint) + && StreamId == streamId + && Format.Codec == format.Codec + && Format.SampleRate == format.SampleRate + && Format.Channels == format.Channels + && Format.FrameDurationMilliseconds == format.FrameDurationMilliseconds; + + public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint); + + public bool HandleAudioPayload(uint sequence, ReadOnlySpan payload) + { + diagnostics.RecordPacketArrived(); + return Codec switch + { + AudioTransportCodec.Pcm => HandlePcm(payload), + AudioTransportCodec.Opus => HandleOpus(sequence, payload), + _ => false, + }; + } + + public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ } + + // === PCM === + + private bool HandlePcm(ReadOnlySpan payload) + { + if (!RemPcmFrame.TryReadSubHeader(payload, out var frameId, out var partIndex, out var totalParts)) + { + return false; + } + + var partBytes = payload[RemPcmFrame.SubHeaderSize..]; + if (!pcmAssembler.TryAssemble(partBytes, frameId, partIndex, totalParts, out var assembled)) + { + return true; // pending or dropped due to mismatch — not an error condition + } + + // assembled is signed int24 LE, stereo. Convert to float32 and queue. + var sampleCount = assembled.Length / 3; + var floatBytes = sampleCount * sizeof(float); + Span floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes]; + var floatSpan = MemoryMarshal.Cast(floatScratch); + PcmPack.Int24LEToFloat(assembled, floatSpan); + + sessionPlayout.Write(floatScratch); + onFramesQueued(sampleCount / Format.Channels); + return true; + } + + // === Opus === + + private bool HandleOpus(uint sequence, ReadOnlySpan payload) + { + if (opusDecoder is null) return false; + + var frameSize = Math.Max(1, Format.SampleRate * Math.Max(5, Format.FrameDurationMilliseconds) / 1000); + var totalShorts = frameSize * Format.Channels; + Span shortScratch = totalShorts <= 4096 ? stackalloc short[totalShorts] : new short[totalShorts]; + + // Detect a single-packet gap. If the previous packet was N and this is N+2, + // we know N+1 was lost; this packet's payload contains FEC redundancy for + // it. Decode the FEC frame first (so audio plays in order), then the + // current frame. Wrap-around with uint subtraction is intentional. + bool useFecRecovery = false; + if (expectedNextSequence is uint expected) + { + uint gap = sequence - expected; // 0 = exactly expected, 1 = one missing, 2+ = multi-loss + if (gap == 1) + { + useFecRecovery = true; + } + else if (gap > 1 && gap < 1_000_000) + { + // Multi-packet loss — FEC can only recover one. Don't try. + OpusUnrecoveredGaps++; + } + // gap == 0 OR a wild jump (gap >= 1M, e.g. stream reset) → no recovery + } + + if (useFecRecovery) + { + try + { + var fecDecoded = opusDecoder.Decode(payload, shortScratch, frameSize, true); + if (fecDecoded > 0) + { + EmitDecoded(shortScratch, fecDecoded); + OpusFecRecoveries++; + } + } + catch + { + // FEC recovery is best-effort; if it fails, fall through to the + // normal decode and accept a single click rather than crashing. + } + } + + int decoded; + try + { + decoded = opusDecoder.Decode(payload, shortScratch, frameSize, false); + } + catch + { + return false; + } + if (decoded <= 0) return false; + + EmitDecoded(shortScratch, decoded); + expectedNextSequence = sequence + 1U; + return true; + } + + private void EmitDecoded(ReadOnlySpan shortScratch, int sampleCountPerChannel) + { + var floatCount = sampleCountPerChannel * Format.Channels; + var floatBytes = floatCount * sizeof(float); + Span floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes]; + var floatSpan = MemoryMarshal.Cast(floatScratch); + for (var i = 0; i < floatCount; i++) floatSpan[i] = shortScratch[i] / 32768f; + + sessionPlayout.Write(floatScratch); + onFramesQueued(sampleCountPerChannel); + } +} diff --git a/src/RemSound.Sender/AsioCaptureBackend.cs b/src/RemSound.Sender/AsioCaptureBackend.cs new file mode 100644 index 0000000..571eb57 --- /dev/null +++ b/src/RemSound.Sender/AsioCaptureBackend.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using NAudio.Wave; +using NAudio.Wave.Asio; +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// ASIO capture backend. Drives a single for the chosen ASIO driver and +/// produces 48 kHz stereo float frames in the same shape does, so +/// doesn't care which backend is active. +/// +/// Spec identity: each for ASIO uses a synthetic +/// DeviceId of the form "asio:<channel-pair-index>". Channel pair 0 = ASIO +/// channels 0+1, pair 1 = channels 2+3, etc. The driver is implicit (a single driver per +/// session, configured through the Connectivity & transport dialog). +/// +/// Limitations vs the WASAPI backend (deliberate to keep this manageable): +/// • Driver is locked at time. Switching drivers means Stop + new instance. +/// • We always open the AsioOut with the driver's full input channel count, regardless of +/// which pairs the user selected. The unused channels are pulled but discarded. This +/// trades a tiny amount of buffer memory for a big stability win: adding or removing a +/// channel pair never requires reopening the driver, which means we don't fight a +/// concurrent receiver-side AsioOut on single-client drivers (Komplete Audio etc.). +/// • Sample rate is fixed at 48 kHz; if the driver doesn't support that, capture fails to +/// start (the diagnostic line says so). All modern pro audio interfaces support 48 kHz. +/// • Hardware loopback channels (e.g. EVO 8's Loop-back 1/2) are just regular ASIO inputs +/// from our perspective; they live in the same channel space and are picked the same way. +/// +internal sealed class AsioCaptureBackend : ICaptureBackend +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + + // Volatile-published callback. The ASIO audio thread reads this every callback to + // decide where to deliver samples; AudioSender swaps it on mode changes so the same + // open driver can keep running while routing changes between Mixed / AsioLane / no-op. + // Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier). + private volatile Action> onMixedSamples; + private readonly Action? onDiagnostic; + private readonly string driverName; + public string DriverName => driverName; + private readonly object gate = new(); + + private AsioOut? asio; + private List activeChannelPairIndices = []; + private int recordChannelCount; + private float[] mixScratch = new float[1024]; + private float[] interleavedScratch = new float[1024]; + + private long callbackCount; + private long bytesCaptured; + private long clippedSampleCount; + private string? lastError; + private string? captureFormat; + private readonly Stopwatch uptime = new(); + // Per-callback gap tracking. The ASIO callback should fire on a strict period (= buffer + // size in samples / sample rate). When the .NET runtime, GC, USB driver, or Windows + // scheduler stalls the audio thread, that period stretches and the audio stream gets a + // discontinuity — which the receiver can't detect because it just sees a packet arrive + // late. We measure the elapsed time between consecutive callbacks here, track the worst + // since the last read, and let the sender's diag logger surface it. Plain int; access + // is via Interlocked which provides its own memory barriers (no need for volatile). + private long lastCallbackTimestamp; + private int maxCallbackGapMs; + + public AsioCaptureBackend(string driverName, Action> onMixedSamples, Action? onDiagnostic = null) + { + this.driverName = driverName; + this.onMixedSamples = onMixedSamples; + this.onDiagnostic = onDiagnostic; + } + + /// + /// Swap the callback that captured audio is delivered to. Used by AudioSender to keep + /// one persistent AsioCaptureBackend instance alive across audio-mode changes — the + /// driver stays open, the callback gets rewired to the lane appropriate for the new + /// mode (Mixed in AsioOnly, AsioLane in BothIndependent, or a no-op while the + /// composite is being rebuilt). Volatile write, so the audio thread picks the new + /// callback up on its very next ASIO buffer. + /// + public void SetCallback(Action> callback) => + onMixedSamples = callback; + + public bool IsRunning => asio is not null; + public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount); + public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured); + public string? FirstCaptureFormatDescription => captureFormat; + public string? FirstCaptureLastError => lastError; + public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount); + + public IReadOnlyList ActiveSourceNames + { + get + { + lock (gate) + { + return activeChannelPairIndices + .Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}") + .ToList(); + } + } + } + + public void Start(IReadOnlyList specs) + { + lock (gate) + { + if (IsRunning) StopInternal(); + if (specs.Count == 0) return; + + activeChannelPairIndices = ParseChannelPairIndices(specs); + if (activeChannelPairIndices.Count == 0) + { + onDiagnostic?.Invoke("asio capture: no valid channel pair indices in spec list"); + return; + } + + try + { + asio = new AsioOut(driverName); + // Always open with the driver's full input channel count. Pulling channels we + // don't immediately need is essentially free — the driver fills them anyway — + // and it removes the need to ever reopen the AsioOut when the user toggles a + // higher-numbered channel pair. Reopening is what previously caused 15-second + // freezes when both sender and receiver held the same single-client driver + // (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30. + recordChannelCount = asio.DriverInputChannelCount; + if (recordChannelCount <= 0) + { + onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels"); + StopInternal(); + return; + } + asio.InputChannelOffset = 0; + // Sanity-check that the requested pairs are within the driver's channel range. + // We open the full count anyway, but if a saved spec references a pair above + // the driver's range, the OnAudioAvailable mixer would silently emit zero — + // surface that as a diagnostic so it's not mysterious. + var maxPairIndex = activeChannelPairIndices.Max(); + var highestNeededChannel = (maxPairIndex + 1) * 2; + if (highestNeededChannel > recordChannelCount) + { + onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})"); + // Continue anyway — out-of-range pairs just contribute silence to the mix. + } + asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate); + asio.AudioAvailable += OnAudioAvailable; + captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)"; + asio.Play(); + uptime.Restart(); + onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}"); + } + catch (Exception ex) + { + lastError = ex.Message; + onDiagnostic?.Invoke($"asio capture start failed: {ex.GetType().Name}: {ex.Message}"); + StopInternal(); + } + } + } + + public void UpdateSources(IReadOnlyList specs) + { + lock (gate) + { + if (!IsRunning) + { + Start(specs); + return; + } + var newPairs = ParseChannelPairIndices(specs); + // No reopen needed regardless of which pairs change. We always opened the driver + // with its full input channel count at Start, so adding or removing a pair is just + // a matter of which input channels the OnAudioAvailable mixer reads from. Even + // when the new pair set is empty we DO NOT close the driver here — Audient's + // ASIO driver (and several others) doesn't tolerate a close+reopen within a few + // seconds, which is exactly the pattern the user produces by unticking the last + // ASIO source and then ticking another one. Keeping the driver open with zero + // active pairs makes the callback fire harmlessly (zeros) and the next pair + // addition takes effect on the very next callback. The driver only truly closes + // on Stop() or Dispose(), which fire on sender disabled or app exit. + activeChannelPairIndices = newPairs; + onDiagnostic?.Invoke($"asio capture: pairs updated to [{string.Join(",", activeChannelPairIndices)}] (no driver restart)"); + } + } + + public void Stop() + { + lock (gate) StopInternal(); + } + + private void StopInternal() + { + if (asio is not null) + { + try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ } + try { asio.Stop(); } catch { /* ignore */ } + try { asio.Dispose(); } catch { /* ignore */ } + asio = null; + } + uptime.Stop(); + activeChannelPairIndices = []; + recordChannelCount = 0; + } + + public void Dispose() => Stop(); + + private static List ParseChannelPairIndices(IReadOnlyList specs) + { + var result = new List(); + foreach (var spec in specs) + { + if (AsioDeviceId.TryParse(spec.DeviceId, out var pair)) + { + result.Add(pair); + } + } + result.Sort(); + return result.Distinct().ToList(); + } + + public int TakeMaxCallbackGapMs() => Interlocked.Exchange(ref maxCallbackGapMs, 0); + + private void OnAudioAvailable(object? sender, AsioAudioAvailableEventArgs e) + { + Interlocked.Increment(ref callbackCount); + // Capture-callback gap timing. First callback seeds the timestamp without recording a + // gap (we have nothing to compare to). Subsequent callbacks compute the elapsed ms + // since the previous one and CAS-update the max. Skipped entirely when diagnostics + // are off — saves the Stopwatch reads, exchange and CAS loop on every ASIO callback. + if (RemSound.Core.DiagnosticsGate.Enabled) + { + var now = Stopwatch.GetTimestamp(); + var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now); + if (prev != 0) + { + var gapMs = (int)((now - prev) * 1000 / Stopwatch.Frequency); + int current; + do + { + current = Volatile.Read(ref maxCallbackGapMs); + if (gapMs <= current) break; + } while (Interlocked.CompareExchange(ref maxCallbackGapMs, gapMs, current) != current); + } + } + // Pull all interleaved float samples for the recorded channels into a reusable buffer. + var samplesNeeded = e.SamplesPerBuffer * e.InputBuffers.Length; + if (interleavedScratch.Length < samplesNeeded) interleavedScratch = new float[samplesNeeded]; + var written = e.GetAsInterleavedSamples(interleavedScratch); + Interlocked.Add(ref bytesCaptured, written * sizeof(float)); + var interleaved = interleavedScratch; + + // Frame count = total samples / channel count. + var frames = written / Math.Max(1, recordChannelCount); + var stereoFloats = frames * MixChannels; + if (mixScratch.Length < stereoFloats) mixScratch = new float[stereoFloats]; + Array.Clear(mixScratch, 0, stereoFloats); + + // Mix selected channel pairs into the stereo output. Each pair contributes its L/R to + // the mix bus. + List pairs; + lock (gate) pairs = activeChannelPairIndices; + + if (pairs.Count == 0) return; + + for (var f = 0; f < frames; f++) + { + var srcBase = f * recordChannelCount; + var dstBase = f * MixChannels; + float l = 0f, r = 0f; + foreach (var pair in pairs) + { + var lCh = pair * 2; + var rCh = pair * 2 + 1; + if (lCh < recordChannelCount) l += interleaved[srcBase + lCh]; + if (rCh < recordChannelCount) r += interleaved[srcBase + rCh]; + } + // Soft-limit-ish clamp at the encoder boundary; matches MixingEngine. + if (l > 1f) { l = 1f; Interlocked.Increment(ref clippedSampleCount); } + else if (l < -1f) { l = -1f; Interlocked.Increment(ref clippedSampleCount); } + if (r > 1f) { r = 1f; Interlocked.Increment(ref clippedSampleCount); } + else if (r < -1f) { r = -1f; Interlocked.Increment(ref clippedSampleCount); } + mixScratch[dstBase] = l; + mixScratch[dstBase + 1] = r; + } + + onMixedSamples(new ReadOnlyMemory(mixScratch, 0, stereoFloats)); + } + + /// Returns the names of all installed ASIO drivers, or an empty list if NAudio + /// can't find any. Exposed for the App's driver picker UI. + public static IReadOnlyList EnumerateDriverNames() + { + try { return AsioOut.GetDriverNames().ToList(); } + catch { return []; } + } + + /// + /// Briefly opens the named ASIO driver to query its channel counts, then disposes. Single + /// driver instance held for ~50 ms while the COM object reads its channel info — does not + /// claim the device for streaming. Returns (in,out) = (-1,-1) on any failure (driver not + /// installed, busy with another app, etc.). Used by the App to populate channel-pair lists + /// in ASIO mode without holding the driver open between user actions. + /// + public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName) + { + try + { + using var asio = new AsioOut(driverName); + return (asio.DriverInputChannelCount, asio.DriverOutputChannelCount); + } + catch + { + return (-1, -1); + } + } +} diff --git a/src/RemSound.Sender/AsioDeviceProbe.cs b/src/RemSound.Sender/AsioDeviceProbe.cs new file mode 100644 index 0000000..530a032 --- /dev/null +++ b/src/RemSound.Sender/AsioDeviceProbe.cs @@ -0,0 +1,110 @@ +using Microsoft.Win32; +using NAudio.Wave; + +namespace RemSound.Sender; + +/// +/// Public static helpers for the App layer to enumerate ASIO drivers and probe their channel +/// counts and channel names without needing access to the internal +/// / implementation classes. +/// These are read-only queries: opening the driver briefly to read its info, then closing — +/// does NOT claim the device for streaming. +/// +public static class AsioDeviceProbe +{ + /// + /// Names of all installed ASIO drivers. Tries several enumeration paths and merges results, + /// because: + /// • NAudio's built-in AsioOut.GetDriverNames() reads HKLM\SOFTWARE\ASIO in + /// the registry view that matches the calling process. A 64-bit RemSound only sees the + /// 64-bit hive; some ASIO drivers register only into the 32-bit Wow6432Node hive. + /// • A few drivers register under HKCU instead of HKLM. + /// We scan both views and both hives, merge results (case-insensitive de-dup on the + /// registry key name and the human-friendly Description), and return the descriptions. + /// + public static IReadOnlyList EnumerateDriverNames() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var n in AsioOut.GetDriverNames()) names.Add(n); + } + catch { /* ignore — fall through to manual scan */ } + + // Manual scan covers cases NAudio's built-in helper misses. + AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64, names); + AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32, names); + AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64, names); + AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32, names); + + return names.ToList(); + } + + private static void AddFromRegistry(RegistryHive hive, RegistryView view, HashSet names) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var asioKey = baseKey.OpenSubKey(@"SOFTWARE\ASIO"); + if (asioKey is null) return; + foreach (var subKeyName in asioKey.GetSubKeyNames()) + { + using var sub = asioKey.OpenSubKey(subKeyName); + if (sub is null) continue; + // Most drivers store a friendly "Description" value; if absent, the subkey name + // itself is what NAudio uses. + var description = sub.GetValue("Description") as string; + names.Add(string.IsNullOrWhiteSpace(description) ? subKeyName : description); + } + } + catch { /* ignore — that hive/view combo unavailable, fine */ } + } + + /// + /// Probes the named ASIO driver for full info (channel counts + per-channel names). Briefly + /// opens the driver, reads metadata, disposes. Returns a result with empty arrays + −1 + /// counts on any failure. + /// + public static AsioDriverProbeResult ProbeDriverInfo(string driverName) + { + try + { + using var asio = new AsioOut(driverName); + var inCount = asio.DriverInputChannelCount; + var outCount = asio.DriverOutputChannelCount; + var inNames = new List(Math.Max(0, inCount)); + for (var i = 0; i < inCount; i++) + { + try { inNames.Add(asio.AsioInputChannelName(i)); } + catch { inNames.Add($"Input {i + 1}"); } + } + var outNames = new List(Math.Max(0, outCount)); + for (var i = 0; i < outCount; i++) + { + try { outNames.Add(asio.AsioOutputChannelName(i)); } + catch { outNames.Add($"Output {i + 1}"); } + } + return new AsioDriverProbeResult(inCount, outCount, inNames, outNames); + } + catch + { + return new AsioDriverProbeResult(-1, -1, [], []); + } + } + + /// + /// Backwards-compatibility shim around for callers that only + /// need channel counts. + /// + public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName) + { + var info = ProbeDriverInfo(driverName); + return (info.InputChannelCount, info.OutputChannelCount); + } +} + +public sealed record AsioDriverProbeResult( + int InputChannelCount, + int OutputChannelCount, + IReadOnlyList InputChannelNames, + IReadOnlyList OutputChannelNames); diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs new file mode 100644 index 0000000..c7c2ad3 --- /dev/null +++ b/src/RemSound.Sender/AudioSender.cs @@ -0,0 +1,595 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using RemSound.Core; + +namespace RemSound.Sender; + +// PcmPack is in RemSound.Core (used by both Sender and Receiver). + +/// +/// Captures from one or more Windows audio devices via WASAPI (loopback for output devices, +/// direct capture for input devices), mixes them into a single 48 kHz stereo float stream +/// through , encodes (PCM 24-bit or Opus), and sends to a configurable +/// set of UDP receivers. +/// +/// The mixing engine owns the capture lifecycle and the per-source silence keepalive (needed on +/// USB audio interfaces whose loopback callbacks otherwise stall when no app is rendering — see +/// naudio/NAudio#1110). AudioSender just wires the mixer's mixed-sample callback into the +/// existing PCM/Opus encode + UDP path. +/// +/// Threading model: the mixer's tick task delivers 10 ms frames here on its own thread; this +/// class accumulates into PCM 5 ms or Opus 10/20 ms frames and dispatches over UDP. No +/// cross-thread synchronization other than reading a few volatile flags (codec, mute, +/// receiver list). +/// +public sealed class AudioSender : IDisposable +{ + // PCM frame size is configurable via SendRate. Standard = 5 ms (240 samples = 1440 bytes, + // single UDP packet under MaxAudioPayloadBytes=1454). Tight = 2.5 ms (120 samples = 720 + // bytes, also single packet). Tight mode adds nothing structurally — same packet shape, + // just half-size — so the receive-side multipart assembler stays a no-op. + private const int MixChannels = 2; + private const int OpusBitrateLan = 192_000; + private const int PcmStandardSamplesPerChannel = 240; // 5 ms + private const int PcmTightSamplesPerChannel = 120; // 2.5 ms + + // Mutable PCM frame parameters — updated by SetSendRate. Keep them volatile because the + // hot-path read happens on the audio thread while writes come from the UI thread. + private volatile int pcmFrameSamplesPerChannel = PcmStandardSamplesPerChannel; + internal int PcmFrameStereoSamples => pcmFrameSamplesPerChannel * MixChannels; + internal int PcmFrameSamplesPerChannel => pcmFrameSamplesPerChannel; + + private readonly object configGate = new(); + private ICaptureBackend engine; + private IReadOnlyList pendingSources = []; + private readonly UdpClient udp; + // Two lanes. defaultLane carries every output in the three classic modes (Mixed route). + // In BothIndependent mode defaultLane carries WASAPI-only audio (route WasapiLane) and + // asioLane carries ASIO-only audio (route AsioLane), each producing its own UDP stream + // tagged with the matching Lane byte so the receiver routes them to per-lane + // IWaveProvider surfaces. We always construct both lanes — the asio lane sits idle + // (no capture child wired to it) in classic modes and the memory cost is trivial. + private readonly SenderLane defaultLane; + private readonly SenderLane asioLane; + // Persistent AsioCaptureBackend that survives audio-mode changes. The composite borrows + // a reference to it; mode rebuilds rewire its callback (via SetCallback) rather than + // tearing it down and reopening the driver. This avoids Audient (and similar single- + // client drivers) hanging the audio thread for ~5 s on rapid close+reopen — which had + // been crashing the laptop on every "switch between Both and AsioOnly" attempt. + // Lazily created when first needed, disposed when transitioning to WasapiOnly OR when + // the user picks a different ASIO driver entirely. The Action<...> stub is a deliberate + // placeholder that gets immediately swapped via SetCallback in EnsurePersistentAsio. + private AsioCaptureBackend? persistentAsio; + private string? persistentAsioDriverName; + + /// Optional diagnostic sink. Set by callers (typically the App) before Start to receive + /// human-readable status strings ("capture started…", "capture stopped with error…", etc.). + public Action? Diagnostic + { + get => diagnostic; + set => diagnostic = value; + } + private Action? diagnostic; + + // Per-stream state (streamId, audioSequence, frame accumulator, Opus encoder, PCM frame id, + // format-resend timer) now lives on each SenderLane. This file kept its monolithic shape + // through Phase 1/2 — the BothIndependent refactor required splitting "stuff that belongs + // to one outbound stream" from "shared infrastructure". The accumulator/outbound scratch/ + // streamId/sequence counters are all per-lane; the UDP socket, codec config, mute flag, + // engine and stats stay here. See for the per-stream hot path. + + private readonly Stopwatch uptime = new(); + private volatile AudioTransportCodec codec = AudioTransportCodec.Pcm; + private volatile int opusFrameMs = 10; // only meaningful when codec == Opus + private volatile bool muted; + private IPEndPoint[] receivers = []; + private long packetsSent; + private long bytesSent; + + // Internal accessor so SenderLane can read tight-latency without exposing the field + // publicly. Codec, OpusFrameMilliseconds and IsMuted are already exposed publicly below + // and re-used directly by the lane. + internal bool IsTightLatencyEnabled => tightLatencyEnabled; + + // Hot-path timing instrumentation. Both lanes update these on every emit; the SNAP + // timer reads + resets them once per second. Used to split observed inter-packet jitter + // between "our code is slow" vs "the kernel is slow" vs "the network is slow". + // maxEmitTicks = Stopwatch ticks for the WIDEST observation of SenderLane's + // OnMixedSamples (encode + scratch + SendToAll). If this is in + // the multi-ms range, our encode pipeline is the bottleneck. + // maxSendCallTicks = Stopwatch ticks for the WIDEST single udp.Client.SendTo call. + // If this is in the multi-ms range, the kernel TX buffer / NIC + // driver / send-socket contention is the bottleneck. + // Both are reset on each Take() so the SNAP gets per-second peaks. + private long maxEmitTicks; + private long maxSendCallTicks; + internal void RecordEmitTicks(long ticks) + { + long current; + do { current = Volatile.Read(ref maxEmitTicks); } + while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current); + } + internal void RecordSendCallTicks(long ticks) + { + long current; + do { current = Volatile.Read(ref maxSendCallTicks); } + while (ticks > current && Interlocked.CompareExchange(ref maxSendCallTicks, ticks, current) != current); + } + public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency); + public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency); + + // === inbound dispatch (relay-mode) === + // The send socket is normally write-only, but in relay-mode the same socket is what + // catches return packets — the relay forwards traffic into our NAT pinhole, which lives on + // this socket's ephemeral port. An optional inbound-packet callback lets the App route + // those packets into the receiver pipeline (audio) or the heartbeat service. + // Existing LAN peer-to-peer behaviour is unchanged: nothing inbound arrives at this socket + // from a LAN peer because LAN peers send to the receiver's well-known port directly. + private CancellationTokenSource? inboundCts; + private Thread? inboundThread; + private long inboundPackets; + + /// + /// Optional callback invoked for each UDP datagram that arrives at this sender's socket. + /// Buffer is owned by the receive thread — copy what you keep. Length is the byte count + /// (the buffer may be larger). Remote is the sender of the packet (typically a relay). + /// Set this before is called. + /// + public Action? OnInboundPacket { get; set; } + + public AudioSender() + { + udp = new UdpClient(AddressFamily.InterNetwork); + udp.Client.SendBufferSize = 256 * 1024; + udp.Client.ReceiveBufferSize = 256 * 1024; + // Explicit bind to port 0 (OS picks an ephemeral). Two reasons: + // 1. ReceiveFrom on an unbound UDP socket throws SocketException (WSAEINVAL) on + // Windows — the receive thread we start below would then CPU-spin in its + // catch/continue loop. Binding up front makes ReceiveFrom block normally for + // data instead. + // 2. Same NAT pinhole is shared between send and receive — relay mode requires + // this; LAN peer-to-peer is unaffected (we still send from this port, peer just + // sends to its own well-known port as before). + udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0)); + defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan); + asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan); + // WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null. + currentAudioMode = AudioMode.WasapiOnly; + currentAsioDriverName = null; + engine = new CompositeCaptureBackend(currentAudioMode, currentAsioDriverName, defaultLane.OnMixedSamples, asioLane.OnMixedSamples, persistentAsio, msg => diagnostic?.Invoke(msg), useTightLatencyWasapi: false); + } + + // Held so SetTightLatency can rebuild the composite with the same mode/driver. + private AudioMode currentAudioMode; + private string? currentAsioDriverName; + + /// + /// Sets the audio backend mode and (when ASIO is involved) the driver name. The composite is + /// rebuilt to match. Two reachable pipeline shapes today: + /// * WasapiOnly: MixingEngine direct, no ASIO code in the path. Lowest latency for users + /// without ASIO. + /// * BothIndependent: WASAPI MixingEngine + persistent AsioCaptureBackend running side by + /// side, each on its own SenderLane (own streamId, own UDP stream). No mix loop, no tee. + /// Each lane keeps its native latency. + /// Legacy AudioMode.AsioOnly and AudioMode.Both are tolerated (the composite + /// coerces them) but no UI path produces them any more. If running, previously-pending + /// sources are re-applied automatically. + /// + public void SetAudioMode(AudioMode mode, string? asioDriverName) + { + lock (configGate) + { + currentAudioMode = mode; + currentAsioDriverName = asioDriverName; + // Lane route assignment. WasapiOnly: only defaultLane is active, carrying Mixed. + // BothIndependent: defaultLane carries the WASAPI lane, asioLane carries the ASIO + // lane. SetRoute rotates each lane's streamId so the receiver opens a fresh + // session under the new Lane tag — old session drains naturally on its 4-second + // prune. Legacy AsioOnly / Both can't be produced by the UI any more; if they + // arrive (in-flight callers, future call sites) we treat them as BothIndependent + // for routing purposes so the streams still carry distinct Lane tags. + if (mode != AudioMode.WasapiOnly) + { + defaultLane.SetRoute(RenderRoute.WasapiLane); + asioLane.SetRoute(RenderRoute.AsioLane); + } + else + { + defaultLane.SetRoute(RenderRoute.Mixed); + asioLane.SetRoute(RenderRoute.Mixed); // idle; no callbacks will fire on it + } + EnsurePersistentAsioLocked(); + RebuildEngineLocked(); + } + } + + /// + /// Make sure matches the current mode + driver. Created + /// fresh when first transitioning into an ASIO-using mode; reused across subsequent + /// mode changes that keep the same driver; disposed when transitioning to WasapiOnly + /// (no ASIO) or when the user picks a different driver. The persistent instance is + /// loaned to the composite via the constructor; the composite borrows but doesn't + /// dispose, so the underlying ASIO driver handle stays open across engine rebuilds. + /// Caller must hold . The callback is also rewired here based + /// on which lane should receive ASIO audio in the new mode. + /// + private void EnsurePersistentAsioLocked() + { + var willUseAsio = currentAudioMode != AudioMode.WasapiOnly + && !string.IsNullOrEmpty(currentAsioDriverName); + + if (!willUseAsio) + { + // Mode no longer uses ASIO. Dispose the persistent instance so the driver + // releases (other apps may want it). + if (persistentAsio is not null) + { + try { persistentAsio.Dispose(); } catch { /* ignore */ } + persistentAsio = null; + persistentAsioDriverName = null; + } + return; + } + + // Need ASIO. Reuse if the driver matches; rebuild otherwise (rare — only when the + // user picks a different driver in the dropdown). + if (persistentAsio is null || persistentAsioDriverName != currentAsioDriverName) + { + if (persistentAsio is not null) + { + try { persistentAsio.Dispose(); } catch { /* ignore */ } + } + persistentAsio = new AsioCaptureBackend( + currentAsioDriverName!, + _ => { /* placeholder, replaced by SetCallback below */ }, + msg => diagnostic?.Invoke($"asio: {msg}")); + persistentAsioDriverName = currentAsioDriverName; + } + + // Wire the callback to the right lane for the current mode. WasapiOnly never reaches + // here (willUseAsio is false above). BothIndependent is the only ASIO-using mode the + // UI can produce, and it routes ASIO into the dedicated AsioLane. Legacy AsioOnly is + // tolerated by sending into defaultLane (which carries RenderRoute.Mixed in non- + // BothIndependent setups). + persistentAsio.SetCallback( + currentAudioMode == AudioMode.BothIndependent + ? asioLane.OnMixedSamples + : defaultLane.OnMixedSamples); + } + + /// + /// (Re)create the composite backend with the current audio-mode + asio-driver-name + + /// tight-latency-WASAPI flag. Caller must hold configGate. Preserves the running + /// state — if the engine was running before, restart it with the same source list. + /// The persistent ASIO instance is passed in by reference so the composite borrows + /// rather than creates+disposes it; that's what keeps the driver open across rebuilds. + /// + private void RebuildEngineLocked() + { + var wasRunning = engine.IsRunning; + try { engine.Stop(); } catch { /* ignore */ } + try { engine.Dispose(); } catch { /* ignore */ } + engine = new CompositeCaptureBackend( + currentAudioMode, + currentAsioDriverName, + defaultLane.OnMixedSamples, + asioLane.OnMixedSamples, + persistentAsio, + msg => diagnostic?.Invoke(msg), + useTightLatencyWasapi: tightLatencyEnabled); + if (wasRunning && pendingSources.Count > 0) + { + engine.Start(pendingSources); + } + } + + public bool IsAsioBackend => engine is CompositeCaptureBackend; + + /// Updates the PCM frame size based on the user's "Send rate" choice. For Opus, + /// frame size is set via 's opusFrameMs parameter (the App + /// halves it when SendRate is Tight). On a frame-size change, resets the accumulator and + /// stream id so the receiver opens a fresh session at the new format. + public void SetSendRate(SendRate rate) + { + lock (configGate) + { + var newSamples = rate == SendRate.Tight ? PcmTightSamplesPerChannel : PcmStandardSamplesPerChannel; + if (newSamples == pcmFrameSamplesPerChannel) return; + pcmFrameSamplesPerChannel = newSamples; + // Both lanes need to rotate streamId + reset accumulator on a frame-size change. + // The asio lane is idle in classic modes (no producer feeding it) so the reset is + // harmless there; in BothIndependent both lanes are active and both must roll. + defaultLane.OnPcmFrameSizeChanged(); + asioLane.OnPcmFrameSizeChanged(); + } + } + + /// Tight-latency mode toggle. Affects two things: + /// * ASIO-only PCM: every incoming ASIO buffer is emitted directly as a single packet + /// instead of being accumulated to the PCM frame size — saves ~frame_size_ms/2 of + /// average send-side latency. ProcessPcm reads tightLatencyEnabled directly. + /// * WasapiOnly with single source: rebuilds the capture backend as + /// instead of . The WASAPI + /// capture event drives the encode/UDP-send pipeline directly, eliminating the ~6 ms + /// of Stopwatch+WaitHandle scheduler jitter that 's mix tick + /// adds. Especially important at high device sample rates (96 kHz EVO8 etc.) where the + /// in-tick resampler stage compounds the jitter. + /// decides whether push-mode actually applies based on source count and mode. + /// No effect on Opus accumulation (Opus needs fixed frame sizes) or AsioOnly's WASAPI + /// (there's no WASAPI source). Sender-side only as of Phase 3 (2026-05-06): the + /// receiver no longer has a resampler to bypass. + public void SetTightLatency(bool enabled) + { + lock (configGate) + { + if (tightLatencyEnabled == enabled) return; + tightLatencyEnabled = enabled; + RebuildEngineLocked(); + } + } + private volatile bool tightLatencyEnabled; + + public bool IsRunning => engine.IsRunning; + public long CaptureCallbacks => engine.TotalCaptureCallbacks; + public long CaptureBytes => engine.TotalCaptureBytes; + /// Largest gap between capture callbacks since the last call. Resets on read. + /// Use this in periodic diagnostics — if it spikes well above the audio-buffer period + /// (e.g. 19 ms when the period should be ≤ 5 ms), the audio capture thread is being + /// stalled by GC, USB, or scheduler issues, which produces audible discontinuities the + /// receiver can't detect (because no packets are lost — they just contain audio with + /// holes in it). + public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs(); + public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription; + public string? LastCaptureError => engine.FirstCaptureLastError; + public long ClippedSampleCount => engine.ClippedSampleCount; + public AudioTransportCodec Codec => codec; + public int OpusFrameMilliseconds => opusFrameMs; + + /// + /// Atomically set the codec and (for Opus) the frame size. Resets stream identity and the + /// frame accumulator so the receiver sees the new format from the next packet onward. Both + /// parameters are taken together because changing only one would briefly send malformed + /// frames at the encoder boundary. + /// + public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameMs = 10) + { + var clampedFrameMs = Math.Clamp(newOpusFrameMs, 5, 60); + if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameMs == clampedFrameMs)) + { + return; + } + lock (configGate) + { + codec = newCodec; + opusFrameMs = clampedFrameMs; + // Rebuild both lanes' encoders + rotate their streamIds. Same idle-lane rationale + // as SetSendRate — harmless when the asio lane has no producer; necessary when it + // does (BothIndependent). + defaultLane.OnCodecChanged(newCodec, clampedFrameMs); + asioLane.OnCodecChanged(newCodec, clampedFrameMs); + } + } + + public bool IsMuted { get => muted; set => muted = value; } + public long PacketsSent => Interlocked.Read(ref packetsSent); + public long BytesSent => Interlocked.Read(ref bytesSent); + public TimeSpan Uptime => uptime.Elapsed; + + /// + /// Friendly summary of currently-active sources for diagnostic columns. Returns + /// "(none)" when nothing is configured, "(N sources)" when there are 4+ — the snapshot log + /// column is fixed-width-ish and a long join becomes unreadable past 3 sources. + /// + public string CaptureDeviceName + { + get + { + var names = engine.ActiveSourceNames; + if (names.Count == 0) return "(none)"; + if (names.Count <= 3) return string.Join(", ", names); + return $"({names.Count} sources)"; + } + } + + /// Set the destinations to which packets are sent. Live-updateable. + public void SetReceivers(IEnumerable endpoints) + { + var list = endpoints.ToArray(); + Volatile.Write(ref receivers, list); + } + + /// + /// Sets the list of capture sources to mix. Each spec identifies a WASAPI device + whether + /// it's a loopback (output device, system audio) or direct input (mic, line-in). Order does + /// not matter — sources are summed equally. + /// + public void Configure(IReadOnlyList sources) + { + pendingSources = sources; + if (engine.IsRunning) + { + // Live add/remove via NAudio's MixingSampleProvider — mix loop never pauses, + // streamId stays the same, receiver doesn't see a new stream session, no underrun. + engine.UpdateSources(sources); + } + } + + public void Start() + { + if (engine.IsRunning) return; + StartEngineWithCurrentSources(); + } + + private void StartEngineWithCurrentSources() + { + if (pendingSources.Count == 0) + { + diagnostic?.Invoke("sender: start requested but no sources configured"); + return; + } + + defaultLane.ResetForStart(); + asioLane.ResetForStart(); + Interlocked.Exchange(ref packetsSent, 0); + Interlocked.Exchange(ref bytesSent, 0); + uptime.Restart(); + engine.Start(pendingSources); + } + + public void Stop() + { + engine.Stop(); + uptime.Stop(); + } + + /// + /// Start a background thread reading inbound packets from this sender's socket and + /// dispatching them to . Idempotent — safe to call repeatedly. + /// Used in relay mode so heartbeat replies and audio coming back through the relay + /// (which arrive at the sender's NAT pinhole, not the receiver's well-known port) get + /// routed into the right pipelines. No-op for pure LAN peer-to-peer setups. + /// + public void StartReceiving() + { + lock (configGate) + { + if (inboundThread is { IsAlive: true }) return; + inboundCts = new CancellationTokenSource(); + var token = inboundCts.Token; + inboundThread = new Thread(() => InboundReceiveLoop(token)) + { + IsBackground = true, + Name = "RemSound.SenderReceive", + }; + inboundThread.Start(); + } + } + + private void InboundReceiveLoop(CancellationToken token) + { + var buffer = new byte[2048]; + EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0); + while (!token.IsCancellationRequested) + { + int received; + try + { + received = udp.Client.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref anyEndpoint); + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) { break; } + catch (ObjectDisposedException) { break; } + catch (SocketException) { continue; } + catch (OperationCanceledException) { break; } + + if (received <= 0) continue; + if (anyEndpoint is not IPEndPoint remote) continue; + Interlocked.Increment(ref inboundPackets); + + try + { + OnInboundPacket?.Invoke(buffer, received, remote); + } + catch (Exception ex) + { + diagnostic?.Invoke($"sender inbound dispatch threw: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + /// + /// Send an arbitrary datagram on this sender's UDP socket. Used by the heartbeat service + /// in relay mode so its packets share the same NAT pinhole as audio. Returns false if the + /// send failed. + /// + public bool SendVia(byte[] data, int length, IPEndPoint destination) + { + try + { + udp.Send(data, length, destination); + return true; + } + catch (SocketException) { return false; } + catch (ObjectDisposedException) { return false; } + } + + /// Cumulative inbound packets received on this sender's socket. Mostly zero + /// outside relay mode. + public long InboundPackets => Interlocked.Read(ref inboundPackets); + + public void Dispose() + { + Stop(); + try { inboundCts?.Cancel(); } catch { /* ignore */ } + try { inboundThread?.Join(500); } catch { /* ignore */ } + engine.Dispose(); + // Dispose the persistent ASIO LAST, after the engine that was borrowing it. The + // composite's Dispose doesn't touch the persistent instance (it borrowed it); we + // own it here and close the driver as part of app shutdown. + try { persistentAsio?.Dispose(); } catch { /* ignore */ } + persistentAsio = null; + udp.Dispose(); + } + + // === wire path (shared across all lanes) === + + /// + /// Emit a fully-constructed packet to every configured receiver. Per-lane code in + /// builds the header + payload (in its stack/pre-allocated + /// outboundScratch) and calls this; we forward the span straight into the socket's + /// span-aware Send overload so the audio thread never allocates anything in the hot + /// path. The pre-2026-05-11 implementation did packet.ToArray() per send, which on + /// ASIO tight-latency throughput (~750 packets/sec per lane × two lanes in + /// BothIndependent) was a steady ~2 MB/sec of small-byte-array Gen 0 allocations and + /// drove visible packet-emission jitter via GC pauses. The span overload eliminates + /// that entire allocation stream. + /// + /// Single point of outbound socket use means both lanes share the same NAT pinhole + /// and stats. The send-buffer-full or kernel-mutex contention between two threads + /// sending on the same UDP socket is microseconds in practice and not the source of + /// the ms-scale jitter we observe; the per-packet allocation was. + /// + /// UDP failures per-receiver are swallowed by design — UDP is unreliable and one + /// peer dropping shouldn't disturb the others. + /// + internal void SendToAll(ReadOnlySpan packet) + { + var targets = Volatile.Read(ref receivers); + if (targets.Length == 0) return; + + // Use Socket.SendTo with the span overload — UdpClient's span-Send signature is + // .NET 6+. Going via Client (the underlying Socket) avoids one wrapper layer too. + var packetLen = packet.Length; + // Measure the kernel-side time of just the SendTo call when diagnostics are enabled. + // If this number spikes, the bottleneck is the TX path (kernel buffer pressure, NIC, + // single-socket cross-thread contention) rather than our encode pipeline. Hoisted + // out of the per-target loop so a multi-peer broadcast pays one branch instead of N. + var diag = RemSound.Core.DiagnosticsGate.Enabled; + foreach (var target in targets) + { + try + { + if (diag) + { + var sendStart = Stopwatch.GetTimestamp(); + udp.Client.SendTo(packet, target); + RecordSendCallTicks(Stopwatch.GetTimestamp() - sendStart); + } + else + { + udp.Client.SendTo(packet, target); + } + Interlocked.Increment(ref packetsSent); + Interlocked.Add(ref bytesSent, packetLen); + } + catch (SocketException) + { + // Single-packet failures are a non-event; UDP is unreliable by design. + } + catch (ObjectDisposedException) + { + return; + } + } + } +} diff --git a/src/RemSound.Sender/CaptureSource.cs b/src/RemSound.Sender/CaptureSource.cs new file mode 100644 index 0000000..075d30b --- /dev/null +++ b/src/RemSound.Sender/CaptureSource.cs @@ -0,0 +1,193 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// One capture source feeding the mixer. Wraps a single (loopback or +/// direct input) and produces 48 kHz stereo float samples through an NAudio sample-provider chain. +/// +/// Pipeline: +/// WasapiCapture (event-sync, 10 ms buffer) +/// → BufferedWaveProvider (250 ms ring; ReadFully=true pads with silence on underflow, +/// DiscardOnBufferOverflow=true drops oldest on overflow) +/// → ToSampleProvider (bytes → floats) +/// → WdlResamplingSampleProvider (any rate → 48 kHz) +/// → StereoMixDown (any channel layout → stereo) +/// +/// The exposes that final 48 kHz stereo float stream so the mixing engine +/// can plug it into NAudio's . +/// +/// Threading: NAudio's capture event runs on its own dedicated thread. We push samples into a +/// thread-safe BufferedWaveProvider; the mixer's pull thread reads from the sample-provider +/// chain. Standard NAudio idiom — well-tested and avoids hand-rolling SPSC ring buffers. +/// +/// Per-source clock drift across independent audio devices IS unavoidable +/// (https://rogueamoeba.com/support/knowledgebase/?showArticle=Loopback-AggregateDeviceHandling) +/// but the 250 ms ring + automatic discard-on-overflow tolerates it for realistic session +/// lengths. A proper drift-correcting micro-resample is a future addition. +/// +internal sealed class CaptureSource : IDisposable +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int CaptureBufferMs = 10; + private const int RingBufferMs = 250; + + private readonly WasapiCapture capture; + private readonly BufferedWaveProvider buffer; + private readonly Action? onDiagnostic; + private long callbackCount; + private long bytesCaptured; + private string? lastError; + + public string Name { get; } + public CaptureKind Kind { get; } + public string DeviceId { get; } + public ISampleProvider Provider { get; } + public string CaptureFormatDescription { get; } + + public long CallbackCount => Interlocked.Read(ref callbackCount); + public long BytesCaptured => Interlocked.Read(ref bytesCaptured); + public string? LastError => lastError; + public int BufferedMilliseconds => + (int)(buffer.BufferedDuration.TotalMilliseconds); + + public CaptureSource(MMDevice device, CaptureKind kind, string displayName, Action? onDiagnostic = null) + { + Name = displayName; + Kind = kind; + DeviceId = device.ID; + this.onDiagnostic = onDiagnostic; + + capture = kind == CaptureKind.Loopback + ? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs) + : new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs); + + var captureFormat = capture.WaveFormat; + CaptureFormatDescription = + $"{captureFormat.SampleRate} Hz, {captureFormat.Channels} ch, {captureFormat.BitsPerSample}-bit " + + (captureFormat.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : captureFormat.Encoding.ToString()); + + buffer = new BufferedWaveProvider(captureFormat) + { + ReadFully = true, + DiscardOnBufferOverflow = true, + BufferDuration = TimeSpan.FromMilliseconds(RingBufferMs), + }; + + ISampleProvider sp = buffer.ToSampleProvider(); + if (sp.WaveFormat.SampleRate != MixSampleRate) + { + sp = new WdlResamplingSampleProvider(sp, MixSampleRate); + } + if (sp.WaveFormat.Channels != MixChannels) + { + sp = new StereoMixDownSampleProvider(sp); + } + Provider = sp; + + capture.DataAvailable += OnDataAvailable; + capture.RecordingStopped += OnRecordingStopped; + } + + public void Start() + { + try + { + capture.StartRecording(); + onDiagnostic?.Invoke($"capture started \"{Name}\" ({Kind}) at {CaptureFormatDescription}"); + } + catch (Exception ex) + { + lastError = ex.Message; + onDiagnostic?.Invoke($"capture start failed for \"{Name}\": {ex.GetType().Name}: {ex.Message}"); + throw; + } + } + + public void Stop() + { + try { capture.StopRecording(); } catch { /* ignore */ } + } + + public void Dispose() + { + Stop(); + capture.DataAvailable -= OnDataAvailable; + capture.RecordingStopped -= OnRecordingStopped; + capture.Dispose(); + } + + private void OnDataAvailable(object? sender, WaveInEventArgs e) + { + Interlocked.Increment(ref callbackCount); + Interlocked.Add(ref bytesCaptured, e.BytesRecorded); + if (e.BytesRecorded <= 0) return; + try + { + buffer.AddSamples(e.Buffer, 0, e.BytesRecorded); + } + catch (Exception ex) + { + lastError = ex.Message; + onDiagnostic?.Invoke($"capture buffer error for \"{Name}\": {ex.GetType().Name}: {ex.Message}"); + } + } + + private void OnRecordingStopped(object? sender, StoppedEventArgs e) + { + if (e.Exception is not null) + { + lastError = e.Exception.Message; + onDiagnostic?.Invoke($"capture stopped with error for \"{Name}\": {e.Exception.GetType().Name}: {e.Exception.Message}"); + } + } + + /// + /// Down-mixes any channel layout to stereo. Mono is duplicated to L=R; stereo passes through; + /// multi-channel (5.1, 7.1, etc.) takes the front L/R channels (a basic "front-pair" pick, + /// not a full ITU down-mix matrix). Same approach as the legacy RSound build. + /// + private sealed class StereoMixDownSampleProvider : ISampleProvider + { + private readonly ISampleProvider source; + private float[] sourceBuffer = new float[4096]; + + public StereoMixDownSampleProvider(ISampleProvider source) + { + this.source = source; + WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(source.WaveFormat.SampleRate, 2); + } + + public WaveFormat WaveFormat { get; } + + public int Read(float[] buffer, int offset, int count) + { + var frames = count / 2; + var sourceChannels = source.WaveFormat.Channels; + var sourceFloats = frames * sourceChannels; + if (sourceBuffer.Length < sourceFloats) sourceBuffer = new float[sourceFloats]; + var read = source.Read(sourceBuffer, 0, sourceFloats) / Math.Max(sourceChannels, 1); + var written = 0; + for (var i = 0; i < read; i++) + { + if (sourceChannels == 1) + { + var s = sourceBuffer[i]; + buffer[offset + written++] = s; + buffer[offset + written++] = s; + } + else + { + buffer[offset + written++] = sourceBuffer[i * sourceChannels]; + buffer[offset + written++] = sourceBuffer[i * sourceChannels + 1]; + } + } + if (written < count) Array.Clear(buffer, offset + written, count - written); + return count; + } + } +} diff --git a/src/RemSound.Sender/CompositeCaptureBackend.cs b/src/RemSound.Sender/CompositeCaptureBackend.cs new file mode 100644 index 0000000..ea83a4c --- /dev/null +++ b/src/RemSound.Sender/CompositeCaptureBackend.cs @@ -0,0 +1,255 @@ +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// Capture backend that runs a WASAPI and the persistent +/// owned by in parallel, as two +/// independent lanes — each producing its own PCM stream for its own . +/// +/// Two pipeline shapes are reachable today: +/// +/// WasapiOnly: WASAPI child only, no ASIO in the path. Used when no ASIO driver is +/// selected (or none is installed). Lowest latency for WASAPI-only setups. +/// BothIndependent: WASAPI child + persistent ASIO child running side by side. Each +/// delivers samples to its own callback; there is no mix loop, no shared buffer, no +/// tee. ASIO keeps its native sub-5 ms pipeline; WASAPI keeps its WASAPI-event rate. +/// The legacy AudioMode.Both tee-style mode and AudioMode.AsioOnly are +/// no longer reachable from the UI; their enum values remain in +/// for back-compat but produce nothing here. +/// +/// +internal sealed class CompositeCaptureBackend : ICaptureBackend +{ + // WASAPI lane callback. In WasapiOnly this is the only callback in use; in BothIndependent + // it is specifically the WASAPI lane (the ASIO lane has its own callback below). + private readonly Action> onMixedSamples; + // ASIO lane callback. Only meaningful in BothIndependent (passed but unused in WasapiOnly, + // where the persistent ASIO instance is disposed by AudioSender). + private readonly Action>? onAsioLaneSamples; + private readonly Action? onDiagnostic; + private readonly object gate = new(); + + // WASAPI child. Normally a MixingEngine (timer-driven, supports N sources); swapped to + // PushModeWasapiBackend in Start() when useTightLatencyWasapi is true AND there is exactly + // one WASAPI source. Push-mode lets the WASAPI capture event drive the encoder/UDP-send + // pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler jitter that's + // otherwise visible in the receiver as maxGapMs spikes. Multi-source push mode isn't + // supported (rendezvous-of-N-callback-streams problem) — multi-source falls back to + // MixingEngine. + private ICaptureBackend? wasapi; + // ASIO child. BORROWED — AudioSender owns the persistent instance and keeps the driver + // open across audio-mode rebuilds (so Audient and similar drivers don't get a rapid + // close+reopen, which they hate). The composite uses this reference but does NOT + // dispose it; AudioSender disposes on app shutdown or driver change. + private readonly AsioCaptureBackend? asio; + private readonly string? asioDriverName; + private readonly AudioMode mode; + private readonly bool useTightLatencyWasapi; + + private List wasapiSpecs = []; + private List asioSpecs = []; + private bool started; + + public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action> onMixedSamples, Action>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action? onDiagnostic = null, bool useTightLatencyWasapi = false) + { + this.onMixedSamples = onMixedSamples; + this.onAsioLaneSamples = onAsioLaneSamples; + this.onDiagnostic = onDiagnostic; + this.asioDriverName = asioDriverName; + this.mode = mode; + this.useTightLatencyWasapi = useTightLatencyWasapi; + + // Legacy enum values (AsioOnly, Both) are no longer produced by the UI but might + // arrive here from in-flight callers. Coerce them into reachable modes: a non-WASAPI + // request without a driver demotes to WasapiOnly; a non-WASAPI request with a driver + // is treated as BothIndependent (the only ASIO-using mode now). + if (mode != AudioMode.WasapiOnly) + { + if (string.IsNullOrEmpty(asioDriverName) || injectedAsio is null) + { + this.mode = mode = AudioMode.WasapiOnly; + } + else if (mode != AudioMode.BothIndependent) + { + this.mode = mode = AudioMode.BothIndependent; + } + } + + // Always build the WASAPI lane (it is the WasapiOnly callback path, and the WASAPI + // lane in BothIndependent). Push-mode swap, if applicable, happens in Start(). + wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); + + // Borrow the persistent ASIO instance only in BothIndependent. AudioSender already + // pointed its callback at the right lane via SetCallback before constructing us. + if (mode == AudioMode.BothIndependent) + { + asio = injectedAsio; + } + } + + public bool IsRunning => started; + public long TotalCaptureCallbacks => (wasapi?.TotalCaptureCallbacks ?? 0) + (asio?.TotalCaptureCallbacks ?? 0); + public long TotalCaptureBytes => (wasapi?.TotalCaptureBytes ?? 0) + (asio?.TotalCaptureBytes ?? 0); + public string? FirstCaptureFormatDescription => asio?.FirstCaptureFormatDescription ?? wasapi?.FirstCaptureFormatDescription; + public string? FirstCaptureLastError => asio?.FirstCaptureLastError ?? wasapi?.FirstCaptureLastError; + // ClippedSampleCount lived on the (now-removed) classic-Both mix loop; the per-lane + // BothIndependent pipeline has no shared mix bus to clip. Kept as 0 so any UI binding + // that still reads it doesn't NRE. + public long ClippedSampleCount => 0; + + /// Worst callback-gap across both inner backends. We have to take from BOTH (so + /// each inner's counter resets), then return the larger — otherwise the unread inner + /// would just keep accumulating its max forever. + public int TakeMaxCallbackGapMs() + { + var w = wasapi?.TakeMaxCallbackGapMs() ?? 0; + var a = asio?.TakeMaxCallbackGapMs() ?? 0; + return Math.Max(w, a); + } + + public IReadOnlyList ActiveSourceNames + { + get + { + var combined = new List(); + if (wasapi is not null) combined.AddRange(wasapi.ActiveSourceNames); + if (asio is not null) combined.AddRange(asio.ActiveSourceNames); + return combined; + } + } + + public void Start(IReadOnlyList specs) + { + lock (gate) + { + if (started) StopInternal(); + (wasapiSpecs, asioSpecs) = SplitSpecs(specs); + + // Push-mode WASAPI selection. Lets the WASAPI capture event drive the encoder/UDP + // send pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler + // jitter. Conditions: tight-latency requested, and exactly one WASAPI source + // (multi-source needs the rendezvous logic in MixingEngine). Applies equally in + // WasapiOnly and BothIndependent — in either, the WASAPI lane is single-source + // when the user has ticked one input. + var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1; + var currentIsPush = wasapi is PushModeWasapiBackend; + if (wantPushMode != currentIsPush) + { + try { wasapi?.Dispose(); } catch { /* ignore */ } + if (wantPushMode) + { + wasapi = new PushModeWasapiBackend(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); + onDiagnostic?.Invoke("wasapi backend: switched to push-mode (audio-clock-locked, single-source)"); + } + else + { + wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}")); + onDiagnostic?.Invoke("wasapi backend: switched to mix-engine (timer-driven, multi-source capable)"); + } + } + + wasapi!.Start(wasapiSpecs); + // ASIO child is BORROWED from AudioSender. If the driver is already open from a + // previous engine instance we want UpdateSources (which won't close it) rather + // than Start (which would Stop+Open and trigger the close+reopen hang on Audient). + // The callback was already wired to the correct lane by EnsurePersistentAsioLocked. + if (asio is not null) + { + if (asio.IsRunning) asio.UpdateSources(asioSpecs); + else asio.Start(asioSpecs); + } + started = true; + onDiagnostic?.Invoke($"composite capture started: wasapi={wasapiSpecs.Count} sources, asio={asioSpecs.Count} sources, mode={ModeLabel()}{(wantPushMode ? " [wasapi push]" : "")}"); + } + } + + public void UpdateSources(IReadOnlyList specs) + { + lock (gate) + { + if (!started) + { + Start(specs); + return; + } + var (newWasapi, newAsio) = SplitSpecs(specs); + + // If push-mode applicability changes (single WASAPI source toggled on/off), the + // backend has to swap. PushModeWasapiBackend supports only one source. Full + // restart is acceptable here — changing source count mid-session is rare. + var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1; + var isPush = wasapi is PushModeWasapiBackend; + if (wouldBePush != isPush) + { + onDiagnostic?.Invoke($"wasapi backend: source count changed ({wasapiSpecs.Count}→{newWasapi.Count}), restarting to switch backend"); + StopInternal(); + Start(specs); + return; + } + + if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi)) + { + wasapi.UpdateSources(newWasapi); + wasapiSpecs = newWasapi; + } + if (asio is not null && !SpecsEqual(asioSpecs, newAsio)) + { + asio.UpdateSources(newAsio); + asioSpecs = newAsio; + } + } + } + + private string ModeLabel() => mode switch + { + AudioMode.WasapiOnly => "fast (WASAPI direct)", + AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)", + _ => mode.ToString(), + }; + + public void Stop() + { + lock (gate) StopInternal(); + } + + private void StopInternal() + { + if (!started) return; + try { wasapi?.Stop(); } catch { /* ignore */ } + // ASIO child is NEVER stopped here — it's the persistent instance owned by AudioSender + // and kept alive across engine rebuilds. Stopping it would force a close+reopen that + // Audient (and similar drivers) hang on for ~5 s. AudioSender disposes it on app + // shutdown or driver change. + started = false; + } + + public void Dispose() + { + Stop(); + try { wasapi?.Dispose(); } catch { /* ignore */ } + // ASIO child not disposed — see StopInternal above. + } + + private static (List wasapi, List asio) SplitSpecs(IReadOnlyList specs) + { + var wasapi = new List(); + var asio = new List(); + foreach (var spec in specs) + { + if (AsioDeviceId.TryParse(spec.DeviceId, out _)) asio.Add(spec); + else wasapi.Add(spec); + } + return (wasapi, asio); + } + + private static bool SpecsEqual(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count != b.Count) return false; + for (var i = 0; i < a.Count; i++) + { + if (a[i].DeviceId != b[i].DeviceId || a[i].Kind != b[i].Kind) return false; + } + return true; + } +} diff --git a/src/RemSound.Sender/ICaptureBackend.cs b/src/RemSound.Sender/ICaptureBackend.cs new file mode 100644 index 0000000..fa6a1e6 --- /dev/null +++ b/src/RemSound.Sender/ICaptureBackend.cs @@ -0,0 +1,55 @@ +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// Abstraction over the capture-side audio backend so can be wired +/// to either a WASAPI implementation (today's ) or an ASIO +/// implementation () without caring which is in use. +/// +/// Both backends produce 48 kHz stereo float frames via the constructor-supplied +/// onMixedSamples callback and accept the same identity +/// model. ASIO specs use a synthetic of the form +/// "asio:<driver-name>|<channel-pair-index>"; WASAPI specs use the +/// MMDevice ID. +/// +internal interface ICaptureBackend : IDisposable +{ + bool IsRunning { get; } + + /// Total capture callback count across all active sources (WASAPI) or the ASIO + /// driver's input-callback count. + long TotalCaptureCallbacks { get; } + + long TotalCaptureBytes { get; } + + /// Brief format description of the first source (e.g. "96000 Hz, 2 ch, 32-bit + /// float"). Used in diagnostic logs. + string? FirstCaptureFormatDescription { get; } + + string? FirstCaptureLastError { get; } + + /// Cumulative count of samples that hit the soft-limiter / hard-clamp at the + /// encoder boundary. Helpful to know if the source mix is hot enough to need attenuation. + long ClippedSampleCount { get; } + + /// Friendly names of currently-active sources for diagnostic columns. + IReadOnlyList ActiveSourceNames { get; } + + /// Largest observed gap (in milliseconds) between consecutive capture callbacks + /// since the last call. Resets to zero on read. Exposed so the periodic sender diagnostic + /// can surface "the audio capture path stalled for 19 ms" — which on a smoothly-running + /// backend should be ≈ buffer-period, but spikes up when GC, USB, or scheduler hiccups + /// pause the capture thread. A receiver-side gap > 5–10 ms with otherwise clean network + /// is almost always traceable to this value spiking on the sender. Backends that don't + /// support per-callback timing (e.g. trivial test backends) may return 0. + int TakeMaxCallbackGapMs(); + + void Start(IReadOnlyList specs); + + /// Live-update of the active source set without stopping the mix loop. Adds/removes + /// only the sources that actually changed. Behaviour parity expected from both backends. + void UpdateSources(IReadOnlyList specs); + + void Stop(); +} diff --git a/src/RemSound.Sender/LowLatencyWasapiLoopbackCapture.cs b/src/RemSound.Sender/LowLatencyWasapiLoopbackCapture.cs new file mode 100644 index 0000000..0eb45e4 --- /dev/null +++ b/src/RemSound.Sender/LowLatencyWasapiLoopbackCapture.cs @@ -0,0 +1,24 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace RemSound.Sender; + +/// +/// WasapiLoopbackCapture variant that uses event-sync (interrupt-driven) callbacks with a +/// short audio buffer. NAudio's default polls every +/// half-buffer (~50 ms with the default 100 ms buffer), which delivers audio in noticeable +/// bursts and blows up the receiver's playout buffer headroom requirement. +/// +/// With event sync + 10 ms buffer, callbacks fire at the device period (~10 ms typical), +/// each carrying ~10 ms of audio. Far smoother. This is what the older RSound build used, +/// minus the layers of subsequent abstraction. +/// +internal sealed class LowLatencyWasapiLoopbackCapture : WasapiCapture +{ + public LowLatencyWasapiLoopbackCapture(MMDevice device, int audioBufferMilliseconds = 10) + : base(device, useEventSync: true, audioBufferMillisecondsLength: Math.Clamp(audioBufferMilliseconds, 5, 200)) + { + } + + protected override AudioClientStreamFlags GetAudioClientStreamFlags() => AudioClientStreamFlags.Loopback; +} diff --git a/src/RemSound.Sender/MixingEngine.cs b/src/RemSound.Sender/MixingEngine.cs new file mode 100644 index 0000000..db2c20b --- /dev/null +++ b/src/RemSound.Sender/MixingEngine.cs @@ -0,0 +1,358 @@ +using System.Diagnostics; +using NAudio.CoreAudioApi; +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// Owns N objects + an NAudio + +/// a 10 ms mix tick. Each tick pulls one frame's worth of mixed 48 kHz stereo float samples +/// from the mix bus and hands it to , which the caller wires into +/// the encoder/UDP path. +/// +/// Architecture rationale (validated by external research, see notes in CaptureSource.cs): +/// • Separate WASAPI captures, each into a buffered ring, all converted to a common 48 kHz +/// stereo float format, then summed via NAudio's MixingSampleProvider — the canonical +/// pattern (https://www.markheath.net/post/mixing-and-looping-with-naudio). +/// • Loopback captures only fire callbacks when something is rendering on the device. Without +/// a continuous render stream, the mic capture (which always fires) and the loopback (which +/// intermittently fires) desync and the mix gets gaps — see naudio/NAudio#1110. The sender +/// starts a on every loopback source's device to keep +/// callbacks firing continuously. +/// • Per-source clock drift between independent audio devices is unavoidable across long +/// sessions. The 250 ms ring + DiscardOnBufferOverflow tolerates it for realistic +/// conversation lengths. A proper drift-correcting micro-resample is a future addition. +/// +/// Source-list changes are LIVE: diffs the desired set against the +/// active set and only adds/removes the sources that actually changed, using NAudio's +/// AddMixerInput / RemoveMixerInput. The mix loop never pauses; the encoder's streamId stays +/// the same; the receiver doesn't re-init its playout. This is what stops a checkbox toggle +/// from causing a 60 ms gap + receiver underrun + auto-tune freakout. +/// +/// The mix-tick loop runs on its own task with Stopwatch-based scheduling for jitter-tolerant +/// 10 ms timing — better than System.Threading.Timer or Sleep-based loops. +/// +internal sealed class MixingEngine : ICaptureBackend +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int MixTickMs = 10; + private const int MixSamplesPerTick = MixSampleRate * MixChannels * MixTickMs / 1000; // 960 floats + + private readonly Action> onMixedSamples; + private readonly Action? onDiagnostic; + private readonly object gate = new(); + + private readonly List active = []; + private MixingSampleProvider? mixer; + private float[] mixScratch = new float[MixSamplesPerTick]; + private CancellationTokenSource? cts; + private Task? mixTask; + + private long clippedSampleCount; + private long mixTickCount; + + public MixingEngine(Action> onMixedSamples, Action? onDiagnostic = null) + { + this.onMixedSamples = onMixedSamples; + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => mixTask is { IsCompleted: false }; + public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount); + public long MixTickCount => Interlocked.Read(ref mixTickCount); + + public long TotalCaptureCallbacks + { + get + { + lock (gate) + { + long total = 0; + foreach (var a in active) total += a.Source.CallbackCount; + return total; + } + } + } + + public long TotalCaptureBytes + { + get + { + lock (gate) + { + long total = 0; + foreach (var a in active) total += a.Source.BytesCaptured; + return total; + } + } + } + + /// MixingEngine doesn't track per-callback timing — its mix tick is timer-driven + /// rather than callback-driven, so the metric isn't directly meaningful here. Returning 0 + /// is fine: the sender's diag log treats "0 means n/a or no spike". Tight Latency mode in + /// WASAPI uses instead, which is callback-driven. + public int TakeMaxCallbackGapMs() => 0; + + public string? FirstCaptureFormatDescription + { + get { lock (gate) return active.Count == 0 ? null : active[0].Source.CaptureFormatDescription; } + } + + public string? FirstCaptureLastError + { + get { lock (gate) return active.Count == 0 ? null : active[0].Source.LastError; } + } + + public IReadOnlyList ActiveSourceNames + { + get { lock (gate) return active.Select(a => a.Source.Name).ToList(); } + } + + /// + /// Starts the mix loop with the given initial source set. If already running, the existing + /// loop is stopped first. After Start, can be called to add/remove + /// sources without interrupting the loop. + /// + public void Start(IReadOnlyList specs) + { + lock (gate) + { + if (IsRunning) StopInternal(); + if (specs.Count == 0) return; + + var mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels); + mixer = new MixingSampleProvider(mixFormat) { ReadFully = true }; + + foreach (var spec in specs) + { + var entry = OpenSource(spec); + if (entry is null) continue; + mixer.AddMixerInput(entry.Source.Provider); + active.Add(entry); + } + + if (active.Count == 0) + { + onDiagnostic?.Invoke("mixer: no sources opened — staying stopped"); + mixer = null; + return; + } + + foreach (var a in active) + { + try { a.Source.Start(); } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mixer: source \"{a.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}"); + } + } + + Interlocked.Exchange(ref clippedSampleCount, 0); + Interlocked.Exchange(ref mixTickCount, 0); + cts = new CancellationTokenSource(); + mixTask = Task.Run(() => MixLoop(cts.Token)); + onDiagnostic?.Invoke($"mixer started with {active.Count} source(s): [{string.Join(", ", active.Select(a => $"\"{a.Source.Name}\" ({a.Source.Kind})"))}]"); + } + } + + /// + /// Live add/remove of sources without stopping the mix loop. Diffs the desired specs + /// against the currently active set: removes those no longer wanted (RemoveMixerInput + + /// dispose), adds those newly wanted (open + AddMixerInput + start). The mix loop continues + /// reading uninterrupted from whatever is currently in the mixer. + /// + public void UpdateSources(IReadOnlyList specs) + { + lock (gate) + { + // If the engine was started with no sources (specs.Count==0 returns early in + // Start, so mixTask is never created), a later UpdateSources adding sources used + // to silently no-op. That broke the BothIndependent flow where a user starts in + // AsioOnly→BothIndependent with no WASAPI ticks, then later ticks a WASAPI source + // — the lane would never come alive. Mirror AsioCaptureBackend's pattern: when + // not running and the new spec set is non-empty, just delegate to Start. The + // existing empty-specs case (still not running, still no sources to add) stays a + // no-op as before. 2026-05-11. + if (!IsRunning || mixer is null) + { + if (specs.Count > 0) + { + Start(specs); + } + return; + } + + var desiredKeys = specs.Select(s => SourceKey(s.DeviceId, s.Kind)).ToHashSet(); + + // Remove sources no longer wanted. + for (var i = active.Count - 1; i >= 0; i--) + { + var a = active[i]; + if (desiredKeys.Contains(SourceKey(a.Source.DeviceId, a.Source.Kind))) continue; + try { mixer.RemoveMixerInput(a.Source.Provider); } catch { /* ignore */ } + DisposeEntry(a); + active.RemoveAt(i); + onDiagnostic?.Invoke($"mixer: removed source \"{a.Source.Name}\" ({a.Source.Kind})"); + } + + // Add new sources. + var existingKeys = active.Select(a => SourceKey(a.Source.DeviceId, a.Source.Kind)).ToHashSet(); + foreach (var spec in specs) + { + if (existingKeys.Contains(SourceKey(spec.DeviceId, spec.Kind))) continue; + var entry = OpenSource(spec); + if (entry is null) continue; + mixer.AddMixerInput(entry.Source.Provider); + active.Add(entry); + try + { + entry.Source.Start(); + onDiagnostic?.Invoke($"mixer: added source \"{entry.Source.Name}\" ({entry.Source.Kind})"); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mixer: source \"{entry.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}"); + } + } + } + } + + public void Stop() + { + lock (gate) StopInternal(); + } + + private void StopInternal() + { + try { cts?.Cancel(); } catch { /* ignore */ } + try { mixTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ } + cts?.Dispose(); + cts = null; + mixTask = null; + + foreach (var a in active) DisposeEntry(a); + active.Clear(); + mixer = null; + } + + public void Dispose() => Stop(); + + /// + /// Opens a single source from a spec: enumerates the device, creates the capture, attaches a + /// silence keepalive for loopback sources. Does NOT register with the mixer or start capture + /// — caller does that. Returns null on any failure (device gone, format negotiation, etc.) + /// after disposing partial state. + /// + private ActiveSource? OpenSource(CaptureSourceSpec spec) + { + MMDevice? device = null; + try + { + using var enumerator = new MMDeviceEnumerator(); + device = enumerator.GetDevice(spec.DeviceId); + var src = new CaptureSource(device, spec.Kind, spec.Name, onDiagnostic); + SilentRenderKeepAlive? ka = null; + if (spec.Kind == CaptureKind.Loopback) + { + try + { + ka = new SilentRenderKeepAlive(device, onDiagnostic); + ka.Start(); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mixer: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}"); + ka = null; // capture still works without it; just less robust on USB devices + } + } + return new ActiveSource { Source = src, KeepAlive = ka, Device = device }; + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mixer: failed to open source \"{spec.Name}\" ({spec.Kind}): {ex.GetType().Name}: {ex.Message}"); + try { device?.Dispose(); } catch { /* ignore */ } + return null; + } + } + + /// Disposes a source bundle in the right order: keepalive first (it shares the device + /// with the capture; tearing down the device first leaves the keepalive's WasapiOut talking to + /// a freed COM handle), then capture, then device. + private static void DisposeEntry(ActiveSource a) + { + try { a.KeepAlive?.Dispose(); } catch { /* ignore */ } + try { a.Source.Dispose(); } catch { /* ignore */ } + try { a.Device.Dispose(); } catch { /* ignore */ } + } + + private static string SourceKey(string deviceId, CaptureKind kind) => $"{deviceId}|{kind}"; + + private async Task MixLoop(CancellationToken ct) + { + // Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible. + using var threadBoost = new WindowsAudioThreadBoost("Pro Audio"); + + var ticksPerFrame = Stopwatch.Frequency * MixTickMs / 1000; + var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame; + + while (!ct.IsCancellationRequested) + { + try + { + var now = Stopwatch.GetTimestamp(); + if (nextTickStopwatch > now) + { + var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50); + if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break; + continue; + } + + // If we fell catastrophically behind (>4 frames), resync rather than spinning. + if (now - nextTickStopwatch > ticksPerFrame * 4) + { + nextTickStopwatch = now; + } + nextTickStopwatch += ticksPerFrame; + + var localMixer = mixer; + if (localMixer is null) continue; + + var read = localMixer.Read(mixScratch, 0, MixSamplesPerTick); + if (read <= 0) continue; + + // Hard-clamp mixed sum to [-1, 1] to prevent encoder clipping when multiple loud + // sources sum past unity. Counts clipped samples for diagnostics. + long clipped = 0; + for (var i = 0; i < read; i++) + { + var v = mixScratch[i]; + if (v > 1f) { mixScratch[i] = 1f; clipped++; } + else if (v < -1f) { mixScratch[i] = -1f; clipped++; } + } + if (clipped > 0) Interlocked.Add(ref clippedSampleCount, clipped); + Interlocked.Increment(ref mixTickCount); + + onMixedSamples(new ReadOnlyMemory(mixScratch, 0, read)); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"mix loop error: {ex.GetType().Name}: {ex.Message}"); + await Task.Delay(50, ct).ConfigureAwait(false); + } + } + } + + private sealed class ActiveSource + { + public required CaptureSource Source { get; init; } + public required MMDevice Device { get; init; } + public SilentRenderKeepAlive? KeepAlive { get; init; } + } +} diff --git a/src/RemSound.Sender/OpusEncoderState.cs b/src/RemSound.Sender/OpusEncoderState.cs new file mode 100644 index 0000000..9461edc --- /dev/null +++ b/src/RemSound.Sender/OpusEncoderState.cs @@ -0,0 +1,70 @@ +using Concentus; +using Concentus.Enums; + +namespace RemSound.Sender; + +/// +/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio. +/// Frame size is selectable at construction (10 ms or 20 ms). Receiver auto-handles whatever +/// frame size the sender announces in the format packet — no coordination required. +/// +internal sealed class OpusEncoderState : IDisposable +{ + public const int Channels = 2; + private const int PacketBufferBytes = 4000; + + private readonly IOpusEncoder encoder; + private readonly short[] pcm16Scratch; + private readonly byte[] packetScratch = new byte[PacketBufferBytes]; + + public int FrameMilliseconds { get; } + public int FrameSizePerChannel { get; } + + public OpusEncoderState(int frameMilliseconds, int bitrate) + { + // RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames. 10 ms = lowest practical latency, + // 20 ms = same bitrate but more robust to packet loss (each lost packet is half the audio + // share). We expose 10 and 20 as the user-selectable choices. + FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60); + FrameSizePerChannel = 48000 * FrameMilliseconds / 1000; + pcm16Scratch = new short[FrameSizePerChannel * Channels]; + + encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null); + encoder.Bitrate = bitrate; + encoder.Complexity = 10; + encoder.UseVBR = true; + // Inband forward error correction. Each encoded packet carries a low-bitrate + // copy of the PREVIOUS packet's audio. The receiver only uses it when it + // detects a single-packet gap, so on a clean line FEC costs almost nothing + // (the encoder gets a few extra bytes of headroom from VBR). On a lossy + // link it lets the receiver fill a single missing packet without waiting + // — recovery without buffering. + encoder.UseInbandFEC = true; + // Tells the encoder how aggressively to bias FEC redundancy. 10% is a + // sensible value for an internet link via Tailscale: enough redundancy to + // recover most one-packet drops, not so much that we sacrifice quality on + // a clean network. Concentus accepts 0..100. + encoder.PacketLossPercent = 10; + } + + /// Encode one frame at the configured frame size. Returns bytes written. + public int Encode(ReadOnlySpan stereoFloats) + { + if (stereoFloats.Length != FrameSizePerChannel * Channels) + { + throw new ArgumentException($"Expected {FrameSizePerChannel * Channels} samples, got {stereoFloats.Length}", nameof(stereoFloats)); + } + + for (var i = 0; i < stereoFloats.Length; i++) + { + var clamped = Math.Clamp(stereoFloats[i], -1f, 1f); + pcm16Scratch[i] = (short)(clamped * 32767f); + } + + return encoder.Encode(pcm16Scratch, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length); + } + + public ReadOnlySpan LastEncoded(int length) => packetScratch.AsSpan(0, length); + + public void Dispose() { /* IOpusEncoder is finalized by GC, no Dispose */ } +} diff --git a/src/RemSound.Sender/PushModeWasapiBackend.cs b/src/RemSound.Sender/PushModeWasapiBackend.cs new file mode 100644 index 0000000..4f81a7a --- /dev/null +++ b/src/RemSound.Sender/PushModeWasapiBackend.cs @@ -0,0 +1,342 @@ +using System.Runtime.InteropServices; +using NAudio.CoreAudioApi; +using NAudio.Dsp; +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// Single-source WASAPI capture backend with PUSH-DRIVEN timing — the WASAPI capture event +/// callback is the encode/send trigger, so the audio pipeline runs on the audio device's +/// hardware clock instead of the OS scheduler's Stopwatch+WaitHandle clock. +/// +/// Why this exists: uses a Stopwatch-driven 10 ms mix tick that +/// pulls audio through a sample-provider chain. That tick is woken by +/// , which on Windows has ~6 ms of inherent jitter even with +/// MMCSS Pro Audio thread priority — visible as maxGapMs=16-20 ms in the receiver +/// diagnostics. At 48 kHz device rate that jitter is absorbed by buffer cushion; at 96 kHz the +/// extra in-tick resampling stage compounds it and the receiver's buffer ends up sitting +/// ~13 ms lower (closer to the underrun edge), producing audible clicks at tight target +/// latency. +/// +/// Push mode eliminates the mix tick entirely. The WASAPI callback already fires at the +/// device's hardware-clocked period (sub-millisecond precision), and we run the +/// resample / stereo-mixdown / soft-clamp / hand-off-to-encoder pipeline directly on the +/// callback thread. Same architectural shape as already has. +/// +/// Constraints (deliberate scope reduction so we ship something testable): +/// • Single source only. with multiple specs throws — caller is +/// expected to fall back to for multi-source. Mixing N +/// independent WASAPI capture callbacks needs a rendezvous point that doesn't exist +/// in this design. +/// • Float-format capture only. Modern WASAPI loopback / shared-mode delivers +/// 32-bit stereo on every device we've seen. +/// Direct-input devices that report int16 will fall through to a diagnostic and the +/// callback returns silence; caller can fall back to in that +/// case (which uses NAudio's ToSampleProvider conversion path that handles all +/// formats). +/// • Resampling is performed inline using (sinc filter). Same +/// resampler the existing pull path uses — kept identical to keep audio quality +/// comparable. +/// +/// Threading: NAudio's WASAPI callback runs on its own thread, which becomes the audio +/// thread for our purposes. is invoked synchronously from +/// inside that callback, so the encoder/UDP-send work happens on the capture thread. PCM +/// pack and Opus encode are both fast enough not to overrun the next callback period +/// (typically < 200 µs of work per 10 ms callback on modern hardware). +/// +internal sealed class PushModeWasapiBackend : ICaptureBackend +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int CaptureBufferMs = 10; + + private readonly Action> onMixedSamples; + private readonly Action? onDiagnostic; + private readonly object gate = new(); + + private WasapiCapture? capture; + private SilentRenderKeepAlive? keepAlive; + private CaptureSourceSpec? activeSpec; + private string? captureFormatDescription; + private string? lastError; + + private long callbackCount; + private long bytesCaptured; + private long clippedSampleCount; + + // Resampling state — only allocated when source rate != MixSampleRate. + private WdlResampler? resampler; + private int sourceSampleRate; + private int sourceChannels; + + // Reusable scratch buffers. Sized lazily inside the callback. + private float[] sourceFloatScratch = new float[8192]; + private float[] resampledScratch = new float[8192]; + private float[] stereoScratch = new float[4096]; + + public PushModeWasapiBackend(Action> onMixedSamples, Action? onDiagnostic = null) + { + this.onMixedSamples = onMixedSamples; + this.onDiagnostic = onDiagnostic; + } + + public bool IsRunning => capture is not null; + public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount); + public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured); + public string? FirstCaptureFormatDescription => captureFormatDescription; + public string? FirstCaptureLastError => lastError; + public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount); + + public IReadOnlyList ActiveSourceNames => + activeSpec is { } s ? new[] { s.Name } : Array.Empty(); + + /// Push-mode WASAPI is callback-driven and could meaningfully track callback gaps, + /// but for now we don't — adding the timing only matters once we're hunting an audible + /// jitter issue on the WASAPI tight-latency path. Returns 0 (= no spike). Compare with + /// which does track it because that's + /// where Ed has been hunting jitter. + public int TakeMaxCallbackGapMs() => 0; + + public void Start(IReadOnlyList specs) + { + if (specs.Count == 0) + { + onDiagnostic?.Invoke("push-wasapi: start called with no specs — staying stopped"); + return; + } + if (specs.Count > 1) + { + // Surface this loudly. The caller should have routed multi-source to MixingEngine. + throw new InvalidOperationException( + $"PushModeWasapiBackend supports only one source, got {specs.Count}. Caller must fall back to MixingEngine for multi-source."); + } + + lock (gate) + { + if (IsRunning) StopInternal(); + var spec = specs[0]; + try + { + using var enumerator = new MMDeviceEnumerator(); + var device = enumerator.GetDevice(spec.DeviceId); + + capture = spec.Kind == CaptureKind.Loopback + ? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs) + : new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs); + + var fmt = capture.WaveFormat; + sourceChannels = fmt.Channels; + sourceSampleRate = fmt.SampleRate; + captureFormatDescription = $"{fmt.SampleRate} Hz, {fmt.Channels} ch, {fmt.BitsPerSample}-bit " + + (fmt.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : fmt.Encoding.ToString()); + + if (fmt.Encoding != WaveFormatEncoding.IeeeFloat) + { + onDiagnostic?.Invoke( + $"push-wasapi: source \"{spec.Name}\" reports non-float capture format ({fmt.Encoding}); push mode requires IeeeFloat"); + lastError = $"unsupported source encoding: {fmt.Encoding}"; + StopInternal(); + return; + } + + if (fmt.SampleRate != MixSampleRate) + { + resampler = new WdlResampler(); + // Same configuration the existing CaptureSource pull path uses — sinc filter, + // 64-tap, 32 sub-phase. Quality matches the pull path so any audible + // difference vs MixingEngine is timing-driven, not filter-quality-driven. + resampler.SetMode(true, 2, true, 64, 32); + resampler.SetFilterParms(); + resampler.SetFeedMode(false); // pull mode internally; we drive the pull from our callback + resampler.SetRates(sourceSampleRate, MixSampleRate); + } + else + { + resampler = null; + } + + if (spec.Kind == CaptureKind.Loopback) + { + // WASAPI loopback only fires callbacks while something else is rendering on + // the device. Same trick MixingEngine uses (see naudio/NAudio#1110). + try + { + keepAlive = new SilentRenderKeepAlive(device, onDiagnostic); + keepAlive.Start(); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"push-wasapi: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}"); + keepAlive = null; + } + } + + activeSpec = spec; + capture.DataAvailable += OnDataAvailable; + capture.RecordingStopped += OnRecordingStopped; + capture.StartRecording(); + onDiagnostic?.Invoke($"push-wasapi started \"{spec.Name}\" ({spec.Kind}) at {captureFormatDescription}"); + } + catch (Exception ex) + { + lastError = ex.Message; + onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}"); + StopInternal(); + throw; + } + } + } + + public void UpdateSources(IReadOnlyList specs) + { + // Single-source backend; live add/remove like MixingEngine V2 isn't applicable. + // If the spec list shape is unchanged, no-op. Otherwise restart. + var noChange = activeSpec is { } s + && specs.Count == 1 + && specs[0].DeviceId == s.DeviceId + && specs[0].Kind == s.Kind; + if (noChange) return; + lock (gate) StopInternal(); + if (specs.Count > 0) Start(specs); + } + + public void Stop() + { + lock (gate) StopInternal(); + } + + private void StopInternal() + { + if (capture is not null) + { + try { capture.DataAvailable -= OnDataAvailable; } catch { /* ignore */ } + try { capture.RecordingStopped -= OnRecordingStopped; } catch { /* ignore */ } + try { capture.StopRecording(); } catch { /* ignore */ } + try { capture.Dispose(); } catch { /* ignore */ } + capture = null; + } + if (keepAlive is not null) + { + try { keepAlive.Dispose(); } catch { /* ignore */ } + keepAlive = null; + } + resampler = null; + activeSpec = null; + } + + public void Dispose() => Stop(); + + private void OnDataAvailable(object? sender, WaveInEventArgs e) + { + Interlocked.Increment(ref callbackCount); + Interlocked.Add(ref bytesCaptured, e.BytesRecorded); + if (e.BytesRecorded <= 0) return; + + try + { + // 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start). + var sourceFloatCount = e.BytesRecorded / sizeof(float); + if (sourceFloatScratch.Length < sourceFloatCount) + sourceFloatScratch = new float[sourceFloatCount]; + // MemoryMarshal.Cast avoids a copy where layout permits, but e.Buffer is byte[] and we + // need the floats indexable so we copy into our scratch. Copy is cheap: 7680 bytes for + // 10 ms at 96 kHz stereo float. + Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded); + var sourceFrames = sourceFloatCount / sourceChannels; + + // 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the + // pull from our callback. Approximate output frames = input * outRate / inRate. + float[] working; + int workingFrames; + int workingChannels; + if (resampler is null) + { + working = sourceFloatScratch; + workingFrames = sourceFrames; + workingChannels = sourceChannels; + } + else + { + // Compute a generous upper bound on output frames (add a small pad for the + // resampler's lookahead). The resampler is fed exactly what it needs and tells us + // how many output frames it actually produced; any input we couldn't feed in this + // iteration is held in its internal state for next callback. + var outBound = (int)Math.Ceiling(sourceFrames * (double)MixSampleRate / sourceSampleRate) + 16; + if (resampledScratch.Length < outBound * sourceChannels) + resampledScratch = new float[outBound * sourceChannels]; + + var inFramesNeeded = resampler.ResamplePrepare(outBound, sourceChannels, out var inBuf, out var inOff); + var copyFrames = Math.Min(sourceFrames, inFramesNeeded); + if (copyFrames > 0) + { + Array.Copy(sourceFloatScratch, 0, inBuf, inOff, copyFrames * sourceChannels); + } + var produced = resampler.ResampleOut(resampledScratch, 0, copyFrames, outBound, sourceChannels); + working = resampledScratch; + workingFrames = produced; + workingChannels = sourceChannels; + } + + if (workingFrames <= 0) return; + + // 3. Stereo mixdown. Mono → duplicate; stereo → passthrough; multi-channel → take + // front L/R (matches StereoMixDownSampleProvider in CaptureSource). + float[] stereo; + if (workingChannels == 2) + { + stereo = working; + } + else + { + if (stereoScratch.Length < workingFrames * MixChannels) + stereoScratch = new float[workingFrames * MixChannels]; + if (workingChannels == 1) + { + for (var i = 0; i < workingFrames; i++) + { + stereoScratch[i * 2] = working[i]; + stereoScratch[i * 2 + 1] = working[i]; + } + } + else + { + for (var i = 0; i < workingFrames; i++) + { + stereoScratch[i * 2] = working[i * workingChannels]; + stereoScratch[i * 2 + 1] = working[i * workingChannels + 1]; + } + } + stereo = stereoScratch; + } + + // 4. Soft clamp at the encoder boundary (matches MixingEngine / AsioCaptureBackend). + var stereoFloatCount = workingFrames * MixChannels; + for (var i = 0; i < stereoFloatCount; i++) + { + var v = stereo[i]; + if (v > 1f) { stereo[i] = 1f; Interlocked.Increment(ref clippedSampleCount); } + else if (v < -1f) { stereo[i] = -1f; Interlocked.Increment(ref clippedSampleCount); } + } + + // 5. Hand off to the encoder/UDP-send pipeline. Synchronous on the capture thread. + onMixedSamples(new ReadOnlyMemory(stereo, 0, stereoFloatCount)); + } + catch (Exception ex) + { + lastError = ex.Message; + onDiagnostic?.Invoke($"push-wasapi: callback error: {ex.GetType().Name}: {ex.Message}"); + } + } + + private void OnRecordingStopped(object? sender, StoppedEventArgs e) + { + if (e.Exception is not null) + { + lastError = e.Exception.Message; + onDiagnostic?.Invoke($"push-wasapi: capture stopped with error: {e.Exception.GetType().Name}: {e.Exception.Message}"); + } + } +} diff --git a/src/RemSound.Sender/RemSound.Sender.csproj b/src/RemSound.Sender/RemSound.Sender.csproj new file mode 100644 index 0000000..6d2f89b --- /dev/null +++ b/src/RemSound.Sender/RemSound.Sender.csproj @@ -0,0 +1,17 @@ + + + net10.0-windows + enable + enable + true + RemSound.Sender + RemSound.Sender + true + + + + + + + + diff --git a/src/RemSound.Sender/SenderLane.cs b/src/RemSound.Sender/SenderLane.cs new file mode 100644 index 0000000..5050a4c --- /dev/null +++ b/src/RemSound.Sender/SenderLane.cs @@ -0,0 +1,305 @@ +using RemSound.Core; + +namespace RemSound.Sender; + +/// +/// One outbound audio stream's worth of state. Each lane owns its own streamId, audio +/// sequence counter, frame accumulator, Opus encoder, format-resend timer and PCM frame id. +/// AudioSender holds one or more of these — in the three classic modes (WasapiOnly, +/// AsioOnly, Both) there is exactly one lane and behaviour is identical to the pre-refactor +/// monolithic AudioSender. The BothIndependent mode (Stage 4) instantiates two: a WASAPI +/// lane fed by the WASAPI capture child and an ASIO lane fed by the ASIO capture child, each +/// producing its own UDP stream on its own streamId, multiplexed by the receiver's +/// (endpoint, streamId) keying. +/// +/// Threading: the hot-path methods ( and below) are called from +/// the capture engine's callback thread. Each lane has exactly one such thread feeding it. +/// Cross-thread state read from AudioSender (codec, mute, opusFrameMs, etc.) goes through +/// volatile fields on the owner. Configuration mutations (, +/// ) come from the UI thread; they take the same +/// configGate that AudioSender does to serialise streamId rotation against in-flight +/// accumulator writes — see AudioSender for the gate. +/// +internal sealed class SenderLane +{ + private const int MixSampleRate = 48000; + private const int MixChannels = 2; + private const int MaxFrameStereoSamples = MixSampleRate * 20 / 1000 * MixChannels; // 1920, Opus 20 ms + private const int FormatResendIntervalMs = 250; + + private readonly AudioSender owner; + private readonly int opusBitrate; + + // Hot-path scratch. Sized to the largest possible single frame (Opus 20 ms = 1920 stereo + // samples). PCM 5 ms uses only the first 480, Opus 10 ms only the first 960. Reusing one + // buffer means no realloc on codec change. outboundScratch is per-lane so two lanes don't + // step on each other's packet construction. + private readonly float[] frameAccumulator = new float[MaxFrameStereoSamples]; + private int frameAccumulatorWritten; + private readonly byte[] outboundScratch = new byte[2048]; + + // Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus + // FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic + // format-announce packet; receiver doesn't sequence-check format packets but having a + // separate counter keeps the audio FEC clean (see AudioSender.audioSequence comment for + // the original reasoning). + private uint audioSequence; + private uint pcmFrameId; + private uint formatSequence; + private ushort streamId; + private DateTime lastFormatPacketUtc = DateTime.MinValue; + + private OpusEncoderState opusEncoder; + private int opusFrameStereoSamples; + + // Which render route this lane announces in its format packets. The receiver reads the + // Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine + // route the lane's audio to the corresponding per-route IWaveProvider surface (lane + // backends in BothIndependent mode; the legacy Mixed surface in every classic mode). + // Default Mixed = classic-mode behaviour, indistinguishable from a pre-2026-05-11 sender. + // BothIndependent assigns WasapiLane / AsioLane to the two SenderLanes at mode-change + // time via SetRoute. + private volatile RenderRoute route = RenderRoute.Mixed; + public RenderRoute Route => route; + + public ushort StreamId => streamId; + + public SenderLane(AudioSender owner, int initialOpusFrameMs, int opusBitrate) + { + this.owner = owner; + this.opusBitrate = opusBitrate; + opusEncoder = new OpusEncoderState(initialOpusFrameMs, opusBitrate); + opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels; + streamId = NewStreamId(); + } + + private static ushort NewStreamId() => (ushort)Random.Shared.Next(1, ushort.MaxValue); + + /// + /// Set this lane's render route. Called by AudioSender when audio-mode changes — e.g. + /// switching into BothIndependent flips the default lane from Mixed to WasapiLane and + /// activates the asio lane as AsioLane. Rotates streamId and forces an immediate format + /// re-announce so the receiver opens a fresh session with the new Lane tag rather than + /// continuing to route the existing session under the old tag. + /// + public void SetRoute(RenderRoute newRoute) + { + if (route == newRoute) return; + route = newRoute; + streamId = NewStreamId(); + lastFormatPacketUtc = DateTime.MinValue; + frameAccumulatorWritten = 0; + } + + /// Reset per-lane counters and pick a new streamId. Called from + /// so the receiver sees a fresh session on each start. + public void ResetForStart() + { + streamId = NewStreamId(); + audioSequence = 0; + pcmFrameId = 0; + formatSequence = 0; + frameAccumulatorWritten = 0; + lastFormatPacketUtc = DateTime.MinValue; + } + + /// + /// Codec just changed. Rotates streamId (the receiver opens a fresh session at the new + /// format), rebuilds the Opus encoder if Opus is in play, and zeroes the accumulator so + /// any half-filled frame from the previous format doesn't leak into the new one. + /// + public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameMs) + { + if (newCodec == AudioTransportCodec.Opus) + { + opusEncoder = new OpusEncoderState(opusFrameMs, opusBitrate); + opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels; + } + streamId = NewStreamId(); + lastFormatPacketUtc = DateTime.MinValue; + frameAccumulatorWritten = 0; + } + + /// PCM frame size just changed. Rotates streamId so the receiver sees a fresh + /// session at the new packet cadence and resets the accumulator. No encoder rebuild — + /// Opus is unaffected by the PCM send-rate setting. + public void OnPcmFrameSizeChanged() + { + streamId = NewStreamId(); + lastFormatPacketUtc = DateTime.MinValue; + frameAccumulatorWritten = 0; + } + + // === hot path === + + public void OnMixedSamples(ReadOnlyMemory stereoFloats) + { + var span = stereoFloats.Span; + if (span.IsEmpty) return; + + // Whole-callback timing — captures encode plus kernel send for the SNAP's emitMs + // column. Skipped entirely when diagnostics are off so the audio thread doesn't pay + // two Stopwatch reads + a CAS loop per callback for a number nobody is going to log. + var diag = RemSound.Core.DiagnosticsGate.Enabled; + var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; + EnsureFormatPacketSent(); + + switch (owner.Codec) + { + case AudioTransportCodec.Pcm: + ProcessPcm(span); + break; + case AudioTransportCodec.Opus: + ProcessOpus(span); + break; + } + if (diag) owner.RecordEmitTicks(System.Diagnostics.Stopwatch.GetTimestamp() - emitStart); + } + + private void ProcessPcm(ReadOnlySpan samples) + { + // Tight-latency mode: emit each delivered sample buffer as its own packet instead of + // accumulating to the PCM frame size. Saves up to (frame_size_ms / 2) of average + // accumulator delay. Variable packet size per call. Cap at 240 stereo-frames (5 ms = + // 1440 bytes) to stay under MaxAudioPayloadBytes=1454; in normal ASIO buffer sizes + // (64/128) this cap is never hit. + if (owner.IsTightLatencyEnabled) + { + const int MaxStereoSamplesPerPacket = 240 * MixChannels; + var pos = 0; + while (pos < samples.Length) + { + var chunk = Math.Min(MaxStereoSamplesPerPacket, samples.Length - pos); + EmitPcmFrame(samples.Slice(pos, chunk)); + pos += chunk; + } + return; + } + + var pcmFrameStereoSamples = owner.PcmFrameStereoSamples; + var idx = 0; + while (idx < samples.Length) + { + var spaceLeftForPcmFrame = pcmFrameStereoSamples - frameAccumulatorWritten; + var copy = Math.Min(spaceLeftForPcmFrame, samples.Length - idx); + samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten)); + frameAccumulatorWritten += copy; + idx += copy; + + if (frameAccumulatorWritten == pcmFrameStereoSamples) + { + EmitPcmFrame(frameAccumulator.AsSpan(0, pcmFrameStereoSamples)); + frameAccumulatorWritten = 0; + } + } + } + + private void ProcessOpus(ReadOnlySpan samples) + { + var frameSamples = opusFrameStereoSamples; + var idx = 0; + while (idx < samples.Length) + { + var spaceLeft = frameSamples - frameAccumulatorWritten; + var copy = Math.Min(spaceLeft, samples.Length - idx); + samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten)); + frameAccumulatorWritten += copy; + idx += copy; + + if (frameAccumulatorWritten == frameSamples) + { + EmitOpusFrame(frameAccumulator.AsSpan(0, frameSamples)); + frameAccumulatorWritten = 0; + } + } + } + + private void EmitPcmFrame(ReadOnlySpan stereoFloats) + { + var bytesOnWire = stereoFloats.Length * 3; + Span int24 = stackalloc byte[bytesOnWire]; + if (owner.IsMuted) + { + int24.Clear(); + } + else + { + PcmPack.FloatToInt24LE(stereoFloats, int24); + } + pcmFrameId++; + SendPcmPart(pcmFrameId, partIndex: 0, totalParts: 1, int24); + } + + private void EmitOpusFrame(ReadOnlySpan stereoFloats) + { + ReadOnlySpan opusBytes; + if (owner.IsMuted) + { + Span silence = stackalloc float[opusFrameStereoSamples]; + silence.Clear(); + var muteLen = opusEncoder.Encode(silence); + opusBytes = opusEncoder.LastEncoded(muteLen); + } + else + { + var len = opusEncoder.Encode(stereoFloats); + if (len <= 0) return; + opusBytes = opusEncoder.LastEncoded(len); + } + SendAudio(opusBytes); + } + + // === wire path === + + private void EnsureFormatPacketSent() + { + if (DateTime.UtcNow - lastFormatPacketUtc < TimeSpan.FromMilliseconds(FormatResendIntervalMs)) return; + lastFormatPacketUtc = DateTime.UtcNow; + + // PCM FrameDurationMilliseconds: receiver only uses this for buffer sizing and + // diagnostics, not for decode. Round 2.5 ms up to ≥1 to keep the wire field integer. + var pcmFrameMs = owner.PcmFrameSamplesPerChannel * 1000 / MixSampleRate; + if (pcmFrameMs < 1) pcmFrameMs = 1; + var codec = owner.Codec; + var opusFrameMs = owner.OpusFrameMilliseconds; + // Pass this lane's current Route as the Lane field. In classic-mode senders this is + // Mixed and the receiver routes the session to its legacy mix bus; in BothIndependent + // senders this is WasapiLane or AsioLane and the receiver routes to the matching + // per-route IWaveProvider surface. + var format = codec == AudioTransportCodec.Opus + ? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameMs, route) + : new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, pcmFrameMs, route); + + // Allocate the extended (36-byte) format payload — see RemPacket.FormatPayloadExtendedSize + // for the backward-compat contract. Old receivers parse the first 32 bytes and ignore + // the rest; new receivers read the Lane byte to decide which render route this stream + // belongs to. The Lane value carried here comes from the AudioFormatInfo constructed + // above, which currently always sets Mixed for the default lane; Stage 4 will set + // WasapiLane / AsioLane on the second lane in BothIndependent mode. + Span packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadExtendedSize]; + RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence); + RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format); + owner.SendToAll(packet); + } + + private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan partBytes) + { + var headerSize = RemPacket.HeaderSize; + var subHeaderSize = RemPcmFrame.SubHeaderSize; + var totalLen = headerSize + subHeaderSize + partBytes.Length; + var dst = outboundScratch.AsSpan(0, totalLen); + RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence); + RemPcmFrame.WriteSubHeader(dst.Slice(headerSize, subHeaderSize), frameId, partIndex, totalParts); + partBytes.CopyTo(dst[(headerSize + subHeaderSize)..]); + owner.SendToAll(dst); + } + + private void SendAudio(ReadOnlySpan opusBytes) + { + var totalLen = RemPacket.HeaderSize + opusBytes.Length; + var dst = outboundScratch.AsSpan(0, totalLen); + RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence); + opusBytes.CopyTo(dst[RemPacket.HeaderSize..]); + owner.SendToAll(dst); + } +} diff --git a/src/RemSound.Sender/SilentRenderKeepAlive.cs b/src/RemSound.Sender/SilentRenderKeepAlive.cs new file mode 100644 index 0000000..72eb247 --- /dev/null +++ b/src/RemSound.Sender/SilentRenderKeepAlive.cs @@ -0,0 +1,61 @@ +using NAudio.CoreAudioApi; +using NAudio.Wave; + +namespace RemSound.Sender; + +/// +/// Pins a continuous silent render stream on a WASAPI device so the device stays "warm". +/// Some USB audio interfaces (Audient EVO8, RME, Focusrite, etc.) only fire WASAPI loopback +/// callbacks when something is actively rendering to the device — when the render endpoint +/// goes idle, the loopback path stops delivering frames until an application starts rendering +/// again. By pinning a zero-volume silent render stream on the same device we capture from, +/// loopback callbacks keep firing regardless of whether other apps are playing audio. +/// +/// Same idea the legacy "silence.exe" used; folded into the sender so it's automatic, sized +/// to the device's actual mix format (no resampler stage), and ties to capture lifetime. +/// We do not own the MMDevice — AudioSender does — so we never dispose it. +/// +internal sealed class SilentRenderKeepAlive : IDisposable +{ + private readonly WasapiOut output; + private readonly Action? onDiagnostic; + + public SilentRenderKeepAlive(MMDevice device, Action? onDiagnostic = null) + { + this.onDiagnostic = onDiagnostic; + // Shared mode with a 50 ms buffer. Latency doesn't matter for silence; longer buffers + // mean fewer wakeups per second. Event sync still gives us efficient blocking turnover. + output = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 50); + output.Init(new SilenceProvider(output.OutputWaveFormat)); + } + + public void Start() + { + try + { + output.Play(); + onDiagnostic?.Invoke($"silence keepalive started ({output.OutputWaveFormat.SampleRate} Hz, {output.OutputWaveFormat.Channels} ch)"); + } + catch (Exception ex) + { + onDiagnostic?.Invoke($"silence keepalive start failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + public void Dispose() + { + try { output.Stop(); } catch { /* ignore */ } + try { output.Dispose(); } catch { /* ignore */ } + } + + private sealed class SilenceProvider(WaveFormat format) : IWaveProvider + { + public WaveFormat WaveFormat { get; } = format; + + public int Read(byte[] buffer, int offset, int count) + { + Array.Clear(buffer, offset, count); + return count; + } + } +}