Initial commit: RemSound v1.0
This commit is contained in:
+39
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 <https://dotnet.microsoft.com/download/dotnet/10.0> (pick the "Windows x64 Desktop Runtime").
|
||||
|
||||
## Updates
|
||||
|
||||
RemSound can check this repository's Releases page on a schedule (never, hourly, every 6 hours, every 24 hours) and either prompt you to install or do it silently. Configure via File → Preferences. You can also trigger a manual check from the Help menu or the same Preferences dialog.
|
||||
|
||||
## Build from source
|
||||
|
||||
You need the .NET 10 SDK. The solution lives at `RemSound.slnx`.
|
||||
|
||||
```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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/RemSound.Core/RemSound.Core.csproj" />
|
||||
<Project Path="src/RemSound.Sender/RemSound.Sender.csproj" />
|
||||
<Project Path="src/RemSound.Receiver/RemSound.Receiver.csproj" />
|
||||
<Project Path="src/RemSound.Harness/RemSound.Harness.csproj" />
|
||||
<Project Path="src/RemSound.App/RemSound.App.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -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-<tag>.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"
|
||||
BIN
Binary file not shown.
Binary file not shown.
+815
@@ -0,0 +1,815 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RemSound user manual</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Tahoma, sans-serif; max-width: 880px; margin: 1em auto; padding: 0 1em; line-height: 1.5; }
|
||||
h1 { border-bottom: 2px solid #444; padding-bottom: 0.3em; }
|
||||
h2 { border-bottom: 1px solid #888; padding-bottom: 0.2em; margin-top: 2em; }
|
||||
h3 { margin-top: 1.5em; }
|
||||
table { border-collapse: collapse; margin: 1em 0; }
|
||||
th, td { border: 1px solid #888; padding: 0.4em 0.7em; text-align: left; vertical-align: top; }
|
||||
th { background: #eee; }
|
||||
code { background: #f3f3f3; padding: 0.1em 0.3em; border-radius: 2px; }
|
||||
.note { background: #fffbe6; border-left: 4px solid #d4b400; padding: 0.6em 1em; margin: 1em 0; }
|
||||
.warn { background: #fff0f0; border-left: 4px solid #c44; padding: 0.6em 1em; margin: 1em 0; }
|
||||
ul, ol { padding-left: 1.4em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>RemSound — user manual</h1>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Table of contents</h2>
|
||||
<ol>
|
||||
<li><a href="#what-it-does">What RemSound does</a></li>
|
||||
<li><a href="#quick-start">Quick start</a></li>
|
||||
<li><a href="#profiles">Profiles</a></li>
|
||||
<li><a href="#main-window">The main window: menu bar + three tabs</a></li>
|
||||
<li><a href="#menu-bar">Menus (File and Help)</a></li>
|
||||
<li><a href="#tab-connectivity">Connectivity tab</a></li>
|
||||
<li><a href="#tab-io">Audio inputs and outputs tab</a></li>
|
||||
<li><a href="#tab-profile">Audio profile tab</a></li>
|
||||
<li><a href="#asio-vs-wasapi">ASIO and WASAPI</a></li>
|
||||
<li><a href="#peers">Peers — finding and connecting</a></li>
|
||||
<li><a href="#network">How the network works (LAN, WAN, Tailscale)</a></li>
|
||||
<li><a href="#latency">Latency and audio quality</a></li>
|
||||
<li><a href="#shortcuts">Keyboard shortcuts</a></li>
|
||||
<li><a href="#hotkeys">Global hotkeys (mute, volume, tray)</a></li>
|
||||
<li><a href="#remote-control">Remote control: adjusting a peer's listening volume from your end</a></li>
|
||||
<li><a href="#startup">Startup behaviour</a></li>
|
||||
<li><a href="#updates">Updating RemSound</a></li>
|
||||
<li><a href="#logs">Logs and diagnostics</a></li>
|
||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||
<li><a href="#glossary">Glossary</a></li>
|
||||
</ol>
|
||||
|
||||
<h2 id="what-it-does">1. What RemSound does</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>The basic flow</h3>
|
||||
<table>
|
||||
<tr><th>Step</th><th>What happens</th></tr>
|
||||
<tr><td>1</td><td>You tick “Send my audio” on the Audio inputs and outputs tab and pick which microphone or audio output you want captured.</td></tr>
|
||||
<tr><td>2</td><td>Your friend ticks “Receive audio” on the same tab and picks which speakers or headphones to play received audio through.</td></tr>
|
||||
<tr><td>3</td><td>One of you ticks the other in the Discovered peers list on the Connectivity tab (or types their IP address manually).</td></tr>
|
||||
<tr><td>4</td><td>Audio starts flowing. The other direction works exactly the same way, independently — both can speak at once.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>There is no central server, no account, nothing in the cloud. Packets go straight from one machine to the other.</p>
|
||||
|
||||
<h2 id="quick-start">2. Quick start</h2>
|
||||
|
||||
<p>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.).</p>
|
||||
|
||||
<ol>
|
||||
<li>Launch RemSound. The first thing you’ll see is the <strong>profile picker</strong>. On a fresh install your only option is <strong>(Blank template)</strong> — 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 <a href="#profiles">Profiles</a> for the full story.</li>
|
||||
<li>Once the main window opens, use Ctrl+Tab to move to the <strong>Audio inputs and outputs</strong> tab. Tick <strong>Receive audio (Alt+R)</strong>, then tick the device you want incoming audio played through in <strong>WASAPI outputs for received sound (Alt+3)</strong>.</li>
|
||||
<li>On the same tab, tick <strong>Send my audio (Alt+S)</strong> and tick your microphone in <strong>WASAPI inputs to send (Alt+5)</strong>.</li>
|
||||
<li>Move to the <strong>Connectivity</strong> tab (Ctrl+Shift+Tab to go back left, or Ctrl+Tab from elsewhere). Find your friend in the <strong>Discovered peers (Alt+D)</strong> list and tick them. Or, if they aren’t appearing, click <strong>Add peer by IP (Alt+A)</strong> and type their address.</li>
|
||||
<li>Have your friend do the same with you on their machine.</li>
|
||||
<li>Within a second or two, both sides will hear each other.</li>
|
||||
<li>Open the <strong>File menu</strong> (Alt+F) and choose <strong>Save as</strong> 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.</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>If you have a pro audio interface (Audient, Komplete Audio, RME, Focusrite, etc.):</strong> on the <strong>Audio inputs and outputs</strong> tab pick your driver in the <strong>ASIO driver (Alt+D)</strong> list to unlock its low-latency channels. Selecting a real driver makes the ASIO device lists appear; selecting <em>(none)</em> hides them again and the app runs WASAPI-only. See <a href="#asio-vs-wasapi">ASIO and WASAPI</a> below.
|
||||
</div>
|
||||
|
||||
<h2 id="profiles">3. Profiles</h2>
|
||||
|
||||
<p>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 <em>profile</em>. 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.</p>
|
||||
|
||||
<h3>The startup picker</h3>
|
||||
|
||||
<p>When RemSound launches, the first thing you see is the profile selection dialog. It’s a listbox of profile names plus a synthetic <strong>(Blank template)</strong> entry at the top. The keys are deliberate:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td>Up / Down</td><td>Move between profiles in the list.</td></tr>
|
||||
<tr><td>Enter</td><td>Load the highlighted profile (or blank template) and open the main window.</td></tr>
|
||||
<tr><td>OK button</td><td>Same as Enter.</td></tr>
|
||||
<tr><td>Del</td><td>Delete the highlighted profile, with a yes / no confirmation.</td></tr>
|
||||
<tr><td>Browse… button</td><td>Pick 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.</td></tr>
|
||||
<tr><td>Esc</td><td>Deliberately ignored. Picking a profile is required to launch.</td></tr>
|
||||
<tr><td>Alt+F4</td><td>Closes the dialog and exits RemSound — equivalent to “I don’t want to start the program right now.”</td></tr>
|
||||
</table>
|
||||
|
||||
<p>The first profile in the list is highlighted by default, so a fresh install where you only have <strong>(Blank template)</strong> is just Enter to proceed.</p>
|
||||
|
||||
<h3>What “Blank template” means</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Saving and updating</h3>
|
||||
|
||||
<p>The File menu has two save items:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Item</th><th>What it does</th></tr>
|
||||
<tr><td><strong>File → Save (Ctrl+S)</strong></td><td>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).</td></tr>
|
||||
<tr><td><strong>File → Save as… (Alt+F, A)</strong></td><td>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.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>The window title bar shows which profile is currently active: <code>RemSound — Active profile: My session name</code>. NVDA reads this on Alt+Tab.</p>
|
||||
|
||||
<h3>Switching, renaming, and deleting</h3>
|
||||
|
||||
<table>
|
||||
<tr><th>Action</th><th>How</th></tr>
|
||||
<tr><td><strong>Switch to a different profile</strong></td><td>File → Open profile (Alt+F, O). Pick a JSON in the file picker. RemSound reloads under that profile.</td></tr>
|
||||
<tr><td><strong>Rename the active profile</strong></td><td>File → Rename current profile (Alt+F, R). Prompts for the new name and renames the JSON on disk; window title updates immediately.</td></tr>
|
||||
<tr><td><strong>Delete a profile</strong></td><td>File → 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.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Where profiles live on disk</h3>
|
||||
|
||||
<p>One JSON file per profile. By default, stored at:</p>
|
||||
|
||||
<pre><RemSound folder>\profiles\<your machine name>\<profile name>.json</pre>
|
||||
|
||||
<p>The per-machine subfolder keeps each computer’s profiles separate. If you used the <strong>Browse…</strong> 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.</p>
|
||||
|
||||
<p>You can also <strong>copy a profile JSON between machines</strong>: 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.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Tip:</strong> 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.
|
||||
</div>
|
||||
|
||||
<h3>What’s NOT in profiles</h3>
|
||||
|
||||
<p>A short list of things that intentionally aren’t profile-saved:</p>
|
||||
<ul>
|
||||
<li><strong>The folder profiles are read from.</strong> Picked via the Browse… button on the startup dialog and saved in a small machine-local <code>remsound.config.json</code> next to the executable.</li>
|
||||
<li><strong>Connection health / heartbeat RTTs</strong> — runtime telemetry, not setup.</li>
|
||||
<li><strong>Auto-tune learning state</strong> — recomputed from scratch each session.</li>
|
||||
<li><strong>Window position and size</strong> — Windows handles these via standard window-manager memory.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="main-window">4. The main window: menu bar + three tabs</h2>
|
||||
|
||||
<p>The main window has three things stacked vertically:</p>
|
||||
|
||||
<ol>
|
||||
<li>A <strong>menu bar</strong> at the top with two menus — <em>File</em> (profile management, preferences, keyboard shortcuts, minimise to tray, exit) and <em>Help</em> (open the user manual, check for updates, About). See <a href="#menu-bar">Menus</a>.</li>
|
||||
<li>A <strong>tab strip</strong> with three tabs — Connectivity, Audio inputs and outputs, Audio profile. Use <strong>Ctrl+Tab</strong> and <strong>Ctrl+Shift+Tab</strong> 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.</li>
|
||||
<li>A <strong>status footer</strong> updating once a second with connect uptime, peer count, send/receive activity, and heartbeat health.</li>
|
||||
</ol>
|
||||
|
||||
<table>
|
||||
<tr><th>Tab</th><th>What it’s for</th></tr>
|
||||
<tr><td><strong>Connectivity</strong></td><td>Connected, discovered, and remembered peers. Add a peer by IP. Connection status read-out.</td></tr>
|
||||
<tr><td><strong>Audio inputs and outputs</strong></td><td>ASIO 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; <em>(none)</em> hides them.</td></tr>
|
||||
<tr><td><strong>Audio profile</strong></td><td>Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into <em>Audio send parameters</em> and <em>Audio receive parameters</em> sections so NVDA announces which group you’ve entered as you tab through.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="menu-bar">5. Menus (File and Help)</h2>
|
||||
|
||||
<h3>File menu</h3>
|
||||
|
||||
<p>Press <strong>Alt+F</strong> to open the menu, then the underlined letter for the item you want, or use the single-press shortcut from the table below.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Item</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Open profile…</strong></td><td>Alt+F, O</td><td>Opens 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.</td></tr>
|
||||
<tr><td><strong>Save</strong></td><td>Ctrl+S</td><td>Overwrites 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.</td></tr>
|
||||
<tr><td><strong>Save as…</strong></td><td>Alt+F, A</td><td>Prompts 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.</td></tr>
|
||||
<tr><td><strong>Rename current profile…</strong></td><td>Alt+F, R</td><td>Renames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename).</td></tr>
|
||||
<tr><td><strong>Minimise to tray</strong></td><td>Alt+F, M</td><td>Hides 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).</td></tr>
|
||||
<tr><td><strong>Keyboard shortcuts…</strong></td><td>Ctrl+K</td><td>Opens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends).</td></tr>
|
||||
<tr><td><strong>Preferences…</strong></td><td>Ctrl+P</td><td>Opens 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.</td></tr>
|
||||
<tr><td><strong>Exit</strong></td><td>Alt+F, X (or Alt+F4)</td><td>Closes RemSound. If there are unsaved profile changes you’ll be prompted first.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Help menu</h3>
|
||||
|
||||
<p>Press <strong>Alt+H</strong> to open the menu, then the underlined letter for the item you want.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Item</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Help</strong></td><td>F1 (anywhere), or Alt+H, H</td><td>Opens 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.</td></tr>
|
||||
<tr><td><strong>Check for updates</strong></td><td>Alt+H, C</td><td>Asks 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 <a href="#updates">Updating RemSound</a>.)</td></tr>
|
||||
<tr><td><strong>About RemSound</strong></td><td>Alt+H, A</td><td>Small 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.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="tab-connectivity">6. Connectivity tab</h2>
|
||||
|
||||
<p>This is where peers and logging live. Tab order on this tab:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Connected peers</strong></td><td>Alt+C</td><td>Peers you currently have audio flowing with. Unticking a row disconnects that peer.</td></tr>
|
||||
<tr><td><strong>Discovered peers</strong></td><td>Alt+D</td><td>Peers 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.</td></tr>
|
||||
<tr><td><strong>Remembered peers</strong></td><td>Alt+R</td><td>Peers you’ve previously connected to or manually added. Persists across sessions. Tick to reconnect.</td></tr>
|
||||
<tr><td><strong>Add peer by IP</strong></td><td>Alt+A</td><td>Opens a small prompt for an IP address or hostname. Adds it to remembered and connects.</td></tr>
|
||||
<tr><td><strong>Connection status</strong></td><td>Alt+S</td><td>A 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.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="tab-io">7. Audio inputs and outputs tab</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>ASIO driver</strong></td><td>Alt+D</td><td>Listbox starting with <em>(none)</em>. Pick <em>(none)</em> 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.</td></tr>
|
||||
<tr><td><strong>Receive audio</strong></td><td>Alt+R</td><td>Master toggle for receiving. When off, no audio plays out regardless of which output devices are ticked.</td></tr>
|
||||
<tr><td><strong>WASAPI outputs for received sound</strong></td><td>Alt+3</td><td>Tick which Windows audio outputs (speakers, headsets) should play received audio. Multiple ticks means received audio plays out of all of them simultaneously.</td></tr>
|
||||
<tr><td><strong>ASIO outputs for received sound</strong></td><td>Alt+1</td><td>(Visible when an ASIO driver is selected.) Tick ASIO channel pairs to play received audio out of.</td></tr>
|
||||
<tr><td><strong>Set volume for all received audio</strong></td><td>Alt+V</td><td>Slider: master volume for everything coming in. There is no per-device or per-source volume.</td></tr>
|
||||
<tr><td><strong>Send my audio</strong></td><td>Alt+S</td><td>Master toggle for sending.</td></tr>
|
||||
<tr><td><strong>WASAPI outputs to send</strong></td><td>Alt+4</td><td>Tick which Windows output devices to capture from (system audio loopback — what’s currently playing on those speakers gets sent).</td></tr>
|
||||
<tr><td><strong>WASAPI inputs to send</strong></td><td>Alt+5</td><td>Tick which Windows input devices to capture (microphones, line-ins).</td></tr>
|
||||
<tr><td><strong>ASIO inputs to send</strong></td><td>Alt+2</td><td>(Visible when an ASIO driver is selected.) Tick ASIO channel pairs to capture and send.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Receiving</h3>
|
||||
|
||||
<p>Receiving has two requirements: <strong>Receive audio</strong> ticked, and at least one output device ticked. Without an output device, even if packets arrive there’s nowhere for them to go.</p>
|
||||
|
||||
<p>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:</p>
|
||||
<ul>
|
||||
<li>One output: just your monitors / headphones.</li>
|
||||
<li>Studio monitors via ASIO + a wireless headset via WASAPI for moving around the house.</li>
|
||||
<li>Two physical outputs for left/right room monitoring.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sending</h3>
|
||||
|
||||
<p>Sending requires <strong>Send my audio</strong> ticked, plus at least one capture source ticked across the three send lists.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>List</th><th>What it captures</th><th>Typical use</th></tr>
|
||||
<tr><td>WASAPI outputs to send</td><td><strong>System audio loopback</strong> — whatever Windows is currently sending out through that output. So picking your “Speakers” device captures whatever you’re hearing.</td><td>Sharing music playback, sharing a video call’s audio, anything coming out of your own speakers.</td></tr>
|
||||
<tr><td>WASAPI inputs to send</td><td><strong>Direct capture</strong> from a microphone or line input.</td><td>Your USB microphone, a headset mic, a line-in.</td></tr>
|
||||
<tr><td>ASIO inputs to send</td><td>An ASIO channel pair — typically a hardware input on a pro audio interface.</td><td>Instrument input on an Audient EVO, microphone preamp on a Focusrite, etc.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>System audio loopback can cause feedback loops.</strong> If you tick the same device for both “WASAPI outputs to send” (loopback) <em>and</em> 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.
|
||||
</div>
|
||||
|
||||
<h2 id="tab-profile">8. Audio profile tab</h2>
|
||||
|
||||
<p>Everything that shapes the audio quality / latency trade-off lives here. The tab is split into two NVDA-announced groups: <strong>Audio send parameters</strong> at the top, <strong>Audio receive parameters</strong> below. As you tab through, NVDA announces which group you’ve entered.</p>
|
||||
|
||||
<h3>Audio send parameters</h3>
|
||||
|
||||
<table>
|
||||
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Audio codec</strong></td><td>Alt+C</td><td>PCM 48k 24-bit, Opus high quality (20 ms), or Opus lower quality (10 ms). See <a href="#latency">codec choice</a>.</td></tr>
|
||||
<tr><td><strong>Packet size</strong></td><td>Alt+P</td><td><em>Standard</em> (default) or <em>Small</em> (LAN only). Smaller packets save a couple of milliseconds of send-side accumulator latency at the cost of doubling the packet rate.</td></tr>
|
||||
<tr><td><strong>Lock to audio clock</strong></td><td>Alt+K</td><td>Sender-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.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Audio receive parameters</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>ASIO latency in milliseconds</strong></td><td>Alt+L</td><td>(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.</td></tr>
|
||||
<tr><td><strong>Continuous auto-tune ASIO latency</strong></td><td>Alt+T</td><td>(Only when an ASIO driver is selected.) Checkbox. Nudges the ASIO latency target as ASIO-lane jitter changes. Independent of the WASAPI toggle.</td></tr>
|
||||
<tr><td><strong>WASAPI latency in milliseconds</strong> (called just “Audio latency” with no ASIO driver)</td><td>Alt+W (Alt+L when no ASIO driver)</td><td>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.</td></tr>
|
||||
<tr><td><strong>Continuous auto-tune WASAPI latency</strong> (called “Continuous auto-tune latency” with no ASIO driver)</td><td>Alt+Y (Alt+T when no ASIO driver)</td><td>Checkbox. When on, RemSound nudges the WASAPI lane latency value automatically as the network changes. The accompanying <strong>Auto-tune latency interval (Alt+I)</strong> 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.</td></tr>
|
||||
<tr><td><strong>Buffer smoothness</strong></td><td>Alt+B</td><td>Listbox, 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.</td></tr>
|
||||
<tr><td><strong>Artefact sound type</strong></td><td>Alt+A</td><td>Listbox. <em>Noise burst</em> (default) replaces an empty-buffer moment with a brief broadband shhh, which blends into music. <em>Click</em> uses raw zero-fill (no concealment) for diagnostic purposes — you hear an obvious click on every buffer underrun.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Most people only need to pick a codec and a smoothness level and let the rest sit on defaults.</p>
|
||||
|
||||
<h2 id="asio-vs-wasapi">9. ASIO and WASAPI</h2>
|
||||
|
||||
<p>RemSound speaks two audio backends. Which one is in play depends on the <strong>ASIO driver (Alt+D)</strong> listbox at the top of the Audio inputs and outputs tab.</p>
|
||||
|
||||
<h3>WASAPI (the default)</h3>
|
||||
<p>Windows’ standard audio engine. Every device in your <strong>Windows Sound</strong> 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.</p>
|
||||
|
||||
<h3>ASIO (needs a driver)</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<p>The ASIO driver picker is omitted entirely on a machine with no ASIO drivers installed. Common drivers that <em>do</em> appear:</p>
|
||||
<ul>
|
||||
<li><strong>Audient USB Audio ASIO Driver</strong> — for EVO 4 / 8 / 16, iD-series interfaces.</li>
|
||||
<li><strong>Komplete Audio ASIO Driver</strong> — for Native Instruments interfaces.</li>
|
||||
<li><strong>Focusrite USB ASIO</strong> — for Scarlett, Clarett, Red.</li>
|
||||
<li><strong>RME ASIO</strong> — for Babyface, Fireface, UCX.</li>
|
||||
<li><strong>Realtek ASIO</strong> — bundled with some Realtek drivers. <em>Avoid this one</em> — it’s a generic wrapper that can grab whatever Windows default device it finds, often clashing with screen reader audio.</li>
|
||||
</ul>
|
||||
|
||||
<h3>How the driver picker chooses the pipeline</h3>
|
||||
|
||||
<p>One control, two outcomes:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>ASIO driver selection</th><th>Pipeline</th><th>Latency</th></tr>
|
||||
<tr><td><em>(none)</em></td><td>WASAPI captures and plays out directly. No ASIO code in the pipeline at all.</td><td>~10–30 ms WASAPI buffer + WasapiOut. Lowest possible for users without an ASIO driver.</td></tr>
|
||||
<tr><td>Any real driver name</td><td>WASAPI 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.</td><td>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 <a href="#latency">Latency</a>).</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Default on a fresh install is <em>(none)</em>. 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 <em>(none)</em>.</p>
|
||||
|
||||
<h3>ASIO channel pairs</h3>
|
||||
<p>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:</p>
|
||||
|
||||
<pre>
|
||||
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)
|
||||
</pre>
|
||||
|
||||
<h3>Buffer size for ASIO</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<div class="warn">
|
||||
<strong>About Realtek ASIO:</strong> 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 <em>that</em> 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.
|
||||
</div>
|
||||
|
||||
<h3>Same driver, sender and receiver, on one machine</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="peers">10. Peers — finding and connecting</h2>
|
||||
|
||||
<p>“Peer” means another machine running RemSound that you want to talk to. Peers are managed on the <strong>Connectivity</strong> tab. The tab has three lists, all of them checkable:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>List</th><th>Contents</th><th>What ticking does</th></tr>
|
||||
<tr><td><strong>Connected peers</strong></td><td>Peers you currently have audio flowing with.</td><td>Unticking disconnects.</td></tr>
|
||||
<tr><td><strong>Discovered peers</strong></td><td>Peers 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).</td><td>Connects you to that peer. Audio starts flowing both ways.</td></tr>
|
||||
<tr><td><strong>Remembered peers</strong></td><td>Peers you’ve connected to before, plus any IPs you’ve manually typed in. Persists across sessions.</td><td>Connects to that remembered peer if they’re online (and adds them as a manual connection if discovery hasn’t found them yet).</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Plus the <strong>Add peer by IP (Alt+A)</strong> 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.</p>
|
||||
|
||||
<h3>Audio only plays from peers you’ve ticked</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Heartbeat indicator</h3>
|
||||
<p>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 <strong>pending</strong>, <strong>stale</strong>, or <strong>unreachable</strong> 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 <strong>Mute connect/disconnect sounds</strong> in the Preferences dialog (File → Preferences, or Ctrl+P).</p>
|
||||
|
||||
<h2 id="network">11. How the network works (LAN, WAN, Tailscale)</h2>
|
||||
|
||||
<p>RemSound uses two UDP ports:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Port</th><th>Purpose</th><th>Default</th></tr>
|
||||
<tr><td>Audio</td><td>The actual sound, sent peer-to-peer. Heartbeat packets share this port too — one socket, one firewall rule, one router pinhole.</td><td>47830</td></tr>
|
||||
<tr><td>Discovery</td><td>“I’m here” announcements every 1.5 seconds, so peers can find each other.</td><td>47831</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>LAN — same Wi-Fi or Ethernet</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>WAN — different physical locations</h3>
|
||||
<p>Direct internet peer-to-peer connections require either:</p>
|
||||
<ul>
|
||||
<li>A VPN that puts both machines on the same virtual network — <strong>Tailscale</strong> is the recommended option. Each machine gets a Tailscale IP (in the <code>100.x.y.z</code> range) and they can reach each other directly.</li>
|
||||
<li>Port forwarding on each end’s router (more involved; not covered here).</li>
|
||||
</ul>
|
||||
|
||||
<h3>Discovery on Tailscale and other VPNs</h3>
|
||||
<p>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:</p>
|
||||
<ol>
|
||||
<li>One time only: each side adds the other’s Tailscale IP to its Remembered peers list (via the “Add peer by IP” button).</li>
|
||||
<li>From then on, RemSound auto-unicasts announcements at those IPs every 1.5 seconds.</li>
|
||||
<li>The receiving side’s discovery hears the announcement, adds the sender to its own unicast list, and announces back.</li>
|
||||
<li>Within seconds, both sides see each other in Discovered peers without further typing.</li>
|
||||
</ol>
|
||||
|
||||
<p>So the rule is: <strong>only one side has to type the other’s IP once.</strong> After that, discovery is bidirectional automatically.</p>
|
||||
|
||||
<h3>Round-trip time and what it means</h3>
|
||||
<table>
|
||||
<tr><th>RTT</th><th>What you’ll experience</th></tr>
|
||||
<tr><td>0–2 ms</td><td>Localhost (same machine talking to itself).</td></tr>
|
||||
<tr><td>2–10 ms</td><td>Same LAN. Effectively instant.</td></tr>
|
||||
<tr><td>15–40 ms</td><td>Typical Tailscale or modern broadband-to-broadband. Comfortable for conversation.</td></tr>
|
||||
<tr><td>50–100 ms</td><td>Tailscale via a relay, or one end on Wi-Fi from a long way off. Still usable but you start to feel it for music.</td></tr>
|
||||
<tr><td>100 ms+</td><td>Something is wrong, or you’re across the world. Music collaboration is challenging.</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="latency">12. Latency and audio quality</h2>
|
||||
|
||||
<p>Five controls together shape the latency / audio-quality trade-off, all on the Audio profile tab:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Audio latency in milliseconds (Alt+L)</strong> — the headline target buffer size at the receiver.</li>
|
||||
<li><strong>Buffer smoothness (Alt+B)</strong> — how aggressively the receiver protects against jitter spikes.</li>
|
||||
<li><strong>Packet size (Alt+P)</strong> — Standard or Small. Smaller packets shave a couple of milliseconds off send-side delay at the cost of doubling packet rate.</li>
|
||||
<li><strong>Lock to audio clock (Alt+K)</strong> — ties packet emission to the audio device’s hardware clock end-to-end, eliminating Windows scheduler jitter.</li>
|
||||
<li><strong>Continuous auto-tune</strong> — lets the receiver choose the latency target for you, every few seconds.</li>
|
||||
</ul>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Audio latency slider</h3>
|
||||
|
||||
<p>The <strong>Audio latency</strong> 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.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Setting</th><th>Best for</th><th>Trade-off</th></tr>
|
||||
<tr><td>5–10 ms</td><td>LAN, localhost.</td><td>Crackles on any WAN with even modest jitter.</td></tr>
|
||||
<tr><td>20–40 ms</td><td>Stable Tailscale or wired internet.</td><td>Good balance — usually inaudible delay added.</td></tr>
|
||||
<tr><td>50–80 ms</td><td>WAN with some Wi-Fi or jitter.</td><td>Noticeable delay; very robust to drops.</td></tr>
|
||||
<tr><td>100 ms+</td><td>Bad networks; voice-only.</td><td>Definitely feels delayed.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Buffer smoothness</h3>
|
||||
|
||||
<p>The <strong>Buffer smoothness</strong> listbox sets a 1-to-10 scale that controls how patient the receiver is when network jitter spikes. The default is 3.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Smoothness</th><th>Behaviour</th><th>Pick when</th></tr>
|
||||
<tr><td>10 — smoothest</td><td>Receiver tolerates the largest jitter spikes without dropping audio. Longest steady-state delay.</td><td>Bad Wi-Fi, busy ISP, music collaboration where every click is unacceptable.</td></tr>
|
||||
<tr><td>4–7</td><td>Middle ground. Smooths most everyday internet jitter without much added delay.</td><td>Most WAN sessions over Tailscale or direct internet.</td></tr>
|
||||
<tr><td>3 — default</td><td>Moderate protection; brief clicks possible on jitter spikes.</td><td>Stable internet or quiet LAN.</td></tr>
|
||||
<tr><td>1 — tightest delay</td><td>Receiver gives up immediately when audio is late. Frequent clicks; lowest delay.</td><td>LAN testing, latency-critical experiments.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Smoothness and the Audio latency spinner work together — smoothness controls <em>how the receiver responds</em> when audio runs late; the latency value controls <em>how big a head-start it builds</em>. Real-world tip: if you can hear clicks, try raising smoothness by one or two before reaching for a bigger latency target.</p>
|
||||
|
||||
<h3>Packet size — Standard or Small</h3>
|
||||
|
||||
<p>Two options: <strong>Standard</strong> (default) and <strong>Small</strong>. Controls the frame size each network packet carries:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Packet size</th><th>What changes</th><th>Pick when</th></tr>
|
||||
<tr><td>Standard (5 ms PCM, 10/20 ms Opus)</td><td>One audio packet every 5 ms (PCM) or 10/20 ms (Opus, depending on codec choice).</td><td>WAN, Tailscale, internet — any time you don’t have a guaranteed-clean LAN.</td></tr>
|
||||
<tr><td>Small (2.5 ms PCM, 5/10 ms Opus, LAN only)</td><td>Halves the effective frame size. Saves up to 2.5 ms of accumulator latency at the sender.</td><td>Same-house LAN over wired Ethernet, where the network simply isn’t going to drop or jitter.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Lock to audio clock</h3>
|
||||
|
||||
<p>The <strong>Lock to audio clock</strong> 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:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Current setup</th><th>What “Lock to audio clock” does</th></tr>
|
||||
<tr><td>No ASIO driver selected (WASAPI only)</td><td>Sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens send-side delay.</td></tr>
|
||||
<tr><td>ASIO driver selected (WASAPI + ASIO running as independent lanes)</td><td>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.</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="note">
|
||||
<strong>Why you’d use it:</strong> 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.
|
||||
</div>
|
||||
|
||||
<h3>Continuous auto-tune</h3>
|
||||
|
||||
<p>The <strong>Continuous auto-tune latency</strong> 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 <strong>Auto-tune latency interval (Alt+I)</strong> combo sets how often it re-evaluates — <strong>3, 5, 10, 15, or 30 seconds</strong>. 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.</p>
|
||||
|
||||
<p>If you turn auto-tune off, the latency value stays wherever it last was.</p>
|
||||
|
||||
<h3>Artefact sound type</h3>
|
||||
|
||||
<p>When the playback buffer comes up empty for a moment (an underrun), RemSound has to fill the gap. The <strong>Artefact sound type</strong> listbox decides what that gap sounds like:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Noise burst (default)</strong> — a brief broadband shhh that blends into music. Easy on the ear; tells you something happened without being jarring.</li>
|
||||
<li><strong>Click</strong> — raw zero-fill, no concealment. Produces an obvious click on every underrun. Use this when you want to <em>hear</em> every problem (e.g. when tuning latency lower).</li>
|
||||
</ul>
|
||||
|
||||
<h3>Opus packet-loss recovery (built in, no setting)</h3>
|
||||
|
||||
<p>Both Opus modes ship with built-in <strong>forward error correction</strong>: 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.</p>
|
||||
|
||||
<p>This is automatic — there’s no switch. PCM mode does not have it.</p>
|
||||
|
||||
<h3>Codec choice</h3>
|
||||
<table>
|
||||
<tr><th>Codec</th><th>Quality</th><th>Bandwidth</th><th>Use when</th></tr>
|
||||
<tr><td>PCM 48k 24-bit</td><td>Best</td><td>~2.3 Mbps</td><td>LAN, fast WAN connections.</td></tr>
|
||||
<tr><td>Opus high quality (20 ms)</td><td>Very good</td><td>~96 kbps</td><td>Most WAN connections — the right default.</td></tr>
|
||||
<tr><td>Opus lower quality (10 ms)</td><td>Good</td><td>~64 kbps</td><td>Slower or less reliable connections; tighter latency.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="shortcuts">13. Keyboard shortcuts (within the main window)</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Connectivity tab</h3>
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td>Alt+C</td><td>Focus Connected peers list</td></tr>
|
||||
<tr><td>Alt+D</td><td>Focus Discovered peers list</td></tr>
|
||||
<tr><td>Alt+R</td><td>Focus Remembered peers list</td></tr>
|
||||
<tr><td>Alt+A</td><td>Add peer by IP</td></tr>
|
||||
<tr><td>Alt+S</td><td>Focus Connection status read-out</td></tr>
|
||||
</table>
|
||||
|
||||
<p>(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.)</p>
|
||||
|
||||
<h3>Audio inputs and outputs tab</h3>
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td>Alt+D</td><td>Focus ASIO driver listbox (hidden if no ASIO drivers are installed)</td></tr>
|
||||
<tr><td>Alt+R</td><td>Toggle Receive audio</td></tr>
|
||||
<tr><td>Alt+1</td><td>Focus ASIO outputs for received sound</td></tr>
|
||||
<tr><td>Alt+2</td><td>Focus ASIO inputs to send</td></tr>
|
||||
<tr><td>Alt+3</td><td>Focus WASAPI outputs for received sound</td></tr>
|
||||
<tr><td>Alt+4</td><td>Focus WASAPI outputs to send</td></tr>
|
||||
<tr><td>Alt+5</td><td>Focus WASAPI inputs to send</td></tr>
|
||||
<tr><td>Alt+V</td><td>Focus volume slider</td></tr>
|
||||
<tr><td>Alt+S</td><td>Toggle Send my audio</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Audio profile tab</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td>Alt+C</td><td>Focus Audio codec</td></tr>
|
||||
<tr><td>Alt+P</td><td>Focus Packet size</td></tr>
|
||||
<tr><td>Alt+K</td><td>Toggle Lock to audio clock</td></tr>
|
||||
<tr><td>Alt+L</td><td>Focus latency spinner — ASIO lane when an ASIO driver is selected, otherwise the single Audio latency spinner</td></tr>
|
||||
<tr><td>Alt+T</td><td>Toggle continuous auto-tune — ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle</td></tr>
|
||||
<tr><td>Alt+W</td><td>(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner</td></tr>
|
||||
<tr><td>Alt+Y</td><td>(Only when an ASIO driver is selected.) Toggle the WASAPI-lane continuous auto-tune</td></tr>
|
||||
<tr><td>Alt+I</td><td>Focus Auto-tune latency interval</td></tr>
|
||||
<tr><td>Alt+B</td><td>Focus Buffer smoothness</td></tr>
|
||||
<tr><td>Alt+A</td><td>Focus Artefact sound type</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>File menu shortcuts (work from any tab)</h3>
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td>Ctrl+S</td><td>Save active profile (or Save as if on Blank template)</td></tr>
|
||||
<tr><td>Ctrl+K</td><td>Open Keyboard shortcuts dialog</td></tr>
|
||||
<tr><td>Ctrl+P</td><td>Open Preferences dialog</td></tr>
|
||||
<tr><td>Alt+F, O</td><td>Open profile</td></tr>
|
||||
<tr><td>Alt+F, A</td><td>Save profile as</td></tr>
|
||||
<tr><td>Alt+F, R</td><td>Rename current profile</td></tr>
|
||||
<tr><td>Alt+F, M</td><td>Minimise to tray</td></tr>
|
||||
<tr><td>Alt+F, X</td><td>Exit</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Always-available</h3>
|
||||
<table>
|
||||
<tr><th>Key</th><th>Action</th></tr>
|
||||
<tr><td><strong>F1</strong></td><td><strong>Open this manual</strong> in your default browser. Works anywhere in RemSound — main window, every dialog, the profile picker on first launch.</td></tr>
|
||||
<tr><td>Ctrl+Tab / Ctrl+Shift+Tab</td><td>Move to next / previous tab</td></tr>
|
||||
<tr><td>Tab / Shift+Tab</td><td>Move between controls within the active tab</td></tr>
|
||||
<tr><td>Spacebar</td><td>Toggle a tick in any device list, or toggle the focused checkbox</td></tr>
|
||||
<tr><td>Up / Down</td><td>Move between items in any list</td></tr>
|
||||
<tr><td>Alt+F4</td><td>Close (standard Windows)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="hotkeys">14. Global hotkeys (work even when minimised)</h2>
|
||||
|
||||
<p>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 <strong>Enter</strong> to rebind the highlighted row, press <strong>Del</strong> to clear it (back to <em>not set</em>), press <strong>Escape</strong> (or Tab to the Close button) to close. Defaults:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Hotkey</th><th>Action</th><th>Default</th></tr>
|
||||
<tr><td>Receive mute</td><td>Mute / unmute incoming audio (this machine)</td><td>Ctrl+Shift+Alt+R</td></tr>
|
||||
<tr><td>Send mute</td><td>Mute / unmute outgoing audio (this machine)</td><td>Ctrl+Shift+Alt+S</td></tr>
|
||||
<tr><td>Tray toggle</td><td>Show / hide the main window</td><td>Ctrl+Shift+F10</td></tr>
|
||||
<tr><td>Volume up / down</td><td>Adjust this machine's received-audio volume</td><td>Unset</td></tr>
|
||||
<tr><td>Send remote volume up to peers</td><td>Tell 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 <a href="#remote-control">Remote control</a>.</td><td>Unset</td></tr>
|
||||
<tr><td>Send remote volume down to peers</td><td>Mirror, lower direction.</td><td>Unset</td></tr>
|
||||
<tr><td>Send remote receive mute toggle to peers</td><td>Tell every connected peer to toggle their RemSound receive mute.</td><td>Unset</td></tr>
|
||||
<tr><td>Send Windows global volume up to peers</td><td>Tell every connected peer to nudge their <em>Windows default-output-device</em> 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 <a href="#remote-control">Remote control</a>.</td><td>Unset</td></tr>
|
||||
<tr><td>Send Windows global volume down to peers</td><td>Mirror, lower direction.</td><td>Unset</td></tr>
|
||||
<tr><td>Send Windows global mute toggle to peers</td><td>Tell every connected peer to toggle their Windows default-output-device mute.</td><td>Unset</td></tr>
|
||||
</table>
|
||||
|
||||
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one regular key.</p>
|
||||
|
||||
<h2 id="remote-control">15. Remote control: adjusting a peer's listening volume from your end</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>RemSound's <strong>remote control</strong> 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.</p>
|
||||
|
||||
<h3>Two flavours of remote command</h3>
|
||||
|
||||
<p>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:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Set</th><th>What the receiver does</th><th>Best for</th></tr>
|
||||
<tr><td><strong>RemSound app volume</strong></td><td>Adjusts 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.</td><td>Fine adjustments while RemSound's slider is in its useful range. Doesn't affect screen-reader volume or any other app.</td></tr>
|
||||
<tr><td><strong>Windows global volume</strong></td><td>Nudges 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.</td><td>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.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Both sets target the receiver. Neither changes anything on the sending machine.</p>
|
||||
|
||||
<h3>How to set it up</h3>
|
||||
|
||||
<ol>
|
||||
<li>On the machine that should <em>respond</em> to remote commands (the one you're listening on — the laptop in the example): open <strong>Preferences</strong> (Ctrl+P) and tick <strong>Accept remote volume commands from peers</strong>. Save the profile (Ctrl+S) so the choice sticks. (One toggle covers both flavours of remote command.)</li>
|
||||
<li>On the machine that should <em>send</em> remote commands (the one your keyboard is driving — the desktop in the example): open the <strong>Keyboard shortcuts</strong> dialog (Ctrl+K). Bind whichever of the six remote-control rows you want:
|
||||
<ul>
|
||||
<li><strong>Send remote volume up / down to peers</strong> — nudges the receiver's RemSound app slider.</li>
|
||||
<li><strong>Send remote receive mute toggle to peers</strong> — toggles the receiver's RemSound mute.</li>
|
||||
<li><strong>Send Windows global volume up / down to peers</strong> — nudges the receiver's Windows master volume.</li>
|
||||
<li><strong>Send Windows global mute toggle to peers</strong> — toggles the receiver's Windows mute.</li>
|
||||
</ul>
|
||||
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.</li>
|
||||
<li>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.</li>
|
||||
</ol>
|
||||
|
||||
<div class="warn">
|
||||
<strong>Heads-up about “Windows global volume”:</strong> system volume affects <em>everything</em> 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.
|
||||
</div>
|
||||
|
||||
<h3>Mutuality</h3>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>What it does not touch</h3>
|
||||
|
||||
<ul>
|
||||
<li>It changes RemSound's <em>receive volume slider</em> on the target machine, not Windows system volume. So your peer can't accidentally turn down a video call's audio or your screen reader.</li>
|
||||
<li>It only works between peers who are already connected (i.e. each has the other ticked in their connected-peers list). The audio allow-list is the gate — an unticked peer can't drive your volume.</li>
|
||||
<li>The opt-in tick is per-profile, so a setup you've designated as “trusted home pair” can have it on while a one-off jam-session profile keeps it off.</li>
|
||||
<li>Remote commands travel on the same UDP audio port as audio and heartbeat (47830 by default), so there's no extra firewall rule to add.</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<strong>Diagnostic tip:</strong> the log file (Preferences dialog → Enable logs) records every remote-control send and receive, including <code>IGNORED</code> 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.
|
||||
</div>
|
||||
|
||||
<h2 id="startup">16. Startup behaviour</h2>
|
||||
|
||||
<p>Open the <strong>Startup behaviour</strong> 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.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Toggle</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Start minimised to tray (Alt+M)</strong></td><td>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.</td></tr>
|
||||
<tr><td><strong>Start RemSound automatically when this user logs in (Alt+A)</strong></td><td>Adds (or removes) a per-user entry in the Windows registry under <code>HKCU\Software\Microsoft\Windows\CurrentVersion\Run</code> pointing at <code>RemSound.exe</code>. 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.</td></tr>
|
||||
<tr><td><strong>Start with a specific profile (Alt+P)</strong></td><td>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.</td></tr>
|
||||
</table>
|
||||
|
||||
<p><strong>Profile to start with (Alt+L)</strong> — 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.</p>
|
||||
|
||||
<h3>Combining the three for a hands-off boot</h3>
|
||||
|
||||
<ol>
|
||||
<li>Save a profile with the device ticks, peers, and audio settings you want for “always-on” use.</li>
|
||||
<li>Open Startup behaviour. Tick all three: <em>Start minimised</em>, <em>Start automatically when this user logs in</em>, <em>Start with a specific profile</em> — and pick the profile you just saved.</li>
|
||||
<li>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.</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>Storage:</strong> the start-minimised choice and the start-with-profile name live in <code><exe>\remsound.config.json</code> (machine-local, profile-independent). The auto-start toggle lives in the Windows registry only — deleting <code>remsound.config.json</code> will not turn off the auto-start; you toggle it from this dialog or from Task Manager → Startup.
|
||||
</div>
|
||||
|
||||
<h2 id="updates">17. Updating RemSound</h2>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>Settings in Preferences</h3>
|
||||
|
||||
<p>Open <strong>File → Preferences</strong> (or Ctrl+P). The update settings sit above the logging row:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Setting</th><th>Shortcut</th><th>What it does</th></tr>
|
||||
<tr><td><strong>Check for updates</strong> (drop-down)</td><td>Alt+U</td><td>How often RemSound polls GitHub in the background. Options: <em>Never</em>, <em>Every hour</em>, <em>Every 6 hours</em>, <em>Every 24 hours</em>. Default is <em>Every 24 hours</em>. The choice is remembered across launches; if you set it to <em>Never</em>, the only way an update arrives is via the manual button below.</td></tr>
|
||||
<tr><td><strong>Check for updates now</strong> (button)</td><td>Alt+N</td><td>Runs 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).</td></tr>
|
||||
<tr><td><strong>Silently install updates when available</strong> (checkbox)</td><td>Alt+I</td><td>When 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 <em>manual</em> “Check for updates now” path always prompts regardless of this checkbox.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>What happens during an install</h3>
|
||||
|
||||
<p>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:</p>
|
||||
|
||||
<ol>
|
||||
<li>RemSound downloads the new release’s <code>RemSound-vX.Y.zip</code> from GitHub into a staging folder next to the running .exe.</li>
|
||||
<li>It writes a tiny detached <code>_apply-update.cmd</code> alongside it that watches the RemSound process.</li>
|
||||
<li>RemSound exits.</li>
|
||||
<li>The detached cmd notices the exit, copies the staged files over the install folder, deletes the staging folder, and relaunches <code>RemSound.exe</code>.</li>
|
||||
<li>The cmd deletes itself.</li>
|
||||
</ol>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h3>If install fails</h3>
|
||||
|
||||
<p>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 <code>Program Files</code> 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).</p>
|
||||
|
||||
<h3>The About dialog and release notes</h3>
|
||||
|
||||
<p>To see what version you’re currently on without checking for updates, open <strong>Help → About RemSound</strong> (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.</p>
|
||||
|
||||
<h2 id="logs">18. Logs and diagnostics</h2>
|
||||
|
||||
<p>If logging is enabled (the <strong>Enable logs</strong> checkbox in the Preferences dialog — File → Preferences, or Ctrl+P — default on), RemSound writes a tab-separated log file per session into <code>publish\logs\</code> (relative to wherever <code>RemSound.exe</code> lives). One file per launch, named <code>RemSound-<machine>-<PID>-<date>-<time>.log</code>.</p>
|
||||
|
||||
<p>The file contains two kinds of rows:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Kind</th><th>Contents</th></tr>
|
||||
<tr><td>EVT</td><td>Event lines — startup, peer selected, capture started, errors, etc.</td></tr>
|
||||
<tr><td>SNAP</td><td>One-second snapshots of running stats: codec, latency target, buffered audio milliseconds, packets sent, packets received, underruns, drops, peer heartbeat RTTs.</td></tr>
|
||||
</table>
|
||||
|
||||
<p>The <strong>Write logs now</strong> 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.</p>
|
||||
|
||||
<p>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:</p>
|
||||
<ul>
|
||||
<li><strong>BufferMs</strong> — how much audio is queued for playback. Should sit close to your latency target.</li>
|
||||
<li><strong>Underruns</strong> — count of moments the playback buffer ran dry. Each one is a tiny click. A few per minute is normal on WAN; hundreds per second means something is broken.</li>
|
||||
<li><strong>Drops</strong> — packets discarded because the buffer overflowed. Should stay near zero in normal use.</li>
|
||||
<li><strong>Heartbeat</strong> — RTT to each connected peer. <code>pending</code> / <code>unreachable</code> / <code>stale</code> indicate problems.</li>
|
||||
<li><strong>OpusFecRecoveries</strong> — running total of single-packet drops that were silently filled in by Opus packet-loss recovery. A non-zero number that’s growing means FEC is saving you from clicks. Only meaningful when an Opus codec is in use.</li>
|
||||
<li><strong>OpusUnrecoveredGaps</strong> — running total of multi-packet drops that FEC couldn’t repair. Each one corresponds to an audible click. Stays at 0 on a clean line; small numbers are normal on WAN.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="troubleshooting">19. Troubleshooting</h2>
|
||||
|
||||
<h3>I don’t hear my friend</h3>
|
||||
<ol>
|
||||
<li>Connectivity tab: is your friend in <strong>Connected peers</strong> with a green RTT (e.g. “192.168.1.5: 27 ms”), not <em>unreachable</em> or <em>pending</em>?</li>
|
||||
<li>Audio inputs and outputs tab: is <strong>Receive audio</strong> ticked?</li>
|
||||
<li>Same tab: is at least one output device ticked in the WASAPI or ASIO output list?</li>
|
||||
<li>Same tab: is the volume slider above zero?</li>
|
||||
</ol>
|
||||
|
||||
<h3>My friend doesn’t hear me</h3>
|
||||
<ol>
|
||||
<li>Audio inputs and outputs tab: is <strong>Send my audio</strong> ticked?</li>
|
||||
<li>Same tab: is at least one capture source ticked across the three send lists?</li>
|
||||
<li>If you’re using a microphone: is Windows’ microphone privacy setting allowing apps to use it? (Settings → Privacy → Microphone.)</li>
|
||||
<li>Have they ticked <em>your</em> name in their Discovered peers list?</li>
|
||||
</ol>
|
||||
|
||||
<h3>I can hear them but the audio crackles</h3>
|
||||
<ul>
|
||||
<li><strong>First, try raising Buffer smoothness</strong> by 1 or 2 on the Audio profile tab (Alt+B). It usually fixes crackles for a smaller delay cost than bumping the latency.</li>
|
||||
<li>Then, try raising the Audio latency value (Alt+L). WAN connections often need 30–80 ms.</li>
|
||||
<li>If using PCM, switch to Opus — Opus has built-in single-packet-loss recovery that PCM doesn’t. Much more tolerant of network instability.</li>
|
||||
<li>If on Wi-Fi, try wired Ethernet — Wi-Fi adds 5–20 ms of unpredictable jitter.</li>
|
||||
<li>If you previously set Packet size to “Small (LAN only)” but you’re now on the internet rather than the same-house LAN, switch it back to Standard. Small packets double your packet rate, which makes WAN clicks more likely.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Audio is fine but the delay feels long</h3>
|
||||
<ul>
|
||||
<li>Lower the relevant latency value on the Audio profile tab in steps until clicks just start, then nudge back up by one step. With an ASIO driver selected this is two independent knobs (one per lane).</li>
|
||||
<li>Or, turn on Continuous auto-tune for the lane that feels slow and let RemSound find the right level.</li>
|
||||
<li>Drop Buffer smoothness down towards 3 if you’d been running it high “just in case”.</li>
|
||||
<li>If only WASAPI matters for your session, set the ASIO driver picker to <em>(none)</em> — that disables ASIO entirely and removes its lane from the pipeline.</li>
|
||||
</ul>
|
||||
|
||||
<h3>The ASIO lane sounds grainy or constantly micro-clicks</h3>
|
||||
<p>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 <strong>ASIO latency in milliseconds (Alt+L)</strong> 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.</p>
|
||||
|
||||
<h3>I can’t see my friend in Discovered peers</h3>
|
||||
<ul>
|
||||
<li>If on the same LAN: are both machines on the same Wi-Fi or Ethernet segment? Some guest networks isolate clients from each other.</li>
|
||||
<li>If on Tailscale: type their Tailscale IP into “Add peer by IP” (Connectivity tab, Alt+A) once. After that, both sides see each other automatically.</li>
|
||||
<li>Check Windows Firewall isn’t blocking RemSound. The first launch usually prompts; if you said no, you’ll need to allow it manually.</li>
|
||||
</ul>
|
||||
|
||||
<h3>One side says “unreachable” even though audio is flowing</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h3>NVDA went silent or crackly when I selected an ASIO driver</h3>
|
||||
<p>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 <em>(none)</em> in the ASIO driver picker (or pick a different ASIO driver).</p>
|
||||
|
||||
<h3>Audio device list shows old devices that are no longer plugged in</h3>
|
||||
<p>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.</p>
|
||||
|
||||
<h2 id="glossary">20. Glossary</h2>
|
||||
|
||||
<table>
|
||||
<tr><th>Term</th><th>Meaning</th></tr>
|
||||
<tr><td>WASAPI</td><td>Windows 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.</td></tr>
|
||||
<tr><td>ASIO</td><td>Audio 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.</td></tr>
|
||||
<tr><td>Loopback capture</td><td>Capturing 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.</td></tr>
|
||||
<tr><td>Channel pair</td><td>A stereo pair of channels on an ASIO driver. Pair 1 = channels 1+2, Pair 2 = channels 3+4, etc.</td></tr>
|
||||
<tr><td>UDP</td><td>The 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.</td></tr>
|
||||
<tr><td>Heartbeat</td><td>A 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).</td></tr>
|
||||
<tr><td>Discovery</td><td>The mechanism by which RemSound peers find each other on the network without needing to know each other’s IP addresses up front.</td></tr>
|
||||
<tr><td>Tailscale</td><td>A user-friendly VPN that puts your machines on a private virtual network. The easiest way to connect RemSound across the internet without router configuration.</td></tr>
|
||||
<tr><td>Jitter</td><td>Variation in the arrival time of network packets. The reason a receive-side buffer is needed.</td></tr>
|
||||
<tr><td>Auto-tune</td><td>The 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.</td></tr>
|
||||
<tr><td>Profile</td><td>A 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.</td></tr>
|
||||
<tr><td>Blank template</td><td>A 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.</td></tr>
|
||||
<tr><td>Lock to audio clock</td><td>Sender-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.</td></tr>
|
||||
<tr><td>Concealment</td><td>Receiver-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 <em>Artefact sound type</em>. Opus has its own packet-loss recovery built into the codec on top of this.</td></tr>
|
||||
<tr><td>AppConfig</td><td>A small machine-local JSON file at <code><exe>\remsound.config.json</code> 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.</td></tr>
|
||||
<tr><td>Remote control</td><td>A 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.</td></tr>
|
||||
<tr><td>Control packet</td><td>The 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.</td></tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="System.Version"/> 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.
|
||||
/// </summary>
|
||||
internal sealed class AboutDialog : Form
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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."
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>user32.NotifyWinEvent</c> directly because the managed
|
||||
/// <see cref="Control.AccessibilityNotifyClients"/> path only fires the state event without
|
||||
/// the focus re-fire, which leaves NVDA silent.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class AudioDeviceCatalog
|
||||
{
|
||||
public static IReadOnlyList<AudioDeviceChoice> 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<AudioDeviceChoice> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
internal static class FormLayoutRows
|
||||
{
|
||||
public static void AddRow(TableLayoutPanel panel, int row, string labelText, Control control, Action<Control> 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<CheckedListBox> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class CmdKeyForm : Form
|
||||
{
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public Func<Keys, bool>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 { } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the bundled <c>readme.html</c> manual in the user's default browser. Wired to F1
|
||||
/// app-wide via <see cref="HelpKeyMessageFilter"/>, 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 <c>ShowDialog</c>/<c>Application.Run</c> call.
|
||||
///
|
||||
/// File location: <c><exe>\readme.html</c> (resolved via <see cref="AppContext.BaseDirectory"/>).
|
||||
/// The .csproj copies it from the project root via a Content/Link rule so a fresh
|
||||
/// <c>dotnet publish</c> always lands a current copy next to the executable.
|
||||
/// </summary>
|
||||
internal static class HelpLauncher
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Install the F1-catches-help message filter on the current thread's message
|
||||
/// loop. Call once from <c>Program.Main</c> before any form is shown. Idempotent — calling
|
||||
/// it twice would register two filters which is wasteful but not harmful.</summary>
|
||||
public static void Install()
|
||||
{
|
||||
Application.AddMessageFilter(new HelpKeyMessageFilter());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
using System.Windows.Forms;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// CheckedListBox subclass that exposes the protected <c>RefreshItem</c> 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.
|
||||
/// </summary>
|
||||
internal sealed class LiveCheckedListBox : CheckedListBox
|
||||
{
|
||||
public void RefreshItemPublic(int index) => RefreshItem(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal sealed class PeerLineStatus
|
||||
{
|
||||
public bool Connected;
|
||||
/// <summary>True when our sender is actively pushing audio at this peer.</summary>
|
||||
public bool Sending;
|
||||
/// <summary>True when audio is arriving from this peer's IP (a fresh receiver session exists).</summary>
|
||||
public bool Receiving;
|
||||
/// <summary>Codec label like "Opus 10ms", "Opus 20ms", "PCM". Null when not connected.</summary>
|
||||
public string? CodecLabel;
|
||||
/// <summary>Round-trip ping ms from heartbeat. Null when not connected or pending.</summary>
|
||||
public int? RttMs;
|
||||
}
|
||||
|
||||
internal sealed class PeerListItem
|
||||
{
|
||||
public PeerAnnouncement Peer { get; }
|
||||
public PeerLineStatus Status { get; } = new();
|
||||
|
||||
public PeerListItem(PeerAnnouncement peer) { Peer = peer; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> { "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)}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> 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.</summary>
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<HotkeyInfo> setField, Action register, Action<HotkeyInfo> 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;
|
||||
|
||||
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
|
||||
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
|
||||
/// the eleven per-row Change methods used to duplicate. The <paramref name="description"/>
|
||||
/// 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".</summary>
|
||||
private void ChangeHotkey(IWin32Window dialogOwner, string description, Action<HotkeyInfo> 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="StartupBehaviourDialog"/> sub-dialog.
|
||||
///
|
||||
/// Both checkboxes save through <see cref="RemSoundSettingsStore"/> 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public bool ChangedAnyProfileSetting { get; private set; }
|
||||
|
||||
public PreferencesDialog(
|
||||
RemSoundSettingsStore settings,
|
||||
ProfileStore? profileStore,
|
||||
Func<bool> getLoggingEnabled,
|
||||
Action<bool> 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>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 <paramref name="store"/> non-null so the dialog refuses an existing-name unless
|
||||
/// the user confirms overwrite.
|
||||
///
|
||||
/// When <paramref name="store"/> is null, no overwrite check is performed — the caller is
|
||||
/// responsible for handling name collisions (rename has its own conflict logic in MainForm).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>remsound.config.json</c> next to the exe.
|
||||
///
|
||||
/// On OK, exposes:
|
||||
/// * <see cref="SelectedTitle"/> — the chosen title, or null for blank template.
|
||||
/// * <see cref="SelectedProfile"/> — the loaded <see cref="Profile"/>, or null for blank.
|
||||
/// * <see cref="Store"/> — 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <summary>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.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Profile>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.App</RootNamespace>
|
||||
<AssemblyName>RemSound</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
|
||||
<!-- Release version. The self-updater (RemSoundUpdater) compares this against the
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Peer-state notification sounds. Copied next to RemSound.exe so SoundPlayer can find
|
||||
them via AppContext.BaseDirectory. -->
|
||||
<Content Include="..\..\connect.wav">
|
||||
<Link>connect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\disconnect.wav">
|
||||
<Link>disconnect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
||||
the published copy whenever the source is newer; manually-edited copies inside
|
||||
publish/ get clobbered, which is correct (publish is build output, not config). -->
|
||||
<Content Include="..\..\readme.html">
|
||||
<Link>readme.html</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,184 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight tab-separated log file written next to the executable in <c>logs\</c>.
|
||||
/// 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 <see cref="Enabled"/>
|
||||
/// 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
|
||||
/// <c>logs\</c>. Flipping <see cref="Enabled"/> back on mid-session is also safe: the next
|
||||
/// write creates the file with its header and the session continues normally.
|
||||
/// </summary>
|
||||
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;
|
||||
/// <summary>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.</summary>
|
||||
private readonly object writeGate = new();
|
||||
|
||||
/// <summary>Path of the log file once it has been created. Null until the first write
|
||||
/// arrives with <see cref="Enabled"/> true (or null forever if logging is never enabled
|
||||
/// or file creation fails).</summary>
|
||||
public string? Path { get; private set; }
|
||||
|
||||
/// <summary>Master gate for all writes. When false, both <see cref="Event"/> and
|
||||
/// <see cref="Snapshot"/> 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.</summary>
|
||||
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.
|
||||
}
|
||||
|
||||
/// <summary>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
|
||||
/// <see cref="writeGate"/>. 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.</summary>
|
||||
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', ' ');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>cmd.exe</c> helper:
|
||||
/// <list type="number">
|
||||
/// <item>Download the release ZIP to <c>%TEMP%\RemSound-update-<tag>.zip</c>.</item>
|
||||
/// <item>Extract to <c><exe>\_update\</c>.</item>
|
||||
/// <item>Write a one-shot batch file at <c><exe>\_apply-update.cmd</c> 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.</item>
|
||||
/// <item>Start the batch with <c>CreateNoWindow</c> + detached, then call
|
||||
/// <see cref="Application.Exit"/>.</item>
|
||||
/// </list>
|
||||
/// The batch survives RemSound's exit because <c>cmd.exe</c> 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 <see cref="RepoOwner"/> / <see cref="RepoName"/>.
|
||||
/// </summary>
|
||||
internal sealed class RemSoundUpdater : IDisposable
|
||||
{
|
||||
public const string RepoOwner = "Ednunp";
|
||||
public const string RepoName = "RemSound";
|
||||
|
||||
/// <summary>Asset name on the GitHub release that the updater downloads. The release
|
||||
/// publisher's <c>gh release create</c> 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 <c>tag_name</c> at runtime.</summary>
|
||||
public const string AssetNameTemplate = "RemSound-{tag}.zip";
|
||||
|
||||
private static readonly HttpClient http = CreateClient();
|
||||
|
||||
/// <summary>Sink for diagnostic lines — the App wires this to <c>logFile.Event</c> 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.</summary>
|
||||
public Action<string>? 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.
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<UpdateInfo?> 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<GitHubRelease>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <c>/R:5 /W:1</c> 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.</summary>
|
||||
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"
|
||||
""";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Parses a release tag like <c>v1.2</c> or <c>1.2.3</c> into a <see cref="Version"/>.
|
||||
/// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result
|
||||
/// always compares meaningfully against <see cref="Assembly.GetName"/>.Version.</summary>
|
||||
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<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="RemSoundUpdater.CheckForUpdateAsync"/> returns when there's a
|
||||
/// newer release available. <see cref="ReleaseNotes"/> is the raw Markdown body of the
|
||||
/// release on GitHub — show it directly in a confirmation dialog if the install isn't
|
||||
/// silent.</summary>
|
||||
internal sealed record UpdateInfo(
|
||||
string Tag,
|
||||
Version Version,
|
||||
string DownloadUrl,
|
||||
string ReleaseNotes,
|
||||
string ReleaseUrl);
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Wires "run RemSound automatically when this user logs in" via the per-user Run
|
||||
/// registry key at <c>HKCU\Software\Microsoft\Windows\CurrentVersion\Run</c>. 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.
|
||||
/// </summary>
|
||||
internal static class StartupAutoStart
|
||||
{
|
||||
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string ValueName = "RemSound";
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Add or update the Run-key entry to point at the currently-running exe.
|
||||
/// Quotes the path so spaces work. Returns true on success.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AppConfig"/> (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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around NAudio's <see cref="MMDeviceEnumerator"/> + <see cref="AudioEndpointVolume"/>
|
||||
/// to drive the Windows master volume on the system's <em>default</em> 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 <see cref="RemSound.Core.GlobalHotkey.Register"/>), 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.
|
||||
/// </summary>
|
||||
internal static class SystemVolumeHelper
|
||||
{
|
||||
private static readonly object cacheLock = new();
|
||||
private static MMDeviceEnumerator? cachedEnumerator;
|
||||
private static MMDevice? cachedDevice;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public static bool TryStepUp() => TryDo(v => v.VolumeStepUp());
|
||||
|
||||
/// <summary>Mirror of <see cref="TryStepUp"/> in the down direction.</summary>
|
||||
public static bool TryStepDown() => TryDo(v => v.VolumeStepDown());
|
||||
|
||||
/// <summary>Toggles the default render device's master mute. Reads the current state,
|
||||
/// flips it, writes it back. Returns true on success.</summary>
|
||||
public static bool TryToggleMute() => TryDo(v => v.Mute = !v.Mute);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<AudioEndpointVolume> 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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="RemSound.App"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>How often the self-updater polls GitHub Releases for a newer build. Values are
|
||||
/// stable: don't reorder; deserialisation reads the underlying int from <c>remsound.config.json</c>.</summary>
|
||||
public enum UpdateCheckFrequency
|
||||
{
|
||||
Never = 0,
|
||||
EveryHour = 1,
|
||||
Every6Hours = 2,
|
||||
Every24Hours = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App-level configuration that lives next to the exe as <c>remsound.config.json</c>.
|
||||
/// Distinct from <see cref="Profile"/>: 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:
|
||||
/// * <see cref="ProfilesDirectory"/> — where the profile JSONs are read from.
|
||||
///
|
||||
/// (Pre-2026-05-11 also held <c>BothModeWarningSuppressed</c> — 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: <c><exe>\remsound.config.json</c>. 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.
|
||||
/// </summary>
|
||||
public sealed class AppConfig
|
||||
{
|
||||
/// <summary>Filesystem path to the directory the app should read profiles from. When
|
||||
/// null, RemSound uses the legacy default: <c><exe>\profiles\<machine>\</c>.
|
||||
/// 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).</summary>
|
||||
public string? ProfilesDirectory { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool SaveProfileConfirmationSuppressed { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool StartMinimised { get; set; }
|
||||
|
||||
/// <summary>If true, RemSound writes a tab-separated diagnostic log to
|
||||
/// <c><exe>\logs\</c>. Lives here (not in <see cref="Profile"/>) 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 <em>Enable logs</em> in the Preferences dialog.</summary>
|
||||
public bool LoggingEnabled { get; set; }
|
||||
|
||||
/// <summary>If non-null and a profile with this title exists, RemSound skips the
|
||||
/// startup profile picker and loads this profile directly. Combine with
|
||||
/// <see cref="StartMinimised"/> + the Windows auto-start registry entry
|
||||
/// (see <c>StartupAutoStart</c>) 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).</summary>
|
||||
public string? StartWithProfileTitle { get; set; }
|
||||
|
||||
/// <summary>How often RemSound polls the GitHub Releases API for a newer build. Default
|
||||
/// <see cref="UpdateCheckFrequency.Every24Hours"/>. Set to <see cref="UpdateCheckFrequency.Never"/>
|
||||
/// to disable background checks entirely (the user can still trigger a manual check via
|
||||
/// the Preferences button or the Help menu).</summary>
|
||||
public UpdateCheckFrequency UpdateCheckFrequency { get; set; } = UpdateCheckFrequency.Every24Hours;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool SilentlyInstallUpdates { get; set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public DateTime? LastUpdateCheckUtc { get; set; }
|
||||
|
||||
private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static AppConfig Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ConfigPath)) return new AppConfig();
|
||||
var json = File.ReadAllText(ConfigPath);
|
||||
return JsonSerializer.Deserialize<AppConfig>(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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes this config to disk. Throws on filesystem failures (caller should
|
||||
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
|
||||
public void Save()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(ConfigPath, json);
|
||||
}
|
||||
|
||||
/// <summary>Convenience: build the appropriate <see cref="ProfileStore"/> 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.</summary>
|
||||
public ProfileStore CreateStore()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ProfilesDirectory) && Directory.Exists(ProfilesDirectory))
|
||||
{
|
||||
return new ProfileStore(ProfilesDirectory);
|
||||
}
|
||||
return new ProfileStore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Audio format announcement carried in every Format packet. <see cref="Lane"/> was added
|
||||
/// 2026-05-11 alongside the BothIndependent audio mode — see <see cref="RenderRoute"/> 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 <see cref="RenderRoute.Mixed"/>.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Selects which audio backends RemSound runs. Two values are produced by the UI today:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>WasapiOnly</b> — 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.</item>
|
||||
/// <item><b>BothIndependent</b> — 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
|
||||
/// <see cref="RenderRoute.WasapiLane"/>) and an ASIO lane carrying ASIO audio
|
||||
/// (<see cref="RenderRoute.AsioLane"/>). 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.</item>
|
||||
/// </list>
|
||||
/// <b>AsioOnly</b> and <b>Both</b> are legacy values kept for back-compat with code paths
|
||||
/// that take an <see cref="AudioMode"/> as input. No UI path produces them any more, and the
|
||||
/// composite backends coerce them into <b>WasapiOnly</b> or <b>BothIndependent</b> on receipt.
|
||||
/// Old profile JSONs that still contain <c>"AudioModeRaw"</c> simply have the key ignored
|
||||
/// (the field was removed from <see cref="Profile"/> in the 2026-05-11 cleanup).
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Write(ReadOnlySpan{byte})"/>; consumer thread calls
|
||||
/// <see cref="Read(Span{byte})"/> or <see cref="ReadFloats(Span{float})"/>.
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Producer side. Writes the entire span; if the buffer is full, drops the oldest bytes to make room.</summary>
|
||||
public void Write(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumer-side: discard the oldest <paramref name="bytesToDrop"/> 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 <c>head</c>, which the SPSC
|
||||
/// invariant treats as consumer-owned).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Producer-side trim. If the buffer currently holds more than <paramref name="targetBytes"/>,
|
||||
/// advances head to discard the oldest excess. Returns the number of bytes dropped. Same
|
||||
/// semantics as the overflow-drop path inside <see cref="Write"/>: 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 <see cref="SessionPlayout.NoteFramesQueued"/> 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Float-typed convenience over <see cref="Read(Span{byte})"/>. 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.
|
||||
/// </summary>
|
||||
public int ReadFloats(Span<float> destination)
|
||||
{
|
||||
var bytes = System.Runtime.InteropServices.MemoryMarshal.AsBytes(destination);
|
||||
var bytesRead = Read(bytes);
|
||||
return bytesRead / sizeof(float);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public int Read(Span<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
public enum AudioTransportCodec
|
||||
{
|
||||
Pcm = 1,
|
||||
Opus = 2
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public enum CaptureKind
|
||||
{
|
||||
Loopback,
|
||||
Input,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies one source the sender should mix into the outgoing stream. <see cref="Name"/> is
|
||||
/// purely for diagnostic logging; <see cref="DeviceId"/> is either a WASAPI MMDevice ID or a
|
||||
/// synthetic ASIO id of the form <c>"asio:<channel-pair-index>"</c>.
|
||||
/// </summary>
|
||||
public sealed record CaptureSourceSpec(string DeviceId, CaptureKind Kind, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum ConcealmentArtifact
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
CosineToneShort = 0,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
CosineToneLow = 1,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
NoiseBurst = 2,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Click = 3,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Single shared on/off switch for the engine's diagnostic instrumentation. The App sets
|
||||
/// <see cref="Enabled"/> at startup from <c>AppConfig.LoggingEnabled</c> and re-sets it
|
||||
/// whenever the user toggles the <em>Enable logs</em> checkbox in Preferences. Every probe
|
||||
/// site in <c>RemSound.Sender</c> and <c>RemSound.Receiver</c> 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:
|
||||
/// <list type="bullet">
|
||||
/// <item>Sender-side max-time probes — <c>SenderLane.OnMixedSamples</c> emit timing,
|
||||
/// <c>AudioSender.SendToAll</c> kernel-send timing, capture-callback gap timers in
|
||||
/// <c>AsioCaptureBackend</c> and <c>MixingEngine</c>.</item>
|
||||
/// <item>Receiver-side max-time probes — <c>NetworkListener</c> dispatch timing,
|
||||
/// <c>ReceiverDiagnostics</c> arrival-gap and render-callback-gap recording.</item>
|
||||
/// <item>The per-sample envelope-spike detector
|
||||
/// (<c>ReceiverDiagnostics.RecordOutputSampleSteps</c>), which iterates every output
|
||||
/// sample doing second-derivative arithmetic and is the most expensive probe.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// What's <em>not</em> behind this gate: the running counters that feed the always-visible
|
||||
/// status footer (packets sent, packets received, bytes, underruns, drops). Those are cheap
|
||||
/// <c>Interlocked.Add</c> calls and the UI needs them whether logs are on or off.
|
||||
///
|
||||
/// Flag is plain <c>volatile</c>: 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.
|
||||
/// </summary>
|
||||
public static class DiagnosticsGate
|
||||
{
|
||||
private static volatile bool enabled;
|
||||
|
||||
/// <summary>True when the engine should run its diagnostic instrumentation. Set by the
|
||||
/// App at startup and on every toggle of the Enable-logs checkbox.</summary>
|
||||
public static bool Enabled
|
||||
{
|
||||
get => enabled;
|
||||
set => enabled = value;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Register the global hotkey. <paramref name="allowRepeat"/> controls whether
|
||||
/// holding the key down fires <see cref="Pressed"/> repeatedly at the OS keyboard
|
||||
/// auto-repeat rate. Default <c>false</c> = 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
|
||||
/// <c>true</c> for step hotkeys where holding the key is meant to ramp a value
|
||||
/// (volume up/down, both local and remote/system variants).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The Win32 GetLastError value captured immediately after the most recent
|
||||
/// failed <see cref="Register"/> 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.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="HandleInjectedPacket"/>.
|
||||
///
|
||||
/// 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 <see cref="AudioReceiver.Start"/> from
|
||||
/// <see cref="AudioReceiver.SetPlaybackEnabled"/> 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 <c>now - pongPayload.originatorTickMs</c>
|
||||
/// using only its own clock. No peer-clock sync needed.
|
||||
/// </summary>
|
||||
public sealed class HeartbeatService : IDisposable
|
||||
{
|
||||
/// <summary>How often a Ping is sent to each tracked peer.</summary>
|
||||
public static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(1);
|
||||
/// <summary>If the most recent Pong is younger than this, the peer is healthy.</summary>
|
||||
public static readonly TimeSpan HealthyWindow = TimeSpan.FromSeconds(2);
|
||||
/// <summary>If the most recent Pong is older than this, the peer is unreachable.</summary>
|
||||
public static readonly TimeSpan UnreachableWindow = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, PeerState> peers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Stopwatch monotonic = Stopwatch.StartNew();
|
||||
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? sendTask;
|
||||
private uint sequence;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound transport for heartbeat packets. REQUIRED — without it Start() succeeds but
|
||||
/// no pings are emitted. Wire it to <see cref="RemSound.Sender.AudioSender.SendVia"/>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
public Func<byte[], int, IPEndPoint, bool>? SendTransport { get; set; }
|
||||
|
||||
public HeartbeatService(Action<string>? 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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetTrackedPeers(IEnumerable<IPEndPoint> audioEndpoints)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var desired = new Dictionary<string, IPEndPoint>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the current health state of every tracked peer. Safe to call from any thread.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PeerHealth> GetAllPeerHealth()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var result = new List<PeerHealth>(peers.Count);
|
||||
foreach (var p in peers.Values)
|
||||
{
|
||||
result.Add(SnapshotHealthLocked(p, nowUtc));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
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<PeerState> 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<byte> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="SendTransport"/>, update RTT state on Pong arrival.
|
||||
/// </summary>
|
||||
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<byte> 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);
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>True if the user pressed a modifier key (Ctrl / Shift / Alt) at any point
|
||||
/// during this capture session. Used in conjunction with <see cref="SawAnyNonModifier"/>
|
||||
/// to detect "user tried to bind a combo but the non-modifier key was swallowed by a
|
||||
/// low-level keyboard hook" — see <see cref="SawAnyNonModifier"/>.</summary>
|
||||
public bool SawAnyModifier { get; private set; }
|
||||
|
||||
/// <summary>True if the user pressed any non-Escape, non-modifier key during this
|
||||
/// capture session. If <see cref="SawAnyModifier"/> 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.</summary>
|
||||
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<string>(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) + "+...";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string>(4);
|
||||
if (Control) parts.Add("Control");
|
||||
if (Shift) parts.Add("Shift");
|
||||
if (Alt) parts.Add("Alt");
|
||||
parts.Add(Key.ToString());
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class PcmPack
|
||||
{
|
||||
/// <summary>
|
||||
/// Pack a span of float samples (range −1..+1) into signed 24-bit little-endian PCM.
|
||||
/// Destination must be at least <c>source.Length * 3</c> bytes.
|
||||
/// </summary>
|
||||
public static void FloatToInt24LE(ReadOnlySpan<float> source, Span<byte> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpack signed 24-bit little-endian PCM into floats in [−1, +1].
|
||||
/// </summary>
|
||||
public static void Int24LEToFloat(ReadOnlySpan<byte> source, Span<float> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// UDP peer discovery. Each running instance announces itself on
|
||||
/// <see cref="DefaultDiscoveryPort"/> 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
|
||||
/// <see cref="SetUnicastPeerAddresses"/>.
|
||||
/// </summary>
|
||||
public sealed class PeerDiscoveryService : IDisposable
|
||||
{
|
||||
public const int DefaultDiscoveryPort = 47821;
|
||||
|
||||
private readonly Guid instanceId = Guid.NewGuid();
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<Guid, PeerAnnouncement> 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<IPAddress> unicastTargets = [];
|
||||
|
||||
public event Action? PeersChanged;
|
||||
|
||||
public IReadOnlyList<PeerAnnouncement> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetUnicastPeerAddresses(IEnumerable<IPAddress> 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<DiscoveryMessage>(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<IPAddress> GetBroadcastAddresses()
|
||||
{
|
||||
var addresses = new HashSet<IPAddress> { 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);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c><exe>\profiles\<machine name>\<title>.json</c> 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.
|
||||
/// </summary>
|
||||
public sealed class Profile
|
||||
{
|
||||
/// <summary>Display title and filename stem (sanitised). Required.</summary>
|
||||
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 ===
|
||||
/// <summary>The ASIO driver this profile uses. <c>null</c> 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 <c>"AudioModeRaw"</c> or <c>"BothModeWarningSuppressed"</c> simply
|
||||
/// have those keys ignored on deserialisation.</summary>
|
||||
public string? AsioDriverName { get; set; }
|
||||
|
||||
// === Selected devices (raw device IDs, not display names) ===
|
||||
public List<string> SelectedWasapiReceiveOutputs { get; set; } = [];
|
||||
public List<string> SelectedAsioReceiveOutputs { get; set; } = [];
|
||||
public List<string> SelectedWasapiSendOutputs { get; set; } = []; // loopback (system audio)
|
||||
public List<string> SelectedWasapiSendInputs { get; set; } = []; // microphones / line-ins
|
||||
public List<string> 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; }
|
||||
/// <summary>True suppresses the connect/disconnect sound cues. Off by default.
|
||||
/// 2026-05-06.</summary>
|
||||
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;
|
||||
/// <summary>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.</summary>
|
||||
public int MaxLatencyMsAsio { get; set; } = 10;
|
||||
/// <summary>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.</summary>
|
||||
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.
|
||||
/// <summary>Receiver-side concealment artifact, stored as raw int for JSON-stability
|
||||
/// across enum-reorderings. Defaults to <see cref="ConcealmentArtifact.NoiseBurst"/>
|
||||
/// (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).</summary>
|
||||
public int ConcealmentArtifactRaw { get; set; } = (int)ConcealmentArtifact.NoiseBurst;
|
||||
|
||||
[JsonIgnore]
|
||||
public ConcealmentArtifact ConcealmentArtifact
|
||||
{
|
||||
get => (ConcealmentArtifact)ConcealmentArtifactRaw;
|
||||
set => ConcealmentArtifactRaw = (int)value;
|
||||
}
|
||||
|
||||
// === Peers ===
|
||||
public List<string> RememberedPeers { get; set; } = [];
|
||||
/// <summary>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.</summary>
|
||||
public List<string> 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; }
|
||||
/// <summary>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.</summary>
|
||||
public HotkeyRecord? RemoteVolumeUpHotkey { get; set; }
|
||||
/// <summary>Mirror of RemoteVolumeUpHotkey for "lower volume" commands.</summary>
|
||||
public HotkeyRecord? RemoteVolumeDownHotkey { get; set; }
|
||||
/// <summary>Hotkey that sends a "toggle receive mute" command to every connected peer.</summary>
|
||||
public HotkeyRecord? RemoteMuteToggleHotkey { get; set; }
|
||||
/// <summary>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.</summary>
|
||||
public HotkeyRecord? SystemVolumeUpHotkey { get; set; }
|
||||
/// <summary>Mirror of SystemVolumeUpHotkey for the down direction.</summary>
|
||||
public HotkeyRecord? SystemVolumeDownHotkey { get; set; }
|
||||
/// <summary>Hotkey that sends a "toggle Windows default-output-device mute" command to
|
||||
/// every connected peer.</summary>
|
||||
public HotkeyRecord? SystemMuteToggleHotkey { get; set; }
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns a defaults-only profile — same shape as the "blank template"
|
||||
/// the user picks at startup. Title is empty (caller assigns when saving).</summary>
|
||||
public static Profile NewBlank() => new();
|
||||
}
|
||||
|
||||
/// <summary>JSON-serialisable hotkey representation. Mirrors <see cref="HotkeyInfo"/>
|
||||
/// but stores Key as a string to keep the JSON robust to enum reorganisations.</summary>
|
||||
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<Keys>(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// File-backed store for <see cref="Profile"/> instances. One profile = one JSON file
|
||||
/// under <c><exe>\profiles\<machine name>\<title>.json</c>.
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>Folder this store reads from and writes into.</summary>
|
||||
public string BaseDirectory => baseDir;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public IReadOnlyList<string> 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<Profile>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the profile by title. Returns true if a file was removed,
|
||||
/// false if it didn't exist or couldn't be deleted.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if a profile with the given title (sanitised filename) already exists.</summary>
|
||||
public bool Exists(string title) => !string.IsNullOrWhiteSpace(title) && File.Exists(PathFor(title));
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <see cref="BaseDirectory"/>.</summary>
|
||||
public string PathFor(string title) => Path.Combine(baseDir, SanitiseFsName(title) + ".json");
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
public enum RemPacketType : byte
|
||||
{
|
||||
Format = 1,
|
||||
Audio = 2,
|
||||
KeepAlive = 3,
|
||||
Heartbeat = 4,
|
||||
/// <summary>
|
||||
/// 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 <see cref="RemoteControlKind"/>
|
||||
/// + 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.
|
||||
/// </summary>
|
||||
Control = 5,
|
||||
}
|
||||
|
||||
public enum HeartbeatKind : byte
|
||||
{
|
||||
Ping = 0,
|
||||
Pong = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a Control packet is asking the receiver to do.
|
||||
///
|
||||
/// Two families of commands:
|
||||
/// * <see cref="VolumeUp"/> / <see cref="VolumeDown"/> / <see cref="MuteToggle"/> — 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).
|
||||
/// * <see cref="SystemVolumeUp"/> / <see cref="SystemVolumeDown"/> / <see cref="SystemMuteToggle"/>
|
||||
/// — 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public static class RemPacket
|
||||
{
|
||||
public const int HeaderSize = 12;
|
||||
/// <summary>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 <see cref="FormatPayloadExtendedSize"/>.</summary>
|
||||
public const int FormatPayloadSize = 32;
|
||||
/// <summary>Extended format payload size (32 base + 4 extension). The extension carries
|
||||
/// the <see cref="AudioFormatInfo.Lane"/> byte at offset 32 plus 3 reserved-zero bytes for
|
||||
/// future growth. Receivers must check <c>payload.Length >= FormatPayloadExtendedSize</c>
|
||||
/// before reading the Lane field; payloads shorter than that default Lane to
|
||||
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
|
||||
public const int FormatPayloadExtendedSize = 36;
|
||||
public const int KeepAlivePayloadSize = 28;
|
||||
/// <summary>
|
||||
/// Heartbeat payload: 1 byte <see cref="HeartbeatKind"/> + 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).
|
||||
/// </summary>
|
||||
public const int HeartbeatPayloadSize = 9;
|
||||
/// <summary>
|
||||
/// Control payload: 1 byte <see cref="RemoteControlKind"/> + 1 signed byte delta. Total
|
||||
/// 2 bytes, plus the 12-byte header = 14 bytes on the wire. See <see cref="RemPacketType.Control"/>
|
||||
/// for the rationale.
|
||||
/// </summary>
|
||||
public const int ControlPayloadSize = 2;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const int DefaultPort = 47830;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const int DefaultPeerDialPort = DefaultPort;
|
||||
public const int Magic = 0x444E4D52; // 'RMND' little-endian
|
||||
public const byte Version = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public const int MaxAudioPayloadBytes = 1454;
|
||||
|
||||
public static int WriteHeader(Span<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the format payload. Always emits <see cref="FormatPayloadExtendedSize"/> 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 <see cref="FormatPayloadSize"/> doc comment for the
|
||||
/// compatibility contract.
|
||||
/// </summary>
|
||||
public static int WriteFormatPayload(Span<byte> 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<byte> destination, KeepAliveInfo info)
|
||||
{
|
||||
if (destination.Length < KeepAlivePayloadSize)
|
||||
{
|
||||
throw new ArgumentException("KeepAlive payload destination too small", nameof(destination));
|
||||
}
|
||||
|
||||
destination[0] = (byte)info.Kind;
|
||||
destination[1] = (byte)info.Codec;
|
||||
destination[2] = (byte)info.Capabilities;
|
||||
destination[3] = 0;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(destination[4..], info.UnixTimeMilliseconds);
|
||||
if (!info.SessionId.TryWriteBytes(destination.Slice(12, 16)))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return KeepAlivePayloadSize;
|
||||
}
|
||||
|
||||
public static bool TryReadHeader(ReadOnlySpan<byte> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the format payload. Accepts both the legacy 32-byte and the extended 36-byte
|
||||
/// layouts: the legacy layout defaults <see cref="AudioFormatInfo.Lane"/> to
|
||||
/// <see cref="RenderRoute.Mixed"/>, 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.
|
||||
/// </summary>
|
||||
public static bool TryReadFormat(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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<byte> payload, out KeepAliveInfo info)
|
||||
{
|
||||
info = default;
|
||||
if (payload.Length < KeepAlivePayloadSize) return false;
|
||||
if (!Enum.IsDefined((KeepAliveKind)payload[0])) return false;
|
||||
info = new KeepAliveInfo(
|
||||
new Guid(payload.Slice(12, 16)),
|
||||
(KeepAliveKind)payload[0],
|
||||
(KeepAliveCapabilities)payload[2],
|
||||
Enum.IsDefined((AudioTransportCodec)payload[1]) ? (AudioTransportCodec)payload[1] : AudioTransportCodec.Pcm,
|
||||
BinaryPrimitives.ReadInt64LittleEndian(payload[4..]));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public static class RemPcmFrame
|
||||
{
|
||||
public const int SubHeaderSize = 6;
|
||||
|
||||
public static int WriteSubHeader(Span<byte> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Core</RootNamespace>
|
||||
<AssemblyName>RemSound.Core</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="Profile"/> /
|
||||
/// <see cref="ProfileStore"/>), 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 <c>configs/</c> folders from prior builds are ignored. Constructor still
|
||||
/// takes an <c>appName</c> for backwards compatibility but it's unused.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-route latency settings used only in BothIndependent audio mode. The existing
|
||||
/// <see cref="LoadMaxLatencyMs"/> / <see cref="SaveMaxLatencyMs"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string> LoadRememberedPeers() =>
|
||||
Try(() => Load()?.RememberedPeers?
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
|
||||
?? [];
|
||||
|
||||
public void SaveRememberedPeers(IEnumerable<string> 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.
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Receiver-side concealment artifact pick. See <see cref="ConcealmentArtifact"/>
|
||||
/// for what each value sounds like. Default is <see cref="ConcealmentArtifact.NoiseBurst"/>
|
||||
/// — 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.</summary>
|
||||
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<T>(Func<T?> action) where T : class
|
||||
{
|
||||
try { return action(); } catch { return null; }
|
||||
}
|
||||
|
||||
private static T? Try<T>(Func<T?> 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;
|
||||
|
||||
/// <summary>Replace the in-memory settings cache from a loaded <see cref="Profile"/>.
|
||||
/// Called once at app startup after the user picks a profile (or never, if they pick
|
||||
/// the blank template — in which case defaults remain).</summary>
|
||||
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<string>(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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string>(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<string>? 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<Keys>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Tag carried in the per-stream <see cref="AudioFormatInfo.Lane"/> 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
|
||||
/// <see cref="Mixed"/> and the receiver's <c>PlayoutEngine</c> 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 <see cref="WasapiLane"/>
|
||||
/// or <see cref="AsioLane"/>; the receiver routes each lane's audio to a separate
|
||||
/// <c>SessionPlayout</c> 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 <see cref="Mixed"/>,
|
||||
/// also matching the pre-2026-05-11 behaviour.
|
||||
/// </summary>
|
||||
public enum RenderRoute : byte
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
Mixed = 0,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
WasapiLane = 1,
|
||||
|
||||
/// <summary>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.</summary>
|
||||
AsioLane = 2,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum SendRate
|
||||
{
|
||||
Standard = 0,
|
||||
Tight = 1,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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 <ip:port> [--opus] [--device <id>]");
|
||||
Console.WriteLine(" Capture the default (or selected) render device and send to the receiver.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RemSound.Harness recv [--port N] [--device <id>] [--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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Harness</RootNamespace>
|
||||
<AssemblyName>RemSound.Harness</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,244 @@
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// ASIO render backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver,
|
||||
/// pulling the receiver's mixed stereo audio from <see cref="PlayoutEngine"/> and broadcasting
|
||||
/// it across one or more output channel pairs of the driver. Same shape as
|
||||
/// <see cref="MultiOutputPlayout"/>: <see cref="AudioReceiver"/> doesn't care which is active.
|
||||
///
|
||||
/// Spec identity: each output ID is a synthetic <c>"asio:<channel-pair-index>"</c>. Pair 0
|
||||
/// = ASIO output channels 0+1, pair 1 = 2+3, etc. The driver itself is locked at construction.
|
||||
///
|
||||
/// Same simplifications as <see cref="AsioCaptureBackend"/>: 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.
|
||||
/// </summary>
|
||||
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<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
private readonly object gate = new();
|
||||
|
||||
private AsioOut? asio;
|
||||
private List<int> activeChannelPairs = [];
|
||||
private BroadcastProvider? broadcaster;
|
||||
|
||||
public AsioRenderBackend(string driverName, IWaveProvider source, Action<string>? 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<string> 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<string> 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<int> ParsePairs(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
var result = new List<int>();
|
||||
foreach (var id in deviceIds)
|
||||
{
|
||||
if (AsioDeviceId.TryParse(id, out var pair) && pair >= 0)
|
||||
{
|
||||
result.Add(pair);
|
||||
}
|
||||
}
|
||||
result.Sort();
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wave provider that pulls stereo audio from <see cref="PlayoutEngine"/> 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.
|
||||
/// </summary>
|
||||
private sealed class BroadcastProvider : IWaveProvider
|
||||
{
|
||||
private readonly IWaveProvider source;
|
||||
private readonly int outputChannelCount;
|
||||
private byte[] sourceScratchBytes = new byte[16384];
|
||||
private List<int> activePairs;
|
||||
|
||||
public WaveFormat WaveFormat { get; }
|
||||
|
||||
public BroadcastProvider(IWaveProvider source, int outputChannelCount, List<int> activePairs)
|
||||
{
|
||||
this.source = source;
|
||||
this.outputChannelCount = outputChannelCount;
|
||||
this.activePairs = new List<int>(activePairs);
|
||||
WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, outputChannelCount);
|
||||
}
|
||||
|
||||
public void SetActivePairs(IEnumerable<int> 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<byte, float>(sourceScratchBytes.AsSpan(0, sourceBytes));
|
||||
var dstFloats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Public façade for the receiver pipeline. Routes raw packets from <see cref="NetworkListener"/>
|
||||
/// to one <see cref="StreamSession"/> per remote sender, all of which write to their own
|
||||
/// <see cref="SessionPlayout"/>; the <see cref="PlayoutEngine"/> then mixes those at render time.
|
||||
///
|
||||
/// Multi-source rationale: the previous design held a single <c>activeSession</c> 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
|
||||
/// <see cref="SessionIdleTimeout"/> is removed by <see cref="PruneIdleSessions"/>, 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.
|
||||
/// </summary>
|
||||
public sealed class AudioReceiver : IDisposable
|
||||
{
|
||||
public const int MixSampleRate = 48000;
|
||||
public const int MixChannels = 2;
|
||||
private const int MixBytesPerSecond = MixSampleRate * MixChannels * sizeof(float);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private const int CapacityHeadroomMultiplier = 8;
|
||||
private const int MaxLatencyForSizingMs = 500;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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<string>? 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();
|
||||
|
||||
/// <summary>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 <see cref="PruneIdleSessions"/> 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.</summary>
|
||||
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<IPAddress>? 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}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>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 <see cref="SessionPlayout.ReadFloats"/>. 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.</summary>
|
||||
public void SetSmoothness(int value) => playoutEngine.SetSmoothness(value);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
|
||||
playoutEngine.SetConcealmentArtifact(artifact);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetAllowedSenders(IEnumerable<IPEndPoint>? allowed)
|
||||
{
|
||||
// Reduce IPEndPoint inputs to bare IPAddress for the gate; see field-comment for why.
|
||||
var snapshot = allowed is null ? null : new HashSet<IPAddress>(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<StreamSession> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Optional diagnostic sink (App writes to log file).</summary>
|
||||
public Action<string>? Diagnostic { get => diagnosticSink; set => diagnosticSink = value; }
|
||||
|
||||
/// <summary>True when audio playback is active — i.e. <see cref="SetPlaybackEnabled"/>
|
||||
/// has been called with <c>true</c> 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 <see cref="IsListenerRunning"/>.
|
||||
/// 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.</summary>
|
||||
public bool IsRunning => multiOutput.IsRunning;
|
||||
/// <summary>True when the UDP listener socket is bound. Independent of playback state.
|
||||
/// Surfaced for diagnostic/symmetry only — most callers want <see cref="IsRunning"/>.</summary>
|
||||
public bool IsListenerRunning => listener.IsRunning;
|
||||
/// <summary>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.</summary>
|
||||
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
|
||||
|
||||
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
|
||||
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
|
||||
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
|
||||
/// render lane is holding samples (slow consumer holding back compaction, or the fast
|
||||
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets
|
||||
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly
|
||||
/// inflating latency on either lane.</summary>
|
||||
public int TakeMaxFanOutCacheMs()
|
||||
{
|
||||
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec.
|
||||
const int MixBytesPerSecond = 48000 * 2 * 4;
|
||||
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0;
|
||||
return bytes * 1000 / MixBytesPerSecond;
|
||||
}
|
||||
public string OutputDeviceName => multiOutput.ActiveDeviceSummary;
|
||||
public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
|
||||
public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public void SetMaxLatencyMsSoft(int value) =>
|
||||
playoutEngine.SetMaxLatencyMs(value, drainOnLower: false);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
/// <summary>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.</summary>
|
||||
public long UnderrunsFor(RenderRoute route) => playoutEngine.AggregateUnderrunsFor(route);
|
||||
/// <summary>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.</summary>
|
||||
public bool HasSessionsForRoute(RenderRoute route) => playoutEngine.HasSessionsForRoute(route);
|
||||
|
||||
public long Underruns => playoutEngine.AggregateUnderruns;
|
||||
public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
|
||||
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
|
||||
public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
|
||||
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been
|
||||
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout
|
||||
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
|
||||
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
|
||||
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
|
||||
/// <summary>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".</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Total times we used Opus inband FEC to recover a single-packet gap, across all active sessions.</summary>
|
||||
public long OpusFecRecoveries
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.OpusFecRecoveries;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total times we saw a multi-packet gap that FEC could not fill, across all active sessions.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds) => multiOutput.SetOutputDevices(deviceIds);
|
||||
|
||||
/// <summary>Take a snapshot of the rolling diagnostic counters. Caller drives at 1 Hz.</summary>
|
||||
public ReceiverDiagnostics.DiagSnapshot TakeDiagnosticsSnapshot() => diagnostics.Take(MixBytesPerSecond);
|
||||
|
||||
/// <summary>
|
||||
/// Bind the UDP listener socket on <paramref name="udpPort"/>. Does NOT start audio
|
||||
/// playback — call <see cref="SetPlaybackEnabled"/>(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 <see cref="OnHeartbeatReceived"/>,
|
||||
/// but Format/Audio packets are discarded at receipt (no decode, no buffer growth).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles audio playback on or off. When <paramref name="enabled"/> 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 <see cref="Start"/> has been called. Idempotent.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop sessions that haven't received audio data in <see cref="SessionIdleTimeout"/>. Caller
|
||||
/// (the App's snapshot tick) drives this so it stays serialised with the network thread on
|
||||
/// the same lock the packet handlers use.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="SessionIdleTimeout"/>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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) ===
|
||||
|
||||
/// <summary>Hook for Heartbeat packets that arrive on the audio receiver's socket. The
|
||||
/// App wires this to <see cref="HeartbeatService.HandleInjectedPacket"/>. 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.</summary>
|
||||
public Action<byte[], int, IPEndPoint>? OnHeartbeatReceived { get; set; }
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public Action<RemoteControlKind, sbyte, IPEndPoint>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="HeartbeatService"/>) — passing a Heartbeat packet here is
|
||||
/// safe (it'll be counted and dropped) but wasteful.
|
||||
/// </summary>
|
||||
public void InjectExternalPacket(byte[] packet, int length, IPEndPoint remote)
|
||||
{
|
||||
HandleRawPacket(packet, length, remote);
|
||||
}
|
||||
|
||||
private void HandleFormat(IPEndPoint remote, ushort streamId, ReadOnlySpan<byte> 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<StreamSession>? 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;
|
||||
/// <summary>
|
||||
/// Monotonic count of new <c>StreamSession</c> 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.
|
||||
/// </summary>
|
||||
public long SessionsOpenedCount => Interlocked.Read(ref sessionsOpenedCount);
|
||||
|
||||
private void HandleAudio(IPEndPoint remote, ushort streamId, uint sequence, ReadOnlySpan<byte> 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);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Render backend that runs a WASAPI <see cref="MultiOutputPlayout"/> and an
|
||||
/// <see cref="AsioRenderBackend"/> in parallel. Two pipeline shapes are reachable today:
|
||||
/// <list type="bullet">
|
||||
/// <item>WasapiOnly: WASAPI child reads <see cref="PlayoutEngine"/> directly; no ASIO in
|
||||
/// the path. Used when no ASIO driver is selected.</item>
|
||||
/// <item>BothIndependent: WASAPI and ASIO children each get their own consumer view from a
|
||||
/// shared <see cref="FanOutSource"/>. 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.</item>
|
||||
/// </list>
|
||||
/// The legacy <c>AudioMode.Both</c> tee mode and <c>AudioMode.AsioOnly</c> values are no
|
||||
/// longer reachable from the UI and are not produced here.
|
||||
/// </summary>
|
||||
internal sealed class CompositeRenderBackend : IRenderBackend
|
||||
{
|
||||
private readonly PlayoutEngine source;
|
||||
private readonly Action<string>? 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<string>? 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;
|
||||
|
||||
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads
|
||||
/// per-lane sources directly with no intermediate cache. Kept on the surface so the
|
||||
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero
|
||||
/// rather than disappearing. Can be removed once we're confident the per-lane wiring
|
||||
/// is the right shape long-term.</summary>
|
||||
public int TakeMaxFanOutCacheBytes() => 0;
|
||||
|
||||
public string ActiveDeviceSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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<string> ActiveDeviceIds
|
||||
{
|
||||
get
|
||||
{
|
||||
var combined = new List<string>();
|
||||
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<string> deviceIds)
|
||||
{
|
||||
// Split by id format: ASIO ids start with "asio:". WASAPI ids are MMDevice strings.
|
||||
var wasapiIds = new List<string>();
|
||||
var asioIds = new List<string>();
|
||||
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.
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the render-side audio backend so <see cref="AudioReceiver"/> can be wired
|
||||
/// to either a WASAPI implementation (today's <see cref="MultiOutputPlayout"/>) or an ASIO
|
||||
/// implementation (<see cref="AsioRenderBackend"/>) without caring which is in use.
|
||||
///
|
||||
/// Both backends pull mixed audio from <see cref="PlayoutEngine"/>'s <see cref="IWaveProvider"/>
|
||||
/// surface and route it to one or more output destinations. WASAPI destinations are MMDevice
|
||||
/// IDs; ASIO destinations are synthetic IDs of the form
|
||||
/// <c>"asio:<driver-name>|<channel-pair-index>"</c>.
|
||||
/// </summary>
|
||||
internal interface IRenderBackend : IDisposable
|
||||
{
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>Friendly summary for the snapshot log column. "(none)" when nothing is
|
||||
/// configured, comma-joined names for ≤3 outputs, "(N outputs)" otherwise.</summary>
|
||||
string ActiveDeviceSummary { get; }
|
||||
|
||||
IReadOnlyList<string> ActiveDeviceIds { get; }
|
||||
|
||||
void Start();
|
||||
|
||||
void Stop();
|
||||
|
||||
/// <summary>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).</summary>
|
||||
void SetOutputDevices(IReadOnlyList<string> deviceIds);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Drives N WASAPI output devices from a single shared <see cref="PlayoutEngine"/>. 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 <see cref="BufferedWaveProvider"/>; each <see cref="WasapiOut"/>
|
||||
/// 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 <see cref="SetOutputDevices"/>: existing devices stay live,
|
||||
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
|
||||
/// </summary>
|
||||
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<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, OutputEntry> 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<string>? onDiagnostic = null)
|
||||
{
|
||||
this.source = source;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => produceTask is { IsCompleted: false };
|
||||
|
||||
/// <summary>
|
||||
/// Friendly names of currently-active output devices, comma-joined. "(none)" when no
|
||||
/// device is enabled. Used by the snapshot log column.
|
||||
/// </summary>
|
||||
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<string> 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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var desired = new HashSet<string>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
/// <see cref="Socket.ReceiveFrom"/> with <see cref="SocketAddress"/> avoids the per-call
|
||||
/// IPEndPoint boxing that <see cref="UdpClient.ReceiveAsync"/> incurred.
|
||||
/// </summary>
|
||||
internal sealed class NetworkListener : IDisposable
|
||||
{
|
||||
private readonly Action<byte[], int, IPEndPoint> onPacket;
|
||||
private readonly Action<string> 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<byte[], int, IPEndPoint> onPacket, Action<string> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public long RejectionCount => Interlocked.Read(ref rejectionCount);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public long DiscardedPartialCount => Interlocked.Read(ref discardedPartialCount);
|
||||
|
||||
public bool TryAssemble(ReadOnlySpan<byte> partBytes, uint frameId, byte partIndex, byte totalParts, out ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
using System.Net;
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-source playout coordinator. Holds one <see cref="SessionPlayout"/> per active sender and
|
||||
/// implements <see cref="IWaveProvider"/> 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: <see cref="GetOrCreateSession"/> / <see cref="RemoveSession"/>
|
||||
/// take a lock and mutate the dictionary; <see cref="Read"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public int TargetLatencyMs => mixedLatency.TargetMs;
|
||||
/// <summary>Legacy property returning the Mixed route's max.</summary>
|
||||
public int MaxLatencyMs => mixedLatency.MaxMs;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. 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.
|
||||
/// </summary>
|
||||
public IWaveProvider WasapiLaneOutput => wasapiLaneOutput;
|
||||
|
||||
/// <summary>
|
||||
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.AsioLane"/>. Same
|
||||
/// contract as <see cref="WasapiLaneOutput"/>; only used in BothIndependent mode.
|
||||
/// </summary>
|
||||
public IWaveProvider AsioLaneOutput => asioLaneOutput;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <paramref name="drainOnLower"/> = 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 <paramref name="drainOnLower"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <summary>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.</summary>
|
||||
public void SetMaxLatencyMs(int value, bool drainOnLower = true) =>
|
||||
SetMaxLatencyMs(RenderRoute.Mixed, value, drainOnLower);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SessionPlayout> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public long AggregateUnderrunsFor(RenderRoute route)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot)
|
||||
{
|
||||
if (s.Route == route) total += s.UnderrunCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sum of click-trim drop bytes across all active sessions.</summary>
|
||||
public long AggregateTrimDropBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.TrimDropBytes;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sum of slider-drain drop bytes across all active sessions.</summary>
|
||||
public long AggregateDrainDropBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DrainDropBytes;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total click-trim fires (one per trim event, regardless of bytes dropped).</summary>
|
||||
public long AggregateTrimFireCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.TrimFireCount;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame drops the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftDropFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame repeats the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftRepeatFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// === WASAPI render thread ===
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="LatencyFor"/>, 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 <see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>
|
||||
/// surfaces are kept around for future per-route routing options but are not used by the
|
||||
/// default render path (see <c>CompositeRenderBackend</c>). 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.
|
||||
/// </summary>
|
||||
public int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
|
||||
|
||||
/// <summary>
|
||||
/// Shared per-route render pull. Iterates the session snapshot, summing only those
|
||||
/// sessions whose <see cref="SessionPlayout.Route"/> matches the requested filter into
|
||||
/// the caller's scratch buffers, applies volume/mute/limiter, and packs to bytes. The
|
||||
/// Mixed route additionally feeds <see cref="ReceiverDiagnostics"/> (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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-lane IWaveProvider. Each instance filters PlayoutEngine's session snapshot down
|
||||
/// to sessions tagged with a specific <see cref="RenderRoute"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AudioReceiver</c>
|
||||
/// whenever a new <c>StreamSession</c> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public void RecordOutputSampleSteps(ReadOnlySpan<float> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the rolling counters and reset them. Called by the App once per second.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Receiver</RootNamespace>
|
||||
<AssemblyName>RemSound.Receiver</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="Concentus" Version="2.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,749 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// 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. <see cref="PlayoutEngine"/> 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 <c>DriftGain</c> / <c>DriftCrossfadeFrames</c>.
|
||||
///
|
||||
/// Threading: <see cref="Write"/> runs on the network thread (per-sender producer);
|
||||
/// <see cref="ReadFloats"/> runs on the WASAPI/ASIO render thread (single consumer). The
|
||||
/// AudioRingBuffer is SPSC-safe; drift / concealment state is only touched from the consumer.
|
||||
/// </summary>
|
||||
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; }
|
||||
/// <summary>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).</summary>
|
||||
public ushort StreamId { get; }
|
||||
/// <summary>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 <see cref="RenderRoute.Mixed"/> — the value an old
|
||||
/// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public long TrimDropBytes => Interlocked.Read(ref trimDropBytes);
|
||||
public long DrainDropBytes => Interlocked.Read(ref drainDropBytes);
|
||||
public long TrimFireCount => Interlocked.Read(ref trimFireCount);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact value) =>
|
||||
concealmentArtifactRaw = (int)value;
|
||||
|
||||
/// <summary>UTC time of the most recent successful audio write. Used by <see cref="AudioReceiver"/>
|
||||
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
|
||||
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<byte> source)
|
||||
{
|
||||
var ms = source.Length * 1000 / MixBytesPerSecond;
|
||||
if (ms > largestWriteMs) largestWriteMs = ms;
|
||||
playout.Write(source);
|
||||
LastWriteUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public void DisarmAndRequestDrain()
|
||||
{
|
||||
playbackArmed = false;
|
||||
drainRequested = true;
|
||||
}
|
||||
|
||||
/// <summary>Reset the buffer and per-session state. Used at start/stop. Arming will rebuild
|
||||
/// from the next packets that arrive.</summary>
|
||||
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.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WASAPI/ASIO render thread. Pulls <paramref name="outFrames"/> stereo frames from this
|
||||
/// session's playout ring into <paramref name="output"/>, 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
|
||||
/// <paramref name="output"/> is untouched (caller is responsible for zero-fill).
|
||||
/// </summary>
|
||||
public int ReadFloats(Span<float> 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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment.
|
||||
/// On a short read, replaces the silence-filled tail with a brief synthesised burst
|
||||
/// (character chosen by <see cref="SetConcealmentArtifact"/>) 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.
|
||||
/// </summary>
|
||||
private void ReadInputWithConcealment(Span<float> 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];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
|
||||
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
|
||||
/// hard-zero in place.</summary>
|
||||
private void ApplyFadeOut(Span<float> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private static void ApplyFadeIn(Span<float> 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);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using Concentus;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AudioRingBuffer"/>.
|
||||
/// </summary>
|
||||
internal sealed class StreamSession : IDisposable
|
||||
{
|
||||
private readonly SessionPlayout sessionPlayout;
|
||||
private readonly ReceiverDiagnostics diagnostics;
|
||||
private readonly Action<int> 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;
|
||||
/// <summary>Number of single-packet gaps recovered using inband FEC from the next packet.</summary>
|
||||
public long OpusFecRecoveries { get; private set; }
|
||||
/// <summary>Number of multi-packet gaps where FEC could not help (only logs once per occurrence).</summary>
|
||||
public long OpusUnrecoveredGaps { get; private set; }
|
||||
|
||||
public IPEndPoint Endpoint { get; }
|
||||
public ushort StreamId { get; }
|
||||
public AudioFormatInfo Format { get; }
|
||||
public AudioTransportCodec Codec => (AudioTransportCodec)Format.Codec;
|
||||
|
||||
/// <summary>For PCM streams: number of incoming packets the assembler rejected outright.</summary>
|
||||
public long PcmFrameRejections => pcmAssembler.RejectionCount;
|
||||
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
|
||||
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
|
||||
|
||||
public StreamSession(
|
||||
IPEndPoint endpoint,
|
||||
ushort streamId,
|
||||
AudioFormatInfo format,
|
||||
SessionPlayout sessionPlayout,
|
||||
ReceiverDiagnostics diagnostics,
|
||||
Action<int> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns true if this session matches the given format identity (codec/rate/channels/frame).</summary>
|
||||
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<byte> 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<byte> 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<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
PcmPack.Int24LEToFloat(assembled, floatSpan);
|
||||
|
||||
sessionPlayout.Write(floatScratch);
|
||||
onFramesQueued(sampleCount / Format.Channels);
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Opus ===
|
||||
|
||||
private bool HandleOpus(uint sequence, ReadOnlySpan<byte> 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<short> 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<short> shortScratch, int sampleCountPerChannel)
|
||||
{
|
||||
var floatCount = sampleCountPerChannel * Format.Channels;
|
||||
var floatBytes = floatCount * sizeof(float);
|
||||
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
for (var i = 0; i < floatCount; i++) floatSpan[i] = shortScratch[i] / 32768f;
|
||||
|
||||
sessionPlayout.Write(floatScratch);
|
||||
onFramesQueued(sampleCountPerChannel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.Asio;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// ASIO capture backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver and
|
||||
/// produces 48 kHz stereo float frames in the same shape <see cref="MixingEngine"/> does, so
|
||||
/// <see cref="AudioSender"/> doesn't care which backend is active.
|
||||
///
|
||||
/// Spec identity: each <see cref="CaptureSourceSpec"/> for ASIO uses a synthetic
|
||||
/// <c>DeviceId</c> of the form <c>"asio:<channel-pair-index>"</c>. 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 <see cref="Start"/> 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.
|
||||
/// </summary>
|
||||
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<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
public string DriverName => driverName;
|
||||
private readonly object gate = new();
|
||||
|
||||
private AsioOut? asio;
|
||||
private List<int> 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<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.driverName = driverName;
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetCallback(Action<ReadOnlyMemory<float>> 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<string> ActiveSourceNames
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return activeChannelPairIndices
|
||||
.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}")
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> 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<CaptureSourceSpec> 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<int> ParseChannelPairIndices(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
var result = new List<int>();
|
||||
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<int> 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<float>(mixScratch, 0, stereoFloats));
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static IReadOnlyList<string> EnumerateDriverNames()
|
||||
{
|
||||
try { return AsioOut.GetDriverNames().ToList(); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Win32;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AsioCaptureBackend"/> / <see cref="AsioRenderBackend"/> implementation classes.
|
||||
/// These are read-only queries: opening the driver briefly to read its info, then closing —
|
||||
/// does NOT claim the device for streaming.
|
||||
/// </summary>
|
||||
public static class AsioDeviceProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Names of all installed ASIO drivers. Tries several enumeration paths and merges results,
|
||||
/// because:
|
||||
/// • NAudio's built-in <c>AsioOut.GetDriverNames()</c> reads <c>HKLM\SOFTWARE\ASIO</c> 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 <c>Wow6432Node</c> 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> EnumerateDriverNames()
|
||||
{
|
||||
var names = new HashSet<string>(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<string> 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 */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>(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<string>(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, [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backwards-compatibility shim around <see cref="ProbeDriverInfo"/> for callers that only
|
||||
/// need channel counts.
|
||||
/// </summary>
|
||||
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<string> InputChannelNames,
|
||||
IReadOnlyList<string> OutputChannelNames);
|
||||
@@ -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).
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="MixingEngine"/>, 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).
|
||||
/// </summary>
|
||||
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<CaptureSourceSpec> 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;
|
||||
|
||||
/// <summary>Optional diagnostic sink. Set by callers (typically the App) before Start to receive
|
||||
/// human-readable status strings ("capture started…", "capture stopped with error…", etc.).</summary>
|
||||
public Action<string>? Diagnostic
|
||||
{
|
||||
get => diagnostic;
|
||||
set => diagnostic = value;
|
||||
}
|
||||
private Action<string>? 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 <see cref="SenderLane"/> 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="StartReceiving"/> is called.
|
||||
/// </summary>
|
||||
public Action<byte[], int, IPEndPoint>? 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AudioMode.AsioOnly</c> and <c>AudioMode.Both</c> are tolerated (the composite
|
||||
/// coerces them) but no UI path produces them any more. If running, previously-pending
|
||||
/// sources are re-applied automatically.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make sure <see cref="persistentAsio"/> 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 <see cref="configGate"/>. The callback is also rewired here based
|
||||
/// on which lane should receive ASIO audio in the new mode.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)create the composite backend with the current audio-mode + asio-driver-name +
|
||||
/// tight-latency-WASAPI flag. Caller must hold <c>configGate</c>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Updates the PCM frame size based on the user's "Send rate" choice. For Opus,
|
||||
/// frame size is set via <see cref="ConfigureCodec"/>'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.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <c>tightLatencyEnabled</c> directly.
|
||||
/// * WasapiOnly with single source: rebuilds the capture backend as
|
||||
/// <see cref="PushModeWasapiBackend"/> instead of <see cref="MixingEngine"/>. The WASAPI
|
||||
/// capture event drives the encode/UDP-send pipeline directly, eliminating the ~6 ms
|
||||
/// of Stopwatch+WaitHandle scheduler jitter that <see cref="MixingEngine"/>'s mix tick
|
||||
/// adds. Especially important at high device sample rates (96 kHz EVO8 etc.) where the
|
||||
/// in-tick resampler stage compounds the jitter. <see cref="CompositeCaptureBackend"/>
|
||||
/// 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.</summary>
|
||||
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;
|
||||
/// <summary>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).</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set the destinations to which packets are sent. Live-updateable.</summary>
|
||||
public void SetReceivers(IEnumerable<IPEndPoint> endpoints)
|
||||
{
|
||||
var list = endpoints.ToArray();
|
||||
Volatile.Write(ref receivers, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Configure(IReadOnlyList<CaptureSourceSpec> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a background thread reading inbound packets from this sender's socket and
|
||||
/// dispatching them to <see cref="OnInboundPacket"/>. 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.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Cumulative inbound packets received on this sender's socket. Mostly zero
|
||||
/// outside relay mode.</summary>
|
||||
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) ===
|
||||
|
||||
/// <summary>
|
||||
/// Emit a fully-constructed packet to every configured receiver. Per-lane code in
|
||||
/// <see cref="SenderLane"/> 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.
|
||||
/// </summary>
|
||||
internal void SendToAll(ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// One capture source feeding the mixer. Wraps a single <see cref="WasapiCapture"/> (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 <see cref="Provider"/> exposes that final 48 kHz stereo float stream so the mixing engine
|
||||
/// can plug it into NAudio's <see cref="MixingSampleProvider"/>.
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>? 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<string>? 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Capture backend that runs a WASAPI <see cref="MixingEngine"/> and the persistent
|
||||
/// <see cref="AsioCaptureBackend"/> owned by <see cref="AudioSender"/> in parallel, as two
|
||||
/// independent lanes — each producing its own PCM stream for its own <see cref="SenderLane"/>.
|
||||
///
|
||||
/// Two pipeline shapes are reachable today:
|
||||
/// <list type="bullet">
|
||||
/// <item>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.</item>
|
||||
/// <item>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 <c>AudioMode.Both</c> tee-style mode and <c>AudioMode.AsioOnly</c> are
|
||||
/// no longer reachable from the UI; their enum values remain in
|
||||
/// <see cref="AudioMode"/> for back-compat but produce nothing here.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<ReadOnlyMemory<float>> 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<ReadOnlyMemory<float>>? onAsioLaneSamples;
|
||||
private readonly Action<string>? 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<CaptureSourceSpec> wasapiSpecs = [];
|
||||
private List<CaptureSourceSpec> asioSpecs = [];
|
||||
private bool started;
|
||||
|
||||
public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<ReadOnlyMemory<float>>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action<string>? 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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public int TakeMaxCallbackGapMs()
|
||||
{
|
||||
var w = wasapi?.TakeMaxCallbackGapMs() ?? 0;
|
||||
var a = asio?.TakeMaxCallbackGapMs() ?? 0;
|
||||
return Math.Max(w, a);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveSourceNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var combined = new List<string>();
|
||||
if (wasapi is not null) combined.AddRange(wasapi.ActiveSourceNames);
|
||||
if (asio is not null) combined.AddRange(asio.ActiveSourceNames);
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> 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<CaptureSourceSpec> 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<CaptureSourceSpec> wasapi, List<CaptureSourceSpec> asio) SplitSpecs(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
var wasapi = new List<CaptureSourceSpec>();
|
||||
var asio = new List<CaptureSourceSpec>();
|
||||
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<CaptureSourceSpec> a, IReadOnlyList<CaptureSourceSpec> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the capture-side audio backend so <see cref="AudioSender"/> can be wired
|
||||
/// to either a WASAPI implementation (today's <see cref="MixingEngine"/>) or an ASIO
|
||||
/// implementation (<see cref="AsioCaptureBackend"/>) without caring which is in use.
|
||||
///
|
||||
/// Both backends produce 48 kHz stereo float frames via the constructor-supplied
|
||||
/// <c>onMixedSamples</c> callback and accept the same <see cref="CaptureSourceSpec"/> identity
|
||||
/// model. ASIO specs use a synthetic <see cref="CaptureSourceSpec.DeviceId"/> of the form
|
||||
/// <c>"asio:<driver-name>|<channel-pair-index>"</c>; WASAPI specs use the
|
||||
/// MMDevice ID.
|
||||
/// </summary>
|
||||
internal interface ICaptureBackend : IDisposable
|
||||
{
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>Total capture callback count across all active sources (WASAPI) or the ASIO
|
||||
/// driver's input-callback count.</summary>
|
||||
long TotalCaptureCallbacks { get; }
|
||||
|
||||
long TotalCaptureBytes { get; }
|
||||
|
||||
/// <summary>Brief format description of the first source (e.g. "96000 Hz, 2 ch, 32-bit
|
||||
/// float"). Used in diagnostic logs.</summary>
|
||||
string? FirstCaptureFormatDescription { get; }
|
||||
|
||||
string? FirstCaptureLastError { get; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
long ClippedSampleCount { get; }
|
||||
|
||||
/// <summary>Friendly names of currently-active sources for diagnostic columns.</summary>
|
||||
IReadOnlyList<string> ActiveSourceNames { get; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
int TakeMaxCallbackGapMs();
|
||||
|
||||
void Start(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
void Stop();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// WasapiLoopbackCapture variant that uses event-sync (interrupt-driven) callbacks with a
|
||||
/// short audio buffer. NAudio's default <see cref="WasapiLoopbackCapture"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Owns N <see cref="CaptureSource"/> objects + an NAudio <see cref="MixingSampleProvider"/> +
|
||||
/// 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 <see cref="OnMixedSamples"/>, 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 <see cref="SilentRenderKeepAlive"/> 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: <see cref="UpdateSources"/> 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.
|
||||
/// </summary>
|
||||
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<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
|
||||
private readonly List<ActiveSource> 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<ReadOnlyMemory<float>> onMixedSamples, Action<string>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <see cref="PushModeWasapiBackend"/> instead, which is callback-driven.</summary>
|
||||
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<string> ActiveSourceNames
|
||||
{
|
||||
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the mix loop with the given initial source set. If already running, the existing
|
||||
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
|
||||
/// sources without interrupting the loop.
|
||||
/// </summary>
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> 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})"))}]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> 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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<float>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Concentus;
|
||||
using Concentus.Enums;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Encode one frame at the configured frame size. Returns bytes written.</summary>
|
||||
public int Encode(ReadOnlySpan<float> 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<byte> LastEncoded(int length) => packetScratch.AsSpan(0, length);
|
||||
|
||||
public void Dispose() { /* IOpusEncoder is finalized by GC, no Dispose */ }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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: <see cref="MixingEngine"/> uses a Stopwatch-driven 10 ms mix tick that
|
||||
/// pulls audio through a sample-provider chain. That tick is woken by
|
||||
/// <see cref="WaitHandle.WaitAny"/>, which on Windows has ~6 ms of inherent jitter even with
|
||||
/// MMCSS Pro Audio thread priority — visible as <c>maxGapMs=16-20 ms</c> 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 <see cref="AsioCaptureBackend"/> already has.
|
||||
///
|
||||
/// Constraints (deliberate scope reduction so we ship something testable):
|
||||
/// • Single source only. <see cref="Start"/> with multiple specs throws — caller is
|
||||
/// expected to fall back to <see cref="MixingEngine"/> 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
|
||||
/// <see cref="WaveFormatEncoding.IeeeFloat"/> 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 <see cref="MixingEngine"/> in that
|
||||
/// case (which uses NAudio's <c>ToSampleProvider</c> conversion path that handles all
|
||||
/// formats).
|
||||
/// • Resampling is performed inline using <see cref="WdlResampler"/> (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. <see cref="onMixedSamples"/> 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).
|
||||
/// </summary>
|
||||
internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int CaptureBufferMs = 10;
|
||||
|
||||
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? 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<ReadOnlyMemory<float>> onMixedSamples, Action<string>? 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<string> ActiveSourceNames =>
|
||||
activeSpec is { } s ? new[] { s.Name } : Array.Empty<string>();
|
||||
|
||||
/// <summary>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
|
||||
/// <see cref="AsioCaptureBackend.TakeMaxCallbackGapMs"/> which does track it because that's
|
||||
/// where Ed has been hunting jitter.</summary>
|
||||
public int TakeMaxCallbackGapMs() => 0;
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> 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<CaptureSourceSpec> 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<float>(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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Sender</RootNamespace>
|
||||
<AssemblyName>RemSound.Sender</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="Concentus" Version="2.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,305 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="OnMixedSamples"/> 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 (<see cref="ConfigureCodec"/>,
|
||||
/// <see cref="OnPcmFrameSizeChanged"/>) 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetRoute(RenderRoute newRoute)
|
||||
{
|
||||
if (route == newRoute) return;
|
||||
route = newRoute;
|
||||
streamId = NewStreamId();
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
|
||||
/// <summary>Reset per-lane counters and pick a new streamId. Called from
|
||||
/// <see cref="AudioSender.Start"/> so the receiver sees a fresh session on each start.</summary>
|
||||
public void ResetForStart()
|
||||
{
|
||||
streamId = NewStreamId();
|
||||
audioSequence = 0;
|
||||
pcmFrameId = 0;
|
||||
formatSequence = 0;
|
||||
frameAccumulatorWritten = 0;
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public void OnPcmFrameSizeChanged()
|
||||
{
|
||||
streamId = NewStreamId();
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
|
||||
// === hot path ===
|
||||
|
||||
public void OnMixedSamples(ReadOnlyMemory<float> 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<float> 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<float> 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<float> stereoFloats)
|
||||
{
|
||||
var bytesOnWire = stereoFloats.Length * 3;
|
||||
Span<byte> 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<float> stereoFloats)
|
||||
{
|
||||
ReadOnlySpan<byte> opusBytes;
|
||||
if (owner.IsMuted)
|
||||
{
|
||||
Span<float> 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<byte> 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<byte> 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<byte> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class SilentRenderKeepAlive : IDisposable
|
||||
{
|
||||
private readonly WasapiOut output;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
|
||||
public SilentRenderKeepAlive(MMDevice device, Action<string>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user