Bump to v2.1.0: UPnP, read-only profile lock, sleep/hibernate audio fix

Headline features:
* Automatic router port opening (UPnP / NAT-PMP / PCP). Opt-in via
  Preferences; surfaces external address + carrier-grade NAT detection.
* Lock profile (read-only). New File-menu tick that makes a profile
  load-only — session changes don't persist, no save prompt on close.
  Unblocks unattended shutdowns (NVDA gone, remote dropped, hibernate)
  where the existing save prompt could deadlock.
* Check for updates on startup (default on) + brief countdown notice
  before silent updates install, so a launch-time update doesn't make
  the app silently vanish.
* "Cue sounds" -> "Audio cue sounds" label clarification.

Bug fixes:
* No sound after the computer wakes from sleep. PowerResumeHandler
  rebuilds the audio backend automatically on resume; brief
  "Reconnecting to audio driver" splash during the rebuild.
* Receiver audio silent after waking from hibernate. RefreshAudioDeviceLists
  now treats a transient ASIO probe failure (returns -1/-1 because the
  driver is mid-teardown / mid-reinit) as "retry next tick" instead of
  clearing the user's tick selection.

Diagnostic-only changes (gated on the existing Enable-logs checkbox,
zero cost when off):
* AudioStepProbe split into cross-buffer vs within-buffer maxes so log
  inspection can tell a real-content sharp transient apart from a
  pipeline-boundary glitch. Plumbed through every probe owner.
* New rxNetGapMs + gc0/gc1/gc2 delta columns in the diag log to split
  receive-side jitter into network-layer vs managed-runtime causes.

Files touched: RELEASE_NOTES.md + readme.html + 24 source files across
RemSound.Core / RemSound.Sender / RemSound.Receiver / RemSound.App.
Three new app files: PowerResumeHandler, RouterPortMapper,
UpdateInstallNoticeDialog.

Wire format and audio pipeline unchanged from v1.5 onward — v1.5
through v2.1 peers interoperate.
This commit is contained in:
Ednunp
2026-05-22 23:06:07 +01:00
parent 00cb4deef1
commit 79b28b6c02
27 changed files with 1980 additions and 125 deletions
+21 -9
View File
@@ -1,26 +1,38 @@
# RemSound v2.0 # RemSound v2.1
A smoother startup for setups that use an ASIO driver. Wire format and audio pipeline are unchanged from v1.4 onward — all versions interoperate. Automatic router setup for internet streaming, a new "lock this profile" option to keep your default profile from prompting on close, a small notice before background updates install, and fixes for the "no sound after sleep / hibernate" problem. Wire format and audio pipeline are unchanged from v1.5 onward — all versions from v1.5 to v2.1 interoperate.
## Change ## What's new
**Startup no longer looks frozen when a profile uses an ASIO driver.** Opening an ASIO driver takes a couple of seconds, and that happens while the main window is being built — so the window used to appear blank or "Not Responding" during that time. RemSound now shows a small **"Loading audio driver, please wait..."** window while the driver opens, then brings up the main window as usual. Profiles that don't use an ASIO driver are unaffected — they start instantly, with no extra window. * **Automatic router port opening (UPnP).** RemSound can now ask your router to open the audio port for incoming peer connections, so you don't have to set up port forwarding by hand. Off by default — tick **"Automatically open my router for incoming connections (UPnP)"** in Preferences (Ctrl+P) to turn it on. A live status line right below the tick tells you what happened: found your router and opened the port (with your external address), found your router but the port couldn't be opened, no router found that supports the feature, or the router opened the port but you're behind a carrier-grade NAT (common on mobile broadband — peers won't reach you directly, use Tailscale or the relay instead). Works with UPnP, NAT-PMP and PCP — whichever your router speaks.
The ASIO driver itself is opened exactly as before; only the on-screen wait indicator was added, so this changes nothing about how RemSound talks to your audio hardware. * **Lock profile (read-only).** New tickable item in the File menu (Alt+F, L). When ticked, anything you change while RemSound is running stays in this session and is forgotten on close — your saved profile is left untouched, and there is **no save-changes prompt on exit**. Useful when you have a default profile you tweak constantly but don't want to commit those tweaks, and essential for unattended shutdowns where a save prompt could deadlock the close (screen reader gone, remote session dropped, machine hibernating). The lock state is saved on the profile itself, so it sticks across launches. Save As on a locked profile produces an unlocked copy. The window title and the startup profile picker both show "(read-only)" so you can tell at a glance.
* **Check for updates on startup.** New checkbox in Preferences, **on by default**. Shortly after RemSound launches it has a quiet look for a new release. Combined with "Silently install updates", this is "leave RemSound to keep itself up to date and never think about it".
* **Brief notice before a silent update installs.** When RemSound finds an update at startup and is set to install silently, it now shows a small window with the version it's about to install and an 8-second countdown. Press Enter (or wait) to install now, "Skip this version" to leave the update for another day, or "Postpone" to try again at the next check. Without this notice, the app would silently close on you a few seconds after launch and you'd have no idea why.
* **"Cue sounds" in Preferences is now labelled "Audio cue sounds"** for clarity.
## Bug fixes
* **No sound after the computer wakes from sleep.** On many setups (especially USB audio interfaces), waking the computer left RemSound's audio engine in a state where it looked like it was running but no sound actually came out — you'd have to quit and reopen RemSound. RemSound now notices when the system has woken up, waits a moment for USB devices to settle, and rebuilds its audio engine automatically. A brief "Reconnecting to audio driver, please wait..." window appears during the rebuild so you can see it's happening.
* **Receiver audio silent after waking from hibernate.** A follow-up to the wake-from-sleep fix above: on hibernate (rather than ordinary sleep), the ASIO receive output's tick selection could be silently wiped during hibernation entry, leaving the receiver running silent on resume even though everything looked normal in the logs. Fixed by recognising the transient driver-disappeared state at hibernation entry and preserving the user's tick until the driver comes back.
## Install ## Install
1. Download `RemSound-v2.0.zip` from this release. 1. Download `RemSound-v2.1.zip` from this release.
2. Close RemSound. 2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles or settings. (For a fresh install, just extract it anywhere you have write permission and run `RemSound.exe`.) 3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it will not touch your profiles, settings or recordings. (For a fresh install, just extract it anywhere you have write permission and run `RemSound.exe`.)
4. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. Press F1 for the user manual. 4. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. Press F1 for the user manual.
Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch. Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch.
## Upgrading ## Upgrading
**v1.9 users:** Help → Check for updates works — it will fetch and install v2.0 automatically. **v1.9, v2.0:** Help → Check for updates works — it will fetch and install v2.1 automatically. If you've ticked the new "Check for updates on startup" and "Silently install updates", v2.1 will install itself shortly after launch with a brief notice.
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v2.0 but not apply it. Install v2.0 by hand using the steps above — just this once. From the build you install onward, updates are automatic. **v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v2.1 but not apply it. Install v2.1 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
If you installed RemSound inside a synced folder (Dropbox etc.) and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps. If you installed RemSound inside a synced folder (Dropbox etc.) and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps.
+85 -7
View File
@@ -153,6 +153,35 @@ ul, ol { padding-left: 1.4em; }
<li><strong>Window position and size</strong> &mdash; Windows itself remembers these.</li> <li><strong>Window position and size</strong> &mdash; Windows itself remembers these.</li>
</ul> </ul>
<h3 id="profile-lock">Locking a profile (read-only)</h3>
<p>By default, RemSound treats your profile like a document: if you change something while it's running, you'll be asked &ldquo;save changes?&rdquo; when you exit. Most of the time that's exactly what you want &mdash; you don't lose work by accident.</p>
<p>But sometimes you want the opposite. You have a profile you live in every day, you toggle send or receive on or off during the day as a matter of course, and you don't want to be asked about saving every single time you close RemSound. You especially don't want to be asked if RemSound might close itself for some other reason (a Windows update, your screen reader crashing, a remote session dropping, a laptop going into hibernate) &mdash; because then there's a save prompt sitting on screen that nobody can dismiss, and the app can't actually close.</p>
<p><strong>Locking the profile</strong> solves this. When ticked:</p>
<ul>
<li>The profile loads normally and everything in the app works the same way it always did.</li>
<li>Anything you change during the session &mdash; ticking a device, sliding the volume, toggling send or receive, picking a peer &mdash; <strong>still works for that session</strong>. RemSound just doesn't write any of it back to the profile file on disk.</li>
<li>When you close RemSound, there is <strong>no save prompt</strong>. The app just closes. Whatever you changed during the session is forgotten; the next time you open the profile it's back to what it was when you locked it.</li>
<li>The window title shows &ldquo;(read-only)&rdquo; so you can always tell at a glance.</li>
<li>The startup profile picker also shows &ldquo;(read-only)&rdquo; next to locked profiles, so you know what you're picking before you hit Enter.</li>
<li>Pressing Save (Ctrl+S) shows a one-time message telling you the profile is locked and suggesting Save as&hellip; instead. There's a tick on the message to stop it showing again.</li>
</ul>
<p><strong>How to lock or unlock:</strong> open the File menu (Alt+F) and pick <strong>Lock profile (read-only)</strong> (Alt+F, L). It's a tickable menu item &mdash; pick it once to turn the lock on (a tick appears next to it); pick it again to turn the lock off (the tick disappears). The lock state is remembered with the profile, so closing and reopening RemSound keeps the profile locked exactly as you left it.</p>
<p><strong>If you decide to keep your changes:</strong> two ways:</p>
<ul>
<li>Unlock the profile first (untick the menu item), then press Save (Ctrl+S). Your changes are saved to the profile.</li>
<li>Or use <strong>Save as&hellip;</strong> directly. That always works, even on a locked profile, and the new copy starts out unlocked &mdash; so you can edit it normally afterwards.</li>
</ul>
<div class="note">
<strong>If a save prompt is blocking your shutdown right now:</strong> close it by pressing Esc (or click Cancel if you can see it), unlock by going File &rarr; Lock profile (read-only), then close RemSound. From this launch forward there'll be no prompt.
</div>
<h2 id="main-window">4. The main window: menu bar + three tabs</h2> <h2 id="main-window">4. The main window: menu bar + three tabs</h2>
<p>The main window has three parts, stacked top to bottom:</p> <p>The main window has three parts, stacked top to bottom:</p>
@@ -185,8 +214,9 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Save</strong></td><td>Ctrl+S</td><td>Updates the current profile with your current settings. If there's no current profile (you're on the blank template), this becomes Save as automatically.</td></tr> <tr><td><strong>Save</strong></td><td>Ctrl+S</td><td>Updates the current profile with your current settings. If there's no current profile (you're on the blank template), this becomes Save as automatically.</td></tr>
<tr><td><strong>Save as&hellip;</strong></td><td>Alt+F, A</td><td>Asks for a name and saves a copy. Use it to save your current setup under a new name, or to save for the first time from the blank template.</td></tr> <tr><td><strong>Save as&hellip;</strong></td><td>Alt+F, A</td><td>Asks for a name and saves a copy. Use it to save your current setup under a new name, or to save for the first time from the blank template.</td></tr>
<tr><td><strong>Rename current profile&hellip;</strong></td><td>Alt+F, M</td><td>Renames the current profile's file and updates the window title. Does nothing on the blank template (there's no profile to rename).</td></tr> <tr><td><strong>Rename current profile&hellip;</strong></td><td>Alt+F, M</td><td>Renames the current profile's file and updates the window title. Does nothing on the blank template (there's no profile to rename).</td></tr>
<tr><td><strong>Lock profile (read-only)</strong> (tickable)</td><td>Alt+F, L</td><td>When ticked, the current profile is loaded for use but RemSound will not save any of your changes back to it. The window title shows &ldquo;(read-only)&rdquo; so you can tell at a glance. Save (Ctrl+S) politely refuses with a hint to use Save as instead, and closing RemSound never asks &ldquo;save changes?&rdquo; &mdash; it just closes. Anything you've changed during the session is forgotten when RemSound closes; the file on disk is left exactly as it was. The lock setting is saved on the profile itself, so it sticks across launches. See <a href="#profile-lock">Locking a profile</a> for the full story.</td></tr>
<tr><td><strong>Minimise to tray</strong></td><td>Alt+F, N</td><td>Hides the window down to the system tray (the small icons near the clock). To bring the window back, click the tray icon or use the &ldquo;Show or hide window&rdquo; global hotkey (set in the Keyboard shortcuts dialog, default Ctrl+Shift+F10).</td></tr> <tr><td><strong>Minimise to tray</strong></td><td>Alt+F, N</td><td>Hides the window down to the system tray (the small icons near the clock). To bring the window back, click the tray icon or use the &ldquo;Show or hide window&rdquo; global hotkey (set in the Keyboard shortcuts dialog, default Ctrl+Shift+F10).</td></tr>
<tr><td><strong>Exit</strong></td><td>Alt+F, X (or Alt+F4)</td><td>Closes RemSound. If you have unsaved profile changes, it asks you first.</td></tr> <tr><td><strong>Exit</strong></td><td>Alt+F, X (or Alt+F4)</td><td>Closes RemSound. If you have unsaved profile changes (and the profile isn't locked), it asks you first.</td></tr>
</table> </table>
<h3>Record menu</h3> <h3>Record menu</h3>
@@ -209,7 +239,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Recording settings&hellip;</strong></td><td>Alt+O, S</td><td>Opens the Recording settings dialog. Up to five lists: <em>Recording source</em> (Alt+S), <em>File format</em> (Alt+F), <em>Audio format attributes</em> (Alt+A), <em>FLAC compression level</em> (Alt+L &mdash; only shown when FLAC is chosen), and <em>Channels</em> (Alt+C). The attributes list changes to match the format you pick. OK saves to the current profile; Cancel discards.</td></tr> <tr><td><strong>Recording settings&hellip;</strong></td><td>Alt+O, S</td><td>Opens the Recording settings dialog. Up to five lists: <em>Recording source</em> (Alt+S), <em>File format</em> (Alt+F), <em>Audio format attributes</em> (Alt+A), <em>FLAC compression level</em> (Alt+L &mdash; only shown when FLAC is chosen), and <em>Channels</em> (Alt+C). The attributes list changes to match the format you pick. OK saves to the current profile; Cancel discards.</td></tr>
<tr><td><strong>Keyboard shortcuts&hellip;</strong></td><td>Ctrl+K, or Alt+O, K</td><td>Opens the global hotkey dialog (mute, volume, show/hide window, start/stop recording, remote-control commands).</td></tr> <tr><td><strong>Keyboard shortcuts&hellip;</strong></td><td>Ctrl+K, or Alt+O, K</td><td>Opens the global hotkey dialog (mute, volume, show/hide window, start/stop recording, remote-control commands).</td></tr>
<tr><td><strong>Startup behaviour&hellip;</strong></td><td>Alt+O, T</td><td>Opens the Startup behaviour dialog. Choose whether to launch automatically with Windows, which profile to load by default, and whether to start hidden in the tray.</td></tr> <tr><td><strong>Startup behaviour&hellip;</strong></td><td>Alt+O, T</td><td>Opens the Startup behaviour dialog. Choose whether to launch automatically with Windows, which profile to load by default, and whether to start hidden in the tray.</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog. This holds the settings that stay the same on this computer no matter which profile is loaded: the profiles folder, the <strong>Cue sounds (Alt+N)</strong> checklist (one tickable item per cue: connect, disconnect, recording start, recording stop), whether to accept remote volume commands, how often to check for updates, a manual check-for-updates button, whether to install updates quietly, whether to keep logs, and a button to write logs now. Esc or the Close button dismisses it.</td></tr> <tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog. This holds the settings that stay the same on this computer no matter which profile is loaded: the profiles folder, the <strong>Audio cue sounds (Alt+N)</strong> checklist (one tickable item per cue: connect, disconnect, recording start, recording stop), whether to accept remote volume commands, whether to check for updates on startup, how often to check for updates after that, a manual check-for-updates button, whether to install updates quietly, whether to ask your router to open the audio port for you (UPnP), whether to keep logs, and a button to write logs now. Esc or the Close button dismisses it.</td></tr>
</table> </table>
<h3>Help menu</h3> <h3>Help menu</h3>
@@ -398,7 +428,7 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>Even if a peer is sending sound your way, you won't hear it until you've ticked their checkbox. This is deliberate &mdash; connecting is a step where you give your consent. A peer's name appears in Discovered the moment they come online, but they can't make any sound on your speakers until you say yes.</p> <p>Even if a peer is sending sound your way, you won't hear it until you've ticked their checkbox. This is deliberate &mdash; connecting is a step where you give your consent. A peer's name appears in Discovered the moment they come online, but they can't make any sound on your speakers until you say yes.</p>
<h3>Connection health</h3> <h3>Connection health</h3>
<p>For each connected peer, the status read-out at the bottom of the window shows a small health note: the latest round-trip time in milliseconds, or <strong>pending</strong>, <strong>stale</strong> or <strong>unreachable</strong> if the regular check-in messages have stopped. (Round-trip time is how long sound takes to travel to the other computer and back.) RemSound plays a connect cue (a short sound) when a peer becomes healthy and a disconnect cue when one becomes unreachable. You can silence both cues using <strong>Cue sounds</strong> in the Preferences dialog (Options &rarr; Preferences, or Ctrl+P).</p> <p>For each connected peer, the status read-out at the bottom of the window shows a small health note: the latest round-trip time in milliseconds, or <strong>pending</strong>, <strong>stale</strong> or <strong>unreachable</strong> if the regular check-in messages have stopped. (Round-trip time is how long sound takes to travel to the other computer and back.) RemSound plays a connect cue (a short sound) when a peer becomes healthy and a disconnect cue when one becomes unreachable. You can silence both cues using <strong>Audio cue sounds</strong> in the Preferences dialog (Options &rarr; Preferences, or Ctrl+P).</p>
<h2 id="network">11. How the network works (LAN, WAN, Tailscale)</h2> <h2 id="network">11. How the network works (LAN, WAN, Tailscale)</h2>
@@ -425,9 +455,29 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>Connecting two computers directly across the internet needs one of these:</p> <p>Connecting two computers directly across the internet needs one of these:</p>
<ul> <ul>
<li>A VPN that puts both computers on the same private network &mdash; <strong>Tailscale</strong> is the one we recommend. (A VPN is a service that creates a private network linking your computers wherever they are.) Each computer gets a Tailscale address (it looks like <code>100.something</code>) and they can reach each other directly.</li> <li>A VPN that puts both computers on the same private network &mdash; <strong>Tailscale</strong> is the one we recommend. (A VPN is a service that creates a private network linking your computers wherever they are.) Each computer gets a Tailscale address (it looks like <code>100.something</code>) and they can reach each other directly.</li>
<li>Or, port forwarding on each end's router (this is more involved and isn't covered here).</li> <li>Or, let RemSound ask your router to open the audio port for you automatically &mdash; see <a href="#upnp">Automatic router port opening (UPnP)</a> below. Off by default; one tick to turn it on.</li>
<li>Or, port forwarding on each end's router by hand (this is more involved and isn't covered here).</li>
</ul> </ul>
<h3 id="upnp">Automatic router port opening (UPnP)</h3>
<p>Most home routers support a feature called UPnP (or its newer cousins NAT-PMP and PCP) which lets an app politely ask the router to open a port so the outside world can reach it. RemSound can use this so two computers can find each other across the internet without you having to log into the router and set up port forwarding by hand.</p>
<p><strong>How to turn it on.</strong> Open <strong>Options &rarr; Preferences</strong> (Ctrl+P) and tick <strong>Automatically open my router for incoming connections (UPnP)</strong> (Alt+O). Off by default &mdash; we don't want to poke your router without permission. As soon as you tick the box, a status line appears just below it telling you what happened:</p>
<table>
<tr><th>Status line says&hellip;</th><th>What it means</th><th>What to do</th></tr>
<tr><td>&ldquo;Searching for a router that supports UPnP / NAT-PMP / PCP&hellip;&rdquo;</td><td>RemSound is asking around on your network for a router that speaks one of these languages. Usually finishes within a few seconds.</td><td>Wait a moment.</td></tr>
<tr><td>&ldquo;Router port opened. Peers can reach you at X.X.X.X:47830.&rdquo;</td><td>Your router has agreed to forward incoming audio to this computer. Tell the peer at the other end that address and they can connect using <em>Add peer by IP</em>.</td><td>Pass that address (the part before the colon) to whoever you want to connect to.</td></tr>
<tr><td>&ldquo;No router with UPnP / NAT-PMP / PCP found.&rdquo;</td><td>Either your router doesn't support it, the feature is turned off in the router's settings, or something on your network is blocking it.</td><td>Try turning UPnP on in your router's settings page (look for &ldquo;UPnP&rdquo; or &ldquo;NAT-PMP&rdquo;), or use Tailscale instead.</td></tr>
<tr><td>&ldquo;The router opened the port, but the external address is on a carrier-grade NAT.&rdquo;</td><td>Your router did its part, but your internet provider has put you behind a second layer of NAT (a sort of giant shared router) and there's nothing your home router can do about that. This is common on mobile broadband and on some cable connections.</td><td>Use Tailscale or the relay server instead &mdash; both work fine through carrier-grade NAT.</td></tr>
<tr><td>&ldquo;The router rejected the port-mapping request.&rdquo;</td><td>The router found the request but said no &mdash; usually because another device on your network already has the same port forwarded, or because the router has UPnP set to a restrictive mode.</td><td>Check your router's UPnP settings, or fall back to manual port forwarding or Tailscale.</td></tr>
</table>
<p><strong>Across sleep and reboots.</strong> If your computer goes to sleep, RemSound asks the router to reopen the port automatically when it wakes up &mdash; some routers drop their port-forwarding list during long idle periods. Closing RemSound politely tells the router to forget the forwarding rule, so the port doesn't stay open after you're done.</p>
<p><strong>Why this is off by default.</strong> Some networks &mdash; corporate offices, shared accommodation, hotel Wi-Fi &mdash; really don't want apps asking the router to open ports for them, either because there's a security policy or because the router is locked down. Off by default means RemSound never touches your router unless you explicitly tick the box.</p>
<h3>Finding peers on Tailscale and other VPNs</h3> <h3>Finding peers on Tailscale and other VPNs</h3>
<p>The ordinary &ldquo;I'm here&rdquo; announcements that work on a home network don't travel across a VPN. RemSound works around this by also sending announcements directly to every address in your Remembered peers list. So:</p> <p>The ordinary &ldquo;I'm here&rdquo; announcements that work on a home network don't travel across a VPN. RemSound works around this by also sending announcements directly to every address in your Remembered peers list. So:</p>
<ol> <ol>
@@ -742,11 +792,24 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<table> <table>
<tr><th>Setting</th><th>Shortcut</th><th>What it does</th></tr> <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 checks for a newer version in the background. Choices: <em>Never</em>, <em>Every hour</em>, <em>Every 6 hours</em>, <em>Every 24 hours</em>. The default is <em>Every 24 hours</em>. Your choice is remembered between launches; if you set it to <em>Never</em>, the only way an update arrives is through the manual button below.</td></tr> <tr><td><strong>Check for updates on startup</strong> (checkbox)</td><td>Alt+S</td><td>When ticked, RemSound has a quiet look for a newer version a few seconds after each launch. On by default. Combined with <em>Silently install updates</em> below, this means leaving RemSound to keep itself up to date without you ever needing to press anything. Untick if you'd rather only ever check on a timer or by pressing the manual button.</td></tr>
<tr><td><strong>Then check every</strong> (drop-down)</td><td>Alt+U</td><td>How often RemSound checks for a newer version in the background <em>after</em> launch. Choices: <em>Never</em>, <em>Every hour</em>, <em>Every 6 hours</em>, <em>Every 24 hours</em>. The default is <em>Every 24 hours</em>. Your choice is remembered between launches; if you set it to <em>Never</em> and you've also unticked the startup check, the only way an update arrives is through the manual button below.</td></tr>
<tr><td><strong>Check for updates now</strong> (button)</td><td>Alt+N</td><td>Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, 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>Check for updates now</strong> (button)</td><td>Alt+N</td><td>Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, 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 check installs any available update without asking &mdash; RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The <em>manual</em> &ldquo;Check for updates now&rdquo; button always asks first, no matter how this checkbox is set.</td></tr> <tr><td><strong>Silently install updates when available</strong> (checkbox)</td><td>Alt+I</td><td>When ticked, the background and startup checks install any available update without asking &mdash; RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The <em>manual</em> &ldquo;Check for updates now&rdquo; button always asks first, no matter how this checkbox is set.</td></tr>
</table> </table>
<h3>The brief notice before a silent update installs</h3>
<p>If RemSound finds an update right after launch and is set to install silently, it now shows a small window so you're not surprised when the app closes a few seconds in. The window says &ldquo;RemSound vX.X is ready to install&rdquo; with three buttons:</p>
<ul>
<li><strong>Install now</strong> &mdash; installs straight away. This is the default; press Enter or wait through the countdown to pick it.</li>
<li><strong>Skip this version</strong> &mdash; leaves the update alone for this launch. (RemSound may offer it again next time it checks.)</li>
<li><strong>Postpone</strong> &mdash; close the notice without installing now. The next scheduled background check will pick it up again.</li>
</ul>
<p>A short countdown picks <em>Install now</em> automatically if you don't choose anything &mdash; long enough to read the version number, short enough that walking away from your desk doesn't block the silent update. Esc has the same effect as Postpone.</p>
<h3>What happens during an install</h3> <h3>What happens during an install</h3>
<p>RemSound can't replace its own program file while it's running, so an install starts a small helper that finishes the job once RemSound has closed:</p> <p>RemSound can't replace its own program file while it's running, so an install starts a small helper that finishes the job once RemSound has closed:</p>
@@ -805,7 +868,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<p>RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. The cues are <code>record start.wav</code> and <code>record stop.wav</code> in the RemSound install folder &mdash; you can drop in your own sounds to customise them, as long as you keep the same filenames.</p> <p>RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. The cues are <code>record start.wav</code> and <code>record stop.wav</code> in the RemSound install folder &mdash; you can drop in your own sounds to customise them, as long as you keep the same filenames.</p>
<p>Each cue can be turned off on its own via <strong>Preferences &rarr; Cue sounds (Alt+N)</strong>. The same checklist also controls the connect and disconnect cues. The cues play through your default Windows output device, separately from the recording, so a normal RemSound recording doesn't contain the cue. The one exception: if your sending side is capturing the very same output device the cue plays through, then the cue gets captured along with everything else from that device. In that case you'll have to live with the start cue appearing in the file, unless you turn it off.</p> <p>Each cue can be turned off on its own via <strong>Preferences &rarr; Audio cue sounds (Alt+N)</strong>. The same checklist also controls the connect and disconnect cues. The cues play through your default Windows output device, separately from the recording, so a normal RemSound recording doesn't contain the cue. The one exception: if your sending side is capturing the very same output device the cue plays through, then the cue gets captured along with everything else from that device. In that case you'll have to live with the start cue appearing in the file, unless you turn it off.</p>
<h3>Where recordings go</h3> <h3>Where recordings go</h3>
@@ -923,6 +986,18 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<h3>The device list shows old devices that are no longer plugged in</h3> <h3>The device list shows old devices that are no longer plugged in</h3>
<p>RemSound refreshes its lists every second. If a device has really been unplugged it should disappear within a few seconds. If it lingers, restart RemSound &mdash; Windows' own device list occasionally needs a nudge.</p> <p>RemSound refreshes its lists every second. If a device has really been unplugged it should disappear within a few seconds. If it lingers, restart RemSound &mdash; Windows' own device list occasionally needs a nudge.</p>
<h3>No sound after the computer wakes from sleep</h3>
<p>RemSound notices when the computer has just woken up, waits a moment for any USB sound devices to come back to life, and rebuilds its audio engine from scratch &mdash; you'll briefly see a small &ldquo;Reconnecting to audio driver&rdquo; window during the rebuild, then sound should resume on its own. If sound still doesn't come back, click on the ASIO driver picker on the Audio inputs and outputs tab and re-pick the same driver (or pick <em>(none)</em> and then re-pick your driver). That triggers the same full rebuild manually. As a last resort, quit and reopen RemSound.</p>
<h3>UPnP says &ldquo;no router found&rdquo; even though my router supports it</h3>
<p>The most common reasons:</p>
<ul>
<li>UPnP is disabled in your router's settings. Look for a checkbox marked &ldquo;UPnP&rdquo;, &ldquo;NAT-PMP&rdquo;, or &ldquo;Allow apps to automatically forward ports&rdquo; in the router's admin page. It's often off by default.</li>
<li>Your Windows network is set to &ldquo;Public&rdquo; rather than &ldquo;Private&rdquo;. Public mode blocks the discovery messages RemSound uses to find the router. In Windows' network settings, switch your home network to Private.</li>
<li>Your computer is on a Wi-Fi guest network or a corporate / hotel network. These networks usually block the kind of discovery messages UPnP needs.</li>
</ul>
<p>If none of those apply, just fall back to Tailscale &mdash; it works without involving the router at all.</p>
<h2 id="glossary">21. Glossary</h2> <h2 id="glossary">21. Glossary</h2>
<table> <table>
@@ -941,6 +1016,9 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<tr><td>Heartbeat</td><td>A small message that connected computers exchange every second to confirm they can still reach each other and to measure the round-trip time. It travels on the audio channel (47830 by default).</td></tr> <tr><td>Heartbeat</td><td>A small message that connected computers exchange every second to confirm they can still reach each other and to measure the round-trip time. It travels on the audio channel (47830 by default).</td></tr>
<tr><td>Discovery</td><td>The way RemSound computers find each other on the network without you having to know each other's addresses up front.</td></tr> <tr><td>Discovery</td><td>The way RemSound computers find each other on the network without you having to know each other's addresses up front.</td></tr>
<tr><td>Tailscale</td><td>An easy-to-use VPN that puts your computers on a private network together. The simplest way to connect RemSound across the internet without changing your router settings.</td></tr> <tr><td>Tailscale</td><td>An easy-to-use VPN that puts your computers on a private network together. The simplest way to connect RemSound across the internet without changing your router settings.</td></tr>
<tr><td>UPnP</td><td>Short for &ldquo;Universal Plug and Play&rdquo;. A feature most home routers support that lets an app politely ask the router to open a port for incoming connections, without the user having to log into the router. RemSound uses UPnP (and its newer relatives NAT-PMP and PCP) to set up port forwarding automatically when you tick &ldquo;Automatically open my router for incoming connections&rdquo; in Preferences. Off by default.</td></tr>
<tr><td>NAT</td><td>Short for &ldquo;Network Address Translation&rdquo;. The way your router lets several computers share a single internet connection &mdash; one public address on the outside, lots of private addresses on the inside. Most home networks use NAT, which is why you usually need port forwarding (or UPnP, or a VPN) for two computers in different places to reach each other directly.</td></tr>
<tr><td>Carrier-grade NAT</td><td>An extra layer of NAT that some internet providers (especially on mobile broadband and some cable connections) put in between your router and the rest of the internet. Your home router opens a port fine, but the provider's NAT in front of it still blocks incoming connections. RemSound's UPnP status line warns you when this is the case &mdash; the way through it is a VPN like Tailscale, or the relay server.</td></tr>
<tr><td>Auto-tune</td><td>RemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with the Continuous auto-tune checkbox on the Audio profile tab.</td></tr> <tr><td>Auto-tune</td><td>RemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with 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 choice &mdash; device ticks, send / receive states, codec, latency, peers, hotkeys, ASIO driver choice, the lot. Stored as one settings file. You pick one at startup, and can switch with File &rarr; Open profile.</td></tr> <tr><td>Profile</td><td>A saved snapshot of every RemSound setting and choice &mdash; device ticks, send / receive states, codec, latency, peers, hotkeys, ASIO driver choice, the lot. Stored as one settings file. You pick one at startup, and can switch with File &rarr; Open profile.</td></tr>
<tr><td>Blank template</td><td>An entry in the startup profile picker that begins a session with all the defaults &mdash; nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save.</td></tr> <tr><td>Blank template</td><td>An entry in the startup profile picker that begins a session with all the defaults &mdash; nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save.</td></tr>
+81
View File
@@ -20,6 +20,87 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary> /// updates" path.</summary>
private const string ReleaseNotes = private const string ReleaseNotes =
""" """
RemSound v2.1
Automatic router setup for internet streaming, a small
notice before background updates install, a "lock this
profile" option for users who don't want close prompts,
and a fix for the "no sound after the laptop wakes up"
problem. No wire-format or audio-pipeline changes
v1.5 through v2.1 peers interoperate.
What's new:
* Automatic router port opening (UPnP). RemSound can
now ask your router to let peers on the internet
reach you, so you don't have to set up port
forwarding by hand. Off by default. Tick the new
"Automatically open my router for incoming
connections (UPnP)" box in Preferences to turn it
on. A status line right below the tick tells you
what happened found your router and opened the
port, found your router but the port couldn't be
opened, or no router found that supports this
feature. If your internet provider puts you behind
a second layer of NAT (common on mobile broadband
and some cable connections), the status line will
say so and suggest using Tailscale or the relay
instead.
* Check for updates on startup. New checkbox in
Preferences, on by default. Shortly after RemSound
launches it has a quiet look for a new release.
Combined with "Silently install updates", this
means leaving RemSound to keep itself up to date
without you ever needing to think about it.
* Brief notice before a silent update installs. When
RemSound finds an update at startup and is set to
install silently, it now shows a small window with
the version it's about to install and an 8-second
countdown. Press Enter (or wait) to install now,
"Skip this version" to leave the update for
another day, or "Postpone" to try again at the
next check. Without this notice, the app could
silently close on you a few seconds after launch
and you'd have no idea why.
* Lock profile (read-only). New tickable item in the
File menu (Alt+F, L). When ticked, anything you
change while RemSound is running stays in this
session and is forgotten on close your saved
profile is left untouched, and there's NO "save
changes?" prompt on exit. Useful when you have a
default profile you want to keep clean even if you
toggle send/receive or volume during the day, and
essential if a save prompt could block shutdown
when you can't reach it (screen reader gone,
remote session dropped, machine hibernating).
Saved per profile, off by default, toggle as often
as you like. Save As on a locked profile produces
an unlocked copy you can edit normally.
* "Cue sounds" in Preferences is now labelled "Audio
cue sounds" for clarity.
Bug fixes:
* No sound after the computer wakes from sleep. On
many setups (especially USB audio interfaces),
waking the computer left RemSound's audio engine
in a state where it looked like it was running but
no sound actually came out you'd have to quit
and reopen RemSound to get audio back. RemSound
now notices when the system has woken up, waits a
moment for the USB devices to settle, and rebuilds
its audio engine automatically. The "Loading audio
driver" window briefly appears during the rebuild
so you can see it's happening.
* Receiver audio silent after waking from hibernate.
A follow-up to the wake-from-sleep fix above: on
hibernate (rather than ordinary sleep) the ASIO
receive output's tick selection could be silently
wiped during hibernation entry, leaving the
receiver running silent on resume even though
everything looked normal in the logs. Fixed by
recognising the transient driver-disappeared state
and preserving the user's tick until the driver
comes back.
RemSound v2.0 RemSound v2.0
A smoother startup when a profile uses an ASIO driver. A smoother startup when a profile uses an ASIO driver.
+20 -7
View File
@@ -27,16 +27,21 @@ namespace RemSound.App;
/// </summary> /// </summary>
internal sealed class AsioLoadingSplash internal sealed class AsioLoadingSplash
{ {
/// <summary>Default message used by <see cref="StartIfNeeded"/> on first launch.</summary>
public const string DefaultMessage = "Loading audio driver, please wait...";
private readonly Thread thread; private readonly Thread thread;
private readonly ManualResetEventSlim shown = new(false); private readonly ManualResetEventSlim shown = new(false);
private readonly string message;
private volatile Form? form; private volatile Form? form;
private AsioLoadingSplash() private AsioLoadingSplash(string message)
{ {
this.message = message;
thread = new Thread(RunSplash) thread = new Thread(RunSplash)
{ {
IsBackground = true, IsBackground = true,
Name = "RemSound startup splash", Name = "RemSound audio-driver splash",
}; };
thread.SetApartmentState(ApartmentState.STA); thread.SetApartmentState(ApartmentState.STA);
thread.Start(); thread.Start();
@@ -51,12 +56,20 @@ internal sealed class AsioLoadingSplash
/// the only case where MainForm construction is slow. Returns null for WASAPI-only /// the only case where MainForm construction is slow. Returns null for WASAPI-only
/// profiles. Dismiss the returned handle once the main window has been built. /// profiles. Dismiss the returned handle once the main window has been built.
/// </summary> /// </summary>
public static AsioLoadingSplash? StartIfNeeded(Profile? profile) public static AsioLoadingSplash? StartIfNeeded(Profile? profile) =>
StartIfAsioDriverName(profile?.AsioDriverName, DefaultMessage);
/// <summary>
/// Generic version of <see cref="StartIfNeeded"/>: starts the splash when an ASIO driver
/// name is configured, with a caller-supplied message. Used by the system-resume handler
/// to show "Reconnecting to audio driver…" while the audio backend is rebuilt after wake.
/// </summary>
public static AsioLoadingSplash? StartIfAsioDriverName(string? asioDriverName, string? message = null)
{ {
if (string.IsNullOrWhiteSpace(profile?.AsioDriverName)) return null; if (string.IsNullOrWhiteSpace(asioDriverName)) return null;
try try
{ {
return new AsioLoadingSplash(); return new AsioLoadingSplash(message ?? DefaultMessage);
} }
catch catch
{ {
@@ -87,8 +100,8 @@ internal sealed class AsioLoadingSplash
{ {
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter, TextAlign = ContentAlignment.MiddleCenter,
Text = "Loading audio driver, please wait...", Text = message,
AccessibleName = "Loading audio driver, please wait", AccessibleName = message.TrimEnd('.', ' '),
}); });
splash.Shown += (_, _) => shown.Set(); splash.Shown += (_, _) => shown.Set();
form = splash; form = splash;
+557 -21
View File
@@ -38,6 +38,17 @@ public sealed class MainForm : Form
private readonly MainFormHotkeyController hotkeyController; private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController; private readonly MainFormTrayController trayController;
private readonly RecordingController recordingController; private readonly RecordingController recordingController;
// Hook into Windows sleep/resume so the audio backend gets rebuilt after wake. USB
// audio devices (ASIO / WASAPI) commonly come back in a degraded post-resume state
// where the pipeline runs but no sound actually comes out of the interface — restarting
// the backend on resume clears it. Subscribed in the constructor, disposed in FormClosing.
private readonly PowerResumeHandler powerResumeHandler;
// Optional UPnP / NAT-PMP / PCP router-port opener (Mono.Nat under the hood). Off by
// default; the user opts in via the "Automatically open my router for incoming
// connections" tick in Preferences (AppConfig.UpnpEnabled). Started lazily in Shown
// when the flag is on, restarted from OnSystemResume so a sleep-drop on the router's
// NAT table is recovered automatically, and stopped in FormClosing.
private readonly RouterPortMapper routerPortMapper;
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip // Menu items for the Record menu kept as fields so RecordingStateChanged can flip
// the visible text + accessibility name between "Start recording" and "Stop recording" // the visible text + accessibility name between "Start recording" and "Stop recording"
// without rebuilding the menu. // without rebuilding the menu.
@@ -393,6 +404,14 @@ public sealed class MainForm : Form
// itself doesn't create steps but is a signal that the input is hot enough that something // itself doesn't create steps but is a signal that the input is hot enough that something
// could be saturating. // could be saturating.
private long prevDiagClippedSamples; private long prevDiagClippedSamples;
// Per-second GC delta. .NET tracks cumulative collection counts per generation; we
// remember the previous tick's values and emit gen-0 / gen-1 / gen-2 deltas in the diag
// log so a click-event correlation analysis can spot when a GC pause coincided with a
// receive-side arrival-gap spike. Gen-2 in particular implies a multi-millisecond stall
// that's a plausible click source. 2026-05-21.
private int prevDiagGc0Count;
private int prevDiagGc1Count;
private int prevDiagGc2Count;
// Profile system (2026-05-02). The active profile (if any) was selected at app start and // Profile system (2026-05-02). The active profile (if any) was selected at app start and
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile // populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
@@ -403,6 +422,27 @@ public sealed class MainForm : Form
// re-launch the form under that profile." // re-launch the form under that profile."
private ProfileStore? profileStore; private ProfileStore? profileStore;
private string? currentProfileTitle; private string? currentProfileTitle;
// True when the active profile has its ReadOnly flag set. Drives three behaviours:
// * The window title gets a " (read-only)" suffix so NVDA / sighted users see
// immediately that changes won't persist.
// * Ctrl+S / File → Save politely refuses (with a "use Save As instead" message).
// * OnFormClosing skips the unsaved-changes prompt entirely — that's the whole
// point of read-only mode, so a profile you live in and toggle send/receive
// on doesn't block shutdown with a dialog you can't reach (NVDA crashed, remote
// session dropped, machine hibernating).
// 2026-05-22 — Andre's request: he toggles send/receive on his default profile and
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
private bool currentProfileReadOnly;
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
// itself on every click; the CheckedChanged handler reads the new value and runs
// OnLockProfileToggled.
private ToolStripMenuItem? lockProfileMenuItem;
// Guards CheckedChanged on lockProfileMenuItem against the programmatic sync that
// happens on profile-load — without it, loading a profile that's read-only would
// re-fire the toggle handler and re-persist the flag pointlessly.
private bool suppressLockProfileToggleHandler;
/// <summary>Full filesystem path of the active profile's JSON file. Tracked separately /// <summary>Full filesystem path of the active profile's JSON file. Tracked separately
/// from <see cref="currentProfileTitle"/> because Save As (2026-05-10) lets the user /// from <see cref="currentProfileTitle"/> because Save As (2026-05-10) lets the user
/// write a profile to an arbitrary path outside <see cref="ProfileStore.BaseDirectory"/>. /// write a profile to an arbitrary path outside <see cref="ProfileStore.BaseDirectory"/>.
@@ -469,6 +509,10 @@ public sealed class MainForm : Form
catch { /* benign — recents tracking is a convenience, not load-critical */ } catch { /* benign — recents tracking is a convenience, not load-critical */ }
} }
pendingProfile = profile; pendingProfile = profile;
// Carry the profile's ReadOnly flag through to the in-memory tracking field. Blank
// template (profile == null) implicitly starts as not-read-only; users still have
// the menu toggle available if they want to lock the working state mid-session.
currentProfileReadOnly = profile?.ReadOnly ?? false;
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into // Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
// the in-memory settings cache BEFORE the rest of the constructor body reads from it. // the in-memory settings cache BEFORE the rest of the constructor body reads from it.
// Control states (device ticks, checkboxes, volume) come later in OnShown. // Control states (device ticks, checkboxes, volume) come later in OnShown.
@@ -901,6 +945,17 @@ public sealed class MainForm : Form
PushDiscoveryUnicastHints(); PushDiscoveryUnicastHints();
hotkeyController.Initialize(this); hotkeyController.Initialize(this);
// Hook system sleep/resume so we can rebuild the audio backend after wake (USB
// audio devices often come back wedged). The handler routes back through
// OnSystemResume on a background thread; that marshals to the UI thread.
powerResumeHandler = new PowerResumeHandler(OnSystemResume, msg => logFile.Event($"power: {msg}"));
// Build the UPnP router-port opener up-front but don't start it — Shown decides
// whether to invoke Start() based on AppConfig.UpnpEnabled. Constructing the field
// here (rather than lazily on tick) keeps the field non-null so the Preferences
// dialog can subscribe to StatusChanged without us juggling instance lifetimes.
routerPortMapper = new RouterPortMapper(msg => logFile.Event($"upnp: {msg}"));
FormClosing += (_, _) => FormClosing += (_, _) =>
{ {
statusTimer.Stop(); statusTimer.Stop();
@@ -908,6 +963,8 @@ public sealed class MainForm : Form
continuousTuneTimer.Stop(); continuousTuneTimer.Stop();
updateCheckTimer.Stop(); updateCheckTimer.Stop();
asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Stop();
try { powerResumeHandler?.Dispose(); } catch { }
try { routerPortMapper?.Dispose(); } catch { }
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean // Reverse every Win32 lever PerformanceMode applied. The kernel would clean
// these up on process exit anyway, but doing it explicitly releases the power // these up on process exit anyway, but doing it explicitly releases the power
// request handle and matches our timeBeginPeriod with a timeEndPeriod. // request handle and matches our timeBeginPeriod with a timeEndPeriod.
@@ -971,6 +1028,41 @@ public sealed class MainForm : Form
{ {
BeginInvoke(() => trayController.Minimize()); BeginInvoke(() => trayController.Minimize());
} }
// Kick off UPnP discovery if the user has the box ticked. Off by default; the
// mapper itself coalesces redundant Start() calls so a re-enter via Shown after
// a sleep cycle is harmless.
var startupCfg = AppConfig.Load();
if (startupCfg.UpnpEnabled)
{
try { routerPortMapper.Start(); }
catch (Exception ex) { logFile.Event($"upnp: start failed: {ex.GetType().Name}: {ex.Message}"); }
}
// Startup update check — separate from the periodic timer because users who
// launch RemSound, find an update, and stay running for less than the timer
// interval would otherwise miss the release entirely. Default on. The
// background-poll path handles both silent install and the user-prompt flow.
if (startupCfg.CheckForUpdatesOnStartup)
{
// Defer a few seconds so the network stack, audio engine, and any device
// hot-swap has settled before we touch GitHub. The visible cue (silent-
// install notice dialog) appears inside the check path, so a small delay
// is invisible to the user.
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(4)).ConfigureAwait(false);
if (IsDisposed) return;
BeginInvoke(new Action(CheckForUpdatesOnStartup));
}
catch (Exception ex)
{
logFile.Event($"updater: startup check scheduling failed: {ex.GetType().Name}: {ex.Message}");
}
});
}
}; };
statusTimer.Start(); statusTimer.Start();
@@ -1090,6 +1182,30 @@ public sealed class MainForm : Form
}; };
renameItem.Click += (_, _) => RenameCurrentProfile(); renameItem.Click += (_, _) => RenameCurrentProfile();
// Lock profile (read-only). When checked, the active profile is loaded for use but
// never written back: Save / Ctrl+S politely refuses (with a "use Save As" message)
// and FormClosing skips the unsaved-changes prompt entirely. Andre's request — he
// toggles send/receive on his default profile and doesn't want a save prompt
// blocking shutdown when his screen reader can't reach it. Off by default; the
// flag is per-profile (stored in the profile JSON) so different profiles can
// independently choose lock vs editable.
//
// CheckOnClick = true makes WinForms flip the .Checked state on every click and
// NVDA reads "Lock profile read-only, checked / not checked". The mnemonic Alt+F, L
// doesn't collide with any existing File-menu letter (O / R / S / A / M / N / X
// are in use).
lockProfileMenuItem = new ToolStripMenuItem("&Lock profile (read-only)")
{
AccessibleName = "Lock profile read-only",
CheckOnClick = true,
Checked = currentProfileReadOnly,
};
lockProfileMenuItem.CheckedChanged += (_, _) =>
{
if (suppressLockProfileToggleHandler) return;
OnLockProfileToggled(lockProfileMenuItem.Checked);
};
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray") var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
{ {
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now — // No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
@@ -1115,6 +1231,7 @@ public sealed class MainForm : Form
saveItem, saveItem,
saveAsItem, saveAsItem,
renameItem, renameItem,
lockProfileMenuItem,
new ToolStripSeparator(), new ToolStripSeparator(),
minimiseItem, minimiseItem,
new ToolStripSeparator(), new ToolStripSeparator(),
@@ -1441,13 +1558,53 @@ public sealed class MainForm : Form
} }
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite /// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
/// it; if we're on the blank template (no current profile), fall through to Save as.</summary> /// it; if we're on the blank template (no current profile), fall through to Save as.
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
/// a one-time message explaining why nothing happened. The message is suppressible
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
private void SaveOrSaveAs() private void SaveOrSaveAs()
{ {
if (currentProfileReadOnly)
{
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
{
ShowSaveBlockedByReadOnlyDialog();
}
return;
}
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs(); if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
else UpdateExistingProfile(); else UpdateExistingProfile();
} }
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
private void ShowSaveBlockedByReadOnlyDialog()
{
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
var page = new TaskDialogPage
{
Caption = AppName,
Heading = "This profile is read-only",
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
Icon = TaskDialogIcon.Information,
Verification = verification,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(this, page);
if (verification.Checked)
{
var cfg = AppConfig.Load();
cfg.SaveOnReadOnlyMessageSuppressed = true;
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
}
}
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank /// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
/// template (nothing to rename). Renames update window title + active-profile state /// template (nothing to rename). Renames update window title + active-profile state
/// in place — no reload required.</summary> /// in place — no reload required.</summary>
@@ -1550,7 +1707,26 @@ public sealed class MainForm : Form
}, },
writeLogsNow: () => logFile.Event("user requested write logs now"), writeLogsNow: () => logFile.Event("user requested write logs now"),
checkForUpdatesNow: () => CheckForUpdatesManually(), checkForUpdatesNow: () => CheckForUpdatesManually(),
onUpdateFrequencyChanged: ApplyUpdateCheckTimer); onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
applyUpnpEnabled: enabled =>
{
// The persist already happened in the dialog; this callback only flips the
// live RouterPortMapper. Start kicks off discovery; Stop politely removes any
// existing mapping.
if (enabled)
{
try { routerPortMapper.Start(); }
catch (Exception ex) { logFile.Event($"upnp: start from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
}
else
{
try { routerPortMapper.Stop(); }
catch (Exception ex) { logFile.Event($"upnp: stop from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
}
},
getUpnpSnapshot: () => (routerPortMapper.Status, routerPortMapper.ExternalEndpoint, routerPortMapper.LastError),
subscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged += handler,
unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler);
dialog.ShowDialog(this); dialog.ShowDialog(this);
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty(); if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
} }
@@ -1596,6 +1772,9 @@ public sealed class MainForm : Form
if (info is null) return; if (info is null) return;
if (AppConfig.Load().SilentlyInstallUpdates) if (AppConfig.Load().SilentlyInstallUpdates)
{ {
// Notice the user before the app vanishes and the helper takes over. Hidden from
// the periodic-poll path on the assumption the user knows they ticked "silently
// install"; the startup path is the noisy one (see CheckForUpdatesOnStartup).
await InstallUpdateAsync(info).ConfigureAwait(true); await InstallUpdateAsync(info).ConfigureAwait(true);
return; return;
} }
@@ -1607,6 +1786,79 @@ public sealed class MainForm : Form
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
} }
/// <summary>Startup-poll path. Fired ~4 s after the main window finishes loading when
/// <see cref="AppConfig.CheckForUpdatesOnStartup"/> is true. Distinct from
/// <see cref="CheckForUpdatesInBackground"/> because the startup case is where the
/// "you launched the app and it's already installing an update" surprise is loudest —
/// silent install here is preceded by a brief notice dialog so the user sees the version
/// number and understands why the app is about to vanish. The non-silent path uses the
/// same MessageBox flow as the background and manual paths so the user-visible question
/// stays consistent.</summary>
private async void CheckForUpdatesOnStartup()
{
UpdateInfo? info;
try
{
info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
logFile.Event($"updater: startup check failed: {ex.GetType().Name}: {ex.Message}");
return;
}
try
{
var cfg = AppConfig.Load();
cfg.LastUpdateCheckUtc = DateTime.UtcNow;
cfg.Save();
}
catch { /* harmless */ }
if (info is null)
{
logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})");
return;
}
logFile.Event($"updater: startup check found {info.Tag}");
if (AppConfig.Load().SilentlyInstallUpdates)
{
// Heads-up the user before we exit and the helper takes over. The notice is its
// own dialog so NVDA reads "RemSound is installing version X" before focus moves;
// a MessageBox would force the user to dismiss it, which defeats the point of
// "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short
// countdown but lets the user pick Install now / Skip / Postpone before then.
using var notice = new UpdateInstallNoticeDialog(info);
var choice = notice.ShowDialog(this);
switch (choice)
{
case DialogResult.OK:
// "Install now" — same as the countdown elapsing.
await InstallUpdateAsync(info).ConfigureAwait(true);
break;
case DialogResult.Ignore:
// "Skip this version" — log and leave the user be; the next startup
// check will probably find the same version and ask again. We don't
// persist a skip list because release tempo is low enough that the
// user can dismiss once or twice without resenting it.
logFile.Event($"updater: user skipped {info.Tag} from startup notice");
break;
case DialogResult.Cancel:
default:
// "Postpone" / closed dialog — silent install at the next opportunity
// (timer tick or next launch).
logFile.Event($"updater: user postponed {info.Tag} from startup notice");
break;
}
return;
}
// Non-silent: same prompt the background poll uses.
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var pick = MessageBox.Show(this, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
/// <summary>Download the new release, stage it, spawn the install helper and exit. On /// <summary>Download the new release, stage it, spawn the install helper and exit. On
/// any failure shows a MessageBox and stays running — partial installs leave the app /// any failure shows a MessageBox and stays running — partial installs leave the app
/// untouched.</summary> /// untouched.</summary>
@@ -2816,6 +3068,20 @@ public sealed class MainForm : Form
IReadOnlyList<AudioDeviceChoice> wasapiInputs; IReadOnlyList<AudioDeviceChoice> wasapiInputs;
IReadOnlyList<AudioDeviceChoice> asioInputChoices = []; IReadOnlyList<AudioDeviceChoice> asioInputChoices = [];
IReadOnlyList<AudioDeviceChoice> asioOutputChoices = []; IReadOnlyList<AudioDeviceChoice> asioOutputChoices = [];
// True if ASIO mode is on AND a driver is configured AND probing it just failed this
// tick. Used to skip the asio list sync below — without this guard, a transient probe
// failure (most commonly during hibernate entry or resume, when the USB stack is being
// torn down or rebuilt) silently clears the user's ASIO tick, and on the next refresh
// when the probe succeeds the list re-populates EMPTY of checks because the tick state
// was lost in the previous clear. Net symptom: receiver-side audio falls silent after
// resume even though all "audio backend re-initialised" log lines look fine.
// 2026-05-22 — traced to a real overnight repro: SNAP at 23:37:33 had ReceiveDevice
// = "ASIO 1/2"; SNAP at 23:37:34 (one second later, mid-hibernate-entry) had "(none)";
// resume at 06:32:06 then opened the audio backend but the asio receive list was empty
// so AsioRenderBackend.SetOutputDevices got an empty pairs list and silently returned
// without opening the AsioOut — the AsioLane sessions queued packets into a ring with
// no consumer (bufMs grew to 970+ ms, TrimDropBytes climbed into the millions).
var asioProbeAttemptedAndFailed = false;
try try
{ {
wasapiOutputs = AudioDeviceCatalog.LoadOutputs(); wasapiOutputs = AudioDeviceCatalog.LoadOutputs();
@@ -2831,6 +3097,17 @@ public sealed class MainForm : Form
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames); asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames); asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames);
} }
else
{
// Probe came back -1/-1 — driver is configured but can't enumerate right
// now. Treat as transient; preserve current list state and try again on
// the next tick. The legitimate "driver is genuinely gone" cases (user
// selected "(none)", or settings.LoadAsioDriverName() returned null/empty)
// take the outer-if's else branch and correctly produce an empty list
// that DOES sync (clearing the UI), so removing a driver from the system
// still wipes the ticks as expected.
asioProbeAttemptedAndFailed = true;
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -2842,8 +3119,22 @@ public sealed class MainForm : Form
var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature); var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature);
var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature); var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature);
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature); var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature);
var asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature); bool asioSendChanged;
var asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature); bool asioReceiveChanged;
if (asioProbeAttemptedAndFailed)
{
// Skip both asio list syncs. Crucially do NOT update the signature fields — leaving
// them unchanged means the NEXT successful probe will still see "signature differs"
// and re-sync the lists with the freshly-probed channel pairs, restoring tick state
// by DeviceId from whatever was preserved in the UI.
asioSendChanged = false;
asioReceiveChanged = false;
}
else
{
asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
}
if (sendOutputChanged || sendInputChanged || asioSendChanged) if (sendOutputChanged || sendInputChanged || asioSendChanged)
{ {
@@ -3236,6 +3527,81 @@ public sealed class MainForm : Form
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks"); if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
} }
/// <summary>
/// Called by <see cref="PowerResumeHandler"/> on a background thread after the system has
/// woken from sleep / hibernate (plus a short USB-settle delay). Marshals onto the UI
/// thread and runs the audio-backend re-init. Swallows the form-already-torn-down race —
/// the handler can fire just as the app is being closed.
/// </summary>
private void OnSystemResume()
{
try
{
if (IsDisposed) return;
BeginInvoke(ReinitAudioBackendsForResume);
}
catch (ObjectDisposedException) { /* form torn down — nothing to do */ }
catch (InvalidOperationException) { /* handle not created yet — same */ }
}
/// <summary>
/// Runs on the UI thread. Closes and reopens the audio backend on both sides (receiver
/// render and sender capture) so any post-sleep wedged state in the USB audio drivers is
/// cleared. Shows the audio-driver splash on its own thread while the reset happens, so
/// the user sees "Reconnecting to audio driver…" instead of a frozen window.
///
/// Implementation note: the receiver's <see cref="RemSound.Receiver.AudioReceiver.SetAudioMode"/>
/// always tears down and rebuilds its render backend, which is exactly the reset we
/// want. The sender's <see cref="RemSound.Sender.AudioSender.SetAudioMode"/> persists
/// its ASIO driver across same-driver calls (to avoid an expensive reopen on every
/// device-tick change) — so we explicitly bounce the sender through <c>WasapiOnly</c>
/// first to force the ASIO driver to be disposed, then <see cref="ApplyAsioMode"/>
/// puts both sides back to the real configuration. The net effect is a full close-and-
/// reopen on both sides; same code path as a manual driver re-pick from the picker.
/// </summary>
private void ReinitAudioBackendsForResume()
{
if (IsDisposed) return;
var mode = settings.LoadAudioMode();
var driver = settings.LoadAsioDriverName();
logFile.Event($"power: re-initialising audio backend after system resume (mode={mode}, driver={driver ?? "(none)"})");
var splash = AsioLoadingSplash.StartIfAsioDriverName(driver, "Reconnecting to audio driver, please wait...");
try
{
// Force the sender's persistent ASIO driver to be disposed by bouncing through
// WasapiOnly. Skipped when there's no ASIO in the current mode — nothing to dispose.
if (mode != AudioMode.WasapiOnly && !string.IsNullOrWhiteSpace(driver))
{
try { sender.SetAudioMode(AudioMode.WasapiOnly, null); }
catch (Exception ex) { logFile.Event($"power: sender WasapiOnly bounce failed: {ex.GetType().Name}: {ex.Message}"); }
}
// ApplyAsioMode re-applies sender + receiver mode, refreshes device lists, and
// re-pushes the audio-runtime + receive-device configuration. The receiver's
// SetAudioMode call inside it does an unconditional render-backend rebuild; the
// sender's, post-bounce, recreates its persistent ASIO from scratch.
ApplyAsioMode();
logFile.Event("power: audio backend re-initialised");
// Re-poke the router. UPnP/NAT-PMP mappings often survive a sleep, but cheap
// routers and ISP-supplied combo boxes sometimes drop their NAT table — easier
// to just rediscover than to guess. Refresh() is a no-op if UPnP is off.
if (AppConfig.Load().UpnpEnabled)
{
try { routerPortMapper.Refresh(); }
catch (Exception ex) { logFile.Event($"upnp: refresh-on-resume failed: {ex.GetType().Name}: {ex.Message}"); }
}
}
catch (Exception ex)
{
logFile.Event($"power: audio backend re-init failed: {ex.GetType().Name}: {ex.Message}");
}
finally
{
splash?.Dismiss();
}
}
// ===================== Peers ===================== // ===================== Peers =====================
private void RefreshKnownPeers() private void RefreshKnownPeers()
@@ -3896,11 +4262,32 @@ public sealed class MainForm : Form
var emitMs = sender.TakeMaxEmitMs(); var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs(); var sendCallMs = sender.TakeMaxSendCallMs();
var rxDispatchMs = receiver.TakeMaxOnPacketMs(); var rxDispatchMs = receiver.TakeMaxOnPacketMs();
// rxNetGapMs = worst inter-packet arrival gap at the user-space UDP socket.
// Distinct from maxGapMs (which is measured at the per-stream-session level
// after decode + assembly): this one is the raw "did ReceiveFrom return on
// time" timing, with no per-session bookkeeping in between. A spike here
// when the sender's sendCbGapMs is small fingers the OS/network path between
// sender and receiver — NIC IRQ servicing, scheduler not waking the receive
// thread, kernel batching, GC pause — rather than the sender stalling or
// RemSound's own decode/dispatch chain. 2026-05-21.
var rxNetGapMs = receiver.TakeMaxInterPacketGapMs();
// fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single // fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single
// active render lane should sit at ~0; non-zero says the FanOut is sitting on // active render lane should sit at ~0; non-zero says the FanOut is sitting on
// samples that aren't reaching the audio output, i.e. extra perceived latency // samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut). // not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs(); var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
// GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the
// previous tick gives the per-second collection count per generation. Gen-0
// collections are cheap (microseconds); Gen-1 takes longer; Gen-2 / LOH can
// pause the runtime for many milliseconds, which is enough to explain a
// 3050 ms rxNetGapMs spike in isolation. Read directly here — GC.CollectionCount
// is essentially free, no need to gate further. 2026-05-21.
var gc0Now = GC.CollectionCount(0);
var gc1Now = GC.CollectionCount(1);
var gc2Now = GC.CollectionCount(2);
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
// Per-stage discontinuity probes. Compare these to localise where in the // Per-stage discontinuity probes. Compare these to localise where in the
// pipeline a click is introduced: // pipeline a click is introduced:
// stepPreEnc = sender's float buffer just before encoding. Non-zero = // stepPreEnc = sender's float buffer just before encoding. Non-zero =
@@ -3919,15 +4306,37 @@ public sealed class MainForm : Form
// can show which lane is producing the discontinuity, free of the cross- // can show which lane is producing the discontinuity, free of the cross-
// stream artefact that the old shared probe registered when both lanes' // stream artefact that the old shared probe registered when both lanes'
// callbacks interleaved into one probe's lastL/R carry. // callbacks interleaved into one probe's lastL/R carry.
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane(); //
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane(); // 2026-05-21 — also surface the cross-buffer (boundary) vs within-buffer
// (content) split for every probe. A non-zero combined step combined with a
// near-zero within-buffer reading means the click is at a buffer / packet
// boundary (lost or duplicated sample, pipeline glitch); a non-zero
// within-buffer reading with a near-zero cross-buffer reading means it's a
// sharp transient inside one buffer (real audio content, system sound). All
// probe drains here go through the XB/WB pair and recompute the combined
// max from the split values — calling Take*Step() AND the split methods on
// the same probe in the same drain window would double-drain.
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi; var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
var stepRawCap = sender.TakeMaxSenderRawCaptureStep(); var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
var clippedNow = sender.ClippedSampleCount; var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow; var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
var stepPostDec = receiver.TakeMaxPostDecodeStep(); var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
var stepPostRing = receiver.TakeMaxPostRingReadStep(); var stepPostDecWB = receiver.TakeMaxPostDecodeStepWithinBuffer();
var stepPostRsm = receiver.TakeMaxPostResamplerStep(); var stepPostDec = stepPostDecXB > stepPostDecWB ? stepPostDecXB : stepPostDecWB;
var stepPostRingXB = receiver.TakeMaxPostRingReadStepCrossBuffer();
var stepPostRingWB = receiver.TakeMaxPostRingReadStepWithinBuffer();
var stepPostRing = stepPostRingXB > stepPostRingWB ? stepPostRingXB : stepPostRingWB;
var stepPostRsmXB = receiver.TakeMaxPostResamplerStepCrossBuffer();
var stepPostRsmWB = receiver.TakeMaxPostResamplerStepWithinBuffer();
var stepPostRsm = stepPostRsmXB > stepPostRsmWB ? stepPostRsmXB : stepPostRsmWB;
// Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that // Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ / // arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
// wireDupΔ are the smoking-gun counters — any non-zero value here means the // wireDupΔ are the smoking-gun counters — any non-zero value here means the
@@ -3944,12 +4353,19 @@ public sealed class MainForm : Form
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " + logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " + $"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " + $"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} fanCacheMs={fanCacheMs} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " + $"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " + $"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " + $"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " + $"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"stepPostDecXB={stepPostDecXB:0.000} stepPostDecWB={stepPostDecWB:0.000} " +
$"stepPostRingXB={stepPostRingXB:0.000} stepPostRingWB={stepPostRingWB:0.000} " +
$"stepPostRsmXB={stepPostRsmXB:0.000} stepPostRsmWB={stepPostRsmWB:0.000} " +
$"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " + $"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " + $"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}"); $"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
@@ -3968,16 +4384,42 @@ public sealed class MainForm : Form
var sendCallMs = sender.TakeMaxSendCallMs(); var sendCallMs = sender.TakeMaxSendCallMs();
// Per-lane pre-encode probes — see the full-diag comment above for the // Per-lane pre-encode probes — see the full-diag comment above for the
// rationale (per-lane fixes the cross-stream artefact in BothIndependent). // rationale (per-lane fixes the cross-stream artefact in BothIndependent).
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane(); // 2026-05-21: drain XB / WB separately so we can localise click events at
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane(); // the buffer boundary (cross-buffer) vs within-buffer (real content). The
// combined step is just the larger of the two for back-compat readers.
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi; var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
// Raw-capture step: now per-backend (each backend owns its own probe). The // Raw-capture step: now per-backend (each backend owns its own probe). The
// accessor returns max across all backends. PushModeWasapiBackend has been // accessor returns max across all backends. PushModeWasapiBackend has been
// wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0. // wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0.
var stepRawCap = sender.TakeMaxSenderRawCaptureStep(); var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
var clippedNow = sender.ClippedSampleCount; var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow; var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}"); // Per-second GC delta on the send-only side too. A send stall caused by a
// gen-2 pause on the SENDER would have a different signature in the SNAP
// log than one caused by a receive-side pause — they'd show up here even
// though no receiver activity is happening on this machine.
var gc0Now = GC.CollectionCount(0);
var gc1Now = GC.CollectionCount(1);
var gc2Now = GC.CollectionCount(2);
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
logFile.Event(
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
} }
// Synthesised end-to-end one-way latency estimate. Sums: // Synthesised end-to-end one-way latency estimate. Sums:
@@ -4183,11 +4625,16 @@ public sealed class MainForm : Form
/// <summary>Window title shows the active profile name explicitly so the user knows what /// <summary>Window title shows the active profile name explicitly so the user knows what
/// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or /// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or
/// just "RemSound" (blank template).</summary> /// just "RemSound" (blank template). Read-only profiles get a " (read-only)" suffix so
private static string FormatWindowTitle(string? loadedTitle) => /// NVDA announces the lock state on every title change and sighted users see it at a
string.IsNullOrEmpty(loadedTitle) /// glance — important context that "anything I change here won't be saved".</summary>
? AppName private string FormatWindowTitle(string? loadedTitle)
: $"{AppName} — Active profile: {loadedTitle}"; {
var readOnlySuffix = currentProfileReadOnly ? " (read-only)" : "";
return string.IsNullOrEmpty(loadedTitle)
? $"{AppName}{readOnlySuffix}"
: $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
}
/// <summary>Show/hide the Update button based on whether a profile is currently loaded. /// <summary>Show/hide the Update button based on whether a profile is currently loaded.
/// Update only makes sense when there's an existing profile to overwrite; Save-as is /// Update only makes sense when there's an existing profile to overwrite; Save-as is
@@ -4261,6 +4708,18 @@ public sealed class MainForm : Form
currentProfileTitle = title; currentProfileTitle = title;
currentProfilePath = path; currentProfilePath = path;
// Save As always produces an editable copy — even if the source profile was
// read-only. Anything else would be surprising: the user picked Save As
// specifically to fork, and they reasonably expect the fork to be editable
// without having to hunt for the menu toggle. The original (locked) profile on
// disk is untouched; this is purely about the new file and the in-memory state.
currentProfileReadOnly = false;
if (lockProfileMenuItem is not null)
{
suppressLockProfileToggleHandler = true;
try { lockProfileMenuItem.Checked = false; }
finally { suppressLockProfileToggleHandler = false; }
}
Text = FormatWindowTitle(title); Text = FormatWindowTitle(title);
AccessibleName = Text; AccessibleName = Text;
UpdateProfileButtonsVisibility(); UpdateProfileButtonsVisibility();
@@ -4377,6 +4836,74 @@ public sealed class MainForm : Form
unsavedChanges = true; unsavedChanges = true;
} }
/// <summary>Handle the user ticking / unticking File → Lock profile (read-only). Updates
/// the in-memory flag, refreshes the window title's "(read-only)" suffix, and persists
/// the new value to the profile JSON on disk via <see cref="PersistReadOnlyFlagOnly"/>.
/// We MUST persist immediately because the very next user action might be the close
/// (the whole point of the feature is that close is unattended); waiting for an explicit
/// Save would defeat the point. 2026-05-22 — Andre's request.</summary>
private void OnLockProfileToggled(bool readOnly)
{
currentProfileReadOnly = readOnly;
Text = FormatWindowTitle(currentProfileTitle);
AccessibleName = Text;
PersistReadOnlyFlagOnly(readOnly);
AppendLogEntry($"profile read-only flag set to {readOnly} for \"{currentProfileTitle ?? "(blank template)"}\"");
}
/// <summary>Write JUST the ReadOnly flag back to the profile file on disk, without
/// touching any of the user's in-session edits. Used by <see cref="OnLockProfileToggled"/>
/// so toggling lock-state writes the flag immediately but leaves every other unsaved
/// change exactly as-is — without this carve-out, unlocking a profile that has unsaved
/// edits would either have to ignore them (losing user intent) or flush them (defeating
/// "the lock writes the lock, nothing else"). Approach: read the profile JSON, deserialise,
/// flip ONE field, re-serialise, write back. Blank-template case (no path) is a silent
/// no-op — there's no file to update, and the user's lock state lives in memory until
/// they Save As, at which point Save As builds a fresh Profile and writes whatever
/// flag the in-memory state has.</summary>
private void PersistReadOnlyFlagOnly(bool readOnly)
{
if (string.IsNullOrEmpty(currentProfilePath)) return;
if (!File.Exists(currentProfilePath)) return;
try
{
var json = File.ReadAllText(currentProfilePath);
var profile = JsonSerializer.Deserialize<Profile>(json);
if (profile is null) return;
if (profile.ReadOnly == readOnly) return; // no change, skip the rewrite
profile.ReadOnly = readOnly;
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson);
// Refresh the unsaved-changes baseline so any user edits made BEFORE the toggle
// remain "unsaved" (still pending a real Save) — the baseline tracks the saved
// profile JSON, and we just rewrote it on disk, so the diff has to be against
// the new file contents not the old ones. Without this, toggling lock on a
// dirty profile would suddenly "clean" the dirty flag from the close path's
// POV, even though the user's other edits still aren't persisted. The new
// baseline reflects the on-disk truth; the in-memory state still differs by
// those other edits, so unsavedChanges-style tracking still works.
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
catch { /* baseline refresh is best-effort */ }
}
catch (Exception ex)
{
// Don't bother the user with a MessageBox for a flag-write failure — they'd just
// see "couldn't persist the lock flag" with no actionable detail. Log and move
// on; the in-memory state already reflects the toggle, so the current session
// works correctly. Next launch the file's flag wins, but a single failed write
// is rare enough that it's not worth a dialog.
AppendLogEntry($"failed to persist read-only flag: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>Serialise an arbitrary <see cref="Profile"/> in the same shape
/// <see cref="SerializeCurrentStateAsProfile"/> uses for the dirty-diff. Lives here so
/// the lock-flag persistence path can refresh the baseline against the rewritten file
/// contents (a partial overwrite of the profile file) without flushing the user's
/// in-session edits. 2026-05-22.</summary>
private static string SerializeProfileForDirtyDiff(Profile profile) =>
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
/// <summary>Serializes the current control state as if the user had just clicked Save. /// <summary>Serializes the current control state as if the user had just clicked Save.
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/> /// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
/// but doesn't write anywhere.</summary> /// but doesn't write anywhere.</summary>
@@ -5250,7 +5777,16 @@ public sealed class MainForm : Form
// controlled close paths where the user has already confirmed their intent via the // controlled close paths where the user has already confirmed their intent via the
// management dialog, and the MainForm gets reconstructed under the new profile // management dialog, and the MainForm gets reconstructed under the new profile
// immediately afterwards. // immediately afterwards.
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch; //
// Also skip the prompt when the active profile is read-only — the whole point of
// read-only mode (Andre's request, 2026-05-22) is that the user has explicitly
// declared "anything I changed this session is throwaway, don't save it and don't
// ask me about it". Without this branch the dirty-prompt would block shutdown on
// a profile where the user wants exactly the opposite: silent exit. Crucially this
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
// on a dialog the user can't reach.
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|| currentProfileReadOnly;
if (!skipPrompt && profileStore is not null && unsavedChanges) if (!skipPrompt && profileStore is not null && unsavedChanges)
{ {
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Threading.Tasks;
using Microsoft.Win32;
namespace RemSound.App;
/// <summary>
/// Subscribes to Windows' system power-state changes and fires a single callback on
/// <see cref="PowerModes.Resume"/> — i.e. when the system has just come back from sleep
/// (S3) or hibernate (S4). Both states raise the same Resume event, so the one hook covers
/// both. Used by <see cref="MainForm"/> to re-initialise the audio backend after wake:
/// after a sleep cycle the USB audio device (ASIO / WASAPI) can come back in a degraded
/// state where the pipeline appears to run but no sound actually comes out of the
/// interface, and a clean close-and-reopen of the audio backend clears it.
///
/// Threading: PowerModeChanged is raised on a system-message thread, NOT the UI thread.
/// The handler returns from that thread quickly (no work done inline) and schedules the
/// real reset on a background task — which then marshals onto the UI thread via the
/// caller's callback. The caller's callback is responsible for any UI-thread marshaling.
///
/// USB settle delay: Windows raises Resume early, sometimes before USB devices have
/// finished re-enumerating. The handler waits <see cref="SettleDelay"/> before firing the
/// callback so the audio backend re-init has a fully-ready USB stack to talk to.
///
/// Debounce: Windows can in rare cases fire Resume twice in quick succession after a
/// short sleep. The handler ignores Resume events within <see cref="DebounceWindow"/> of
/// the previous one so the audio backend isn't torn down and rebuilt twice for one wake.
/// </summary>
internal sealed class PowerResumeHandler : IDisposable
{
/// <summary>How long to wait after Resume before firing the callback — gives the USB
/// bus and audio drivers time to finish re-enumerating.</summary>
public static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(1500);
/// <summary>A second Resume event within this window of the first is treated as a
/// duplicate and ignored.</summary>
public static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(5);
private readonly Action onResume;
private readonly Action<string>? log;
private readonly object gate = new();
private DateTime lastResumeUtc = DateTime.MinValue;
private bool disposed;
/// <param name="onResume">Invoked once per resume event, on a background thread, after
/// the USB-settle delay. The callback is responsible for marshaling onto the UI thread
/// if it touches UI or audio state.</param>
/// <param name="log">Optional sink for diagnostic lines — wire to the app's log if you
/// want resume events visible there.</param>
public PowerResumeHandler(Action onResume, Action<string>? log = null)
{
this.onResume = onResume ?? throw new ArgumentNullException(nameof(onResume));
this.log = log;
SystemEvents.PowerModeChanged += OnPowerModeChanged;
log?.Invoke("subscribed to PowerModeChanged");
}
private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e)
{
if (e.Mode != PowerModes.Resume) return;
if (disposed) return;
lock (gate)
{
var now = DateTime.UtcNow;
if (now - lastResumeUtc < DebounceWindow)
{
log?.Invoke($"Resume event ignored (within {DebounceWindow.TotalSeconds:0} s debounce of previous)");
return;
}
lastResumeUtc = now;
}
log?.Invoke($"system Resume detected — scheduling audio backend reset in {SettleDelay.TotalMilliseconds:0} ms");
// Return from the system message thread immediately; do the work on a background task.
_ = Task.Run(async () =>
{
try
{
await Task.Delay(SettleDelay).ConfigureAwait(false);
if (disposed) return;
onResume();
}
catch (Exception ex)
{
log?.Invoke($"Resume callback failed: {ex.GetType().Name}: {ex.Message}");
}
});
}
public void Dispose()
{
if (disposed) return;
disposed = true;
try { SystemEvents.PowerModeChanged -= OnPowerModeChanged; } catch { /* shutting down */ }
}
}
+161 -34
View File
@@ -1,3 +1,4 @@
using System.Net;
using RemSound.Core; using RemSound.Core;
namespace RemSound.App; namespace RemSound.App;
@@ -7,12 +8,19 @@ namespace RemSound.App;
/// right: /// right:
/// * Browse for RemSound profiles folder — picks the directory the profile picker scans /// * Browse for RemSound profiles folder — picks the directory the profile picker scans
/// next launch. /// next launch.
/// * Cue sounds — per-cue enable list (connect, disconnect, recording start/stop). One /// * Audio cue sounds — per-cue enable list (connect, disconnect, recording start/stop).
/// CheckedListBox; ticked items play, unticked are silent. Replaced the old single /// One CheckedListBox; ticked items play, unticked are silent. Replaced the old single
/// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues /// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues
/// were added — a CheckedListBox scales to future cues without dialog re-layout. /// were added — a CheckedListBox scales to future cues without dialog re-layout. Label
/// gained the "Audio" prefix on 2026-05-21 to disambiguate from the underlying engine's
/// "buffer cues" and "ASIO cues" diagnostic terms, which look the same in writing.
/// * Accept remote volume commands from peers — opt-in for the remote-control feature. /// * Accept remote volume commands from peers — opt-in for the remote-control feature.
/// * Update settings — frequency, manual check, silent-install toggle. /// * Update settings — startup-check toggle, frequency, manual check, silent-install
/// toggle. Layout deliberately reads top-to-bottom as the question the user is
/// answering: "Check for updates on startup? (yes/no) Then, in the background, every?
/// (interval) When one's found? (silent install / ask first)".
/// * UPnP — optional automatic router port-forwarding via Mono.Nat. Off by default; when
/// ticked, we kick off discovery and surface the result + external address inline.
/// * Enable logs + Write logs now. /// * Enable logs + Write logs now.
/// ///
/// Startup behaviour was previously a button here that opened <see cref="StartupBehaviourDialog"/>; /// Startup behaviour was previously a button here that opened <see cref="StartupBehaviourDialog"/>;
@@ -39,8 +47,8 @@ internal sealed class PreferencesDialog : Form
// the CueIndex enum below so the ItemCheck handler can dispatch by index. // the CueIndex enum below so the ItemCheck handler can dispatch by index.
private readonly Label cueListLabel = new() private readonly Label cueListLabel = new()
{ {
Text = "Cue sou&nds (Alt+N):", Text = "Audio cue sou&nds (Alt+N):",
AccessibleName = "Cue sounds", AccessibleName = "Audio cue sounds",
AutoSize = true, AutoSize = true,
Padding = new Padding(0, 6, 0, 4), Padding = new Padding(0, 6, 0, 4),
}; };
@@ -51,7 +59,7 @@ internal sealed class PreferencesDialog : Form
IntegralHeight = false, IntegralHeight = false,
Height = 100, Height = 100,
Width = 360, Width = 360,
AccessibleName = "Cue sounds", AccessibleName = "Audio cue sounds",
}; };
private enum CueIndex private enum CueIndex
@@ -69,14 +77,24 @@ internal sealed class PreferencesDialog : Form
AutoSize = true, AutoSize = true,
}; };
// Update settings — frequency dropdown, manual check button, silent-install checkbox. // Update settings — startup-check checkbox, frequency dropdown, manual check button,
// Sits above the logging row so users meet it during setup; the canonical order in the // silent-install checkbox. Sits above the logging row so users meet it during setup; the
// dialog is "things related to the program staying current" before "things related to // canonical order in the dialog is "things related to the program staying current" before
// diagnosing how it's running". // "things related to diagnosing how it's running".
private readonly AccessibleCheckBox checkForUpdatesOnStartupBox = new()
{
Text = "Check for updates on &startup",
AccessibleName = "Check for updates on startup",
AutoSize = true,
};
private readonly Label updateFrequencyLabel = new() private readonly Label updateFrequencyLabel = new()
{ {
Text = "Check for updates (Alt+&U):", // "Then check every" — reads as a continuation of the startup-check checkbox above,
AccessibleName = "Check for updates frequency", // so the user understands the dropdown controls the *background* poll cadence, not
// the launch behaviour.
Text = "Then check every (Alt+&U):",
AccessibleName = "Then check every",
AutoSize = true, AutoSize = true,
}; };
@@ -84,7 +102,7 @@ internal sealed class PreferencesDialog : Form
{ {
DropDownStyle = ComboBoxStyle.DropDownList, DropDownStyle = ComboBoxStyle.DropDownList,
Width = 200, Width = 200,
AccessibleName = "Check for updates (Alt+U)", AccessibleName = "Then check every (Alt+U)",
}; };
private readonly Button checkForUpdatesNowButton = new() private readonly Button checkForUpdatesNowButton = new()
@@ -101,6 +119,24 @@ internal sealed class PreferencesDialog : Form
AutoSize = true, AutoSize = true,
}; };
// UPnP — automatic router port-forwarding via Mono.Nat. Off by default. The status label
// is updated live from the RouterPortMapper.StatusChanged event so the user sees the
// discovery result inline without having to close and reopen the dialog.
private readonly AccessibleCheckBox upnpEnabledBox = new()
{
Text = "Automatically open my router for incoming connections (UPnP) (Alt+&O)",
AccessibleName = "Automatically open my router for incoming connections via UPnP",
AutoSize = true,
};
private readonly Label upnpStatusLabel = new()
{
Text = "",
AccessibleName = "UPnP status",
AutoSize = true,
Padding = new Padding(20, 0, 0, 4),
};
private readonly AccessibleCheckBox loggingBox = new() private readonly AccessibleCheckBox loggingBox = new()
{ {
Text = "Enable &logs", Text = "Enable &logs",
@@ -127,6 +163,9 @@ internal sealed class PreferencesDialog : Form
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary> /// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
public bool ChangedAnyProfileSetting { get; private set; } public bool ChangedAnyProfileSetting { get; private set; }
private readonly Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot;
private EventHandler? upnpStatusSubscription;
public PreferencesDialog( public PreferencesDialog(
RemSoundSettingsStore settings, RemSoundSettingsStore settings,
ProfileStore? profileStore, ProfileStore? profileStore,
@@ -134,8 +173,14 @@ internal sealed class PreferencesDialog : Form
Action<bool> applyLoggingEnabled, Action<bool> applyLoggingEnabled,
Action writeLogsNow, Action writeLogsNow,
Action checkForUpdatesNow, Action checkForUpdatesNow,
Action onUpdateFrequencyChanged) Action onUpdateFrequencyChanged,
Action<bool> applyUpnpEnabled,
Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot,
Action<EventHandler> subscribeUpnpStatusChanged,
Action<EventHandler> unsubscribeUpnpStatusChanged)
{ {
this.getUpnpSnapshot = getUpnpSnapshot;
Text = "Preferences"; Text = "Preferences";
FormBorderStyle = FormBorderStyle.FixedDialog; FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false; MinimizeBox = false;
@@ -143,7 +188,7 @@ internal sealed class PreferencesDialog : Form
ShowInTaskbar = false; ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent; StartPosition = FormStartPosition.CenterParent;
KeyPreview = true; KeyPreview = true;
ClientSize = new Size(560, 540); ClientSize = new Size(580, 640);
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup // 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory // ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
@@ -214,8 +259,17 @@ internal sealed class PreferencesDialog : Form
// either side stays in lockstep. // either side stays in lockstep.
updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" }); updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" });
var cfgForLoad = AppConfig.Load(); var cfgForLoad = AppConfig.Load();
checkForUpdatesOnStartupBox.Checked = cfgForLoad.CheckForUpdatesOnStartup;
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency; updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates; silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates;
upnpEnabledBox.Checked = cfgForLoad.UpnpEnabled;
checkForUpdatesOnStartupBox.CheckedChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.CheckForUpdatesOnStartup = checkForUpdatesOnStartupBox.Checked;
try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ }
};
updateFrequencyBox.SelectedIndexChanged += (_, _) => updateFrequencyBox.SelectedIndexChanged += (_, _) =>
{ {
var cfg = AppConfig.Load(); var cfg = AppConfig.Load();
@@ -231,6 +285,39 @@ internal sealed class PreferencesDialog : Form
}; };
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow(); checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
// UPnP toggle — persists immediately and tells MainForm to start / stop the mapper.
// Status label refresh wires up below.
upnpEnabledBox.CheckedChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.UpnpEnabled = upnpEnabledBox.Checked;
try { cfg.Save(); } catch { /* harmless */ }
applyUpnpEnabled(upnpEnabledBox.Checked);
RefreshUpnpStatusLabel();
};
// Live UPnP status — the RouterPortMapper raises StatusChanged from a thread-pool
// thread, so marshal back onto the UI thread before touching the label. Subscribe
// on show and unsubscribe on close to avoid leaking the handler past the dialog.
upnpStatusSubscription = (_, _) =>
{
if (IsDisposed) return;
try { BeginInvoke(new Action(RefreshUpnpStatusLabel)); }
catch (ObjectDisposedException) { /* dialog already gone — ignore */ }
catch (InvalidOperationException) { /* handle not created — ignore */ }
};
subscribeUpnpStatusChanged(upnpStatusSubscription);
FormClosed += (_, _) =>
{
if (upnpStatusSubscription is not null)
{
try { unsubscribeUpnpStatusChanged(upnpStatusSubscription); }
catch { /* shutdown — ignore */ }
upnpStatusSubscription = null;
}
};
RefreshUpnpStatusLabel();
loggingBox.Checked = getLoggingEnabled(); loggingBox.Checked = getLoggingEnabled();
loggingBox.CheckedChanged += (_, _) => loggingBox.CheckedChanged += (_, _) =>
{ {
@@ -251,26 +338,28 @@ internal sealed class PreferencesDialog : Form
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
Padding = new Padding(12), Padding = new Padding(12),
ColumnCount = 1, ColumnCount = 1,
RowCount = 9, RowCount = 13,
}; };
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
for (var i = 0; i < 8; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); for (var i = 0; i < 12; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
// Tab order top-to-bottom: browse, cue-sound list, accept remote, update frequency, // Tab order top-to-bottom: browse, cue-sound list, accept remote, check-on-startup,
// check-now, silent install, enable logs, write logs now, close. Updates sit above // frequency, check-now, silent install, UPnP, enable logs, write logs now, close.
// the log row so a user setting up the app meets them first. The Startup behaviour // Updates sit above the log row so a user setting up the app meets them first. The
// button used to live here at tab index 3; it moved to the Options menu in the // Startup behaviour button used to live here at tab index 3; it moved to the Options
// 2026-05-15 reorg. // menu in the 2026-05-15 reorg.
browseProfilesFolderButton.TabIndex = 0; browseProfilesFolderButton.TabIndex = 0;
cueList.TabIndex = 1; cueList.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2; acceptRemoteVolumeBox.TabIndex = 2;
updateFrequencyBox.TabIndex = 3; checkForUpdatesOnStartupBox.TabIndex = 3;
checkForUpdatesNowButton.TabIndex = 4; updateFrequencyBox.TabIndex = 4;
silentlyInstallUpdatesBox.TabIndex = 5; checkForUpdatesNowButton.TabIndex = 5;
loggingBox.TabIndex = 6; silentlyInstallUpdatesBox.TabIndex = 6;
writeLogsNowButton.TabIndex = 7; upnpEnabledBox.TabIndex = 7;
closeButton.TabIndex = 8; loggingBox.TabIndex = 8;
writeLogsNowButton.TabIndex = 9;
closeButton.TabIndex = 10;
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label // 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. // sits inline next to the combo while keeping the combo as the focusable target.
@@ -305,11 +394,14 @@ internal sealed class PreferencesDialog : Form
panel.Controls.Add(browseProfilesFolderButton, 0, 0); panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(cueGroup, 0, 1); panel.Controls.Add(cueGroup, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2); panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
panel.Controls.Add(freqRow, 0, 3); panel.Controls.Add(checkForUpdatesOnStartupBox, 0, 3);
panel.Controls.Add(checkForUpdatesNowButton, 0, 4); panel.Controls.Add(freqRow, 0, 4);
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 5); panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
panel.Controls.Add(loggingBox, 0, 6); panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
panel.Controls.Add(writeLogsNowButton, 0, 7); panel.Controls.Add(upnpEnabledBox, 0, 7);
panel.Controls.Add(upnpStatusLabel, 0, 8);
panel.Controls.Add(loggingBox, 0, 9);
panel.Controls.Add(writeLogsNowButton, 0, 10);
var buttons = new FlowLayoutPanel var buttons = new FlowLayoutPanel
{ {
@@ -336,4 +428,39 @@ internal sealed class PreferencesDialog : Form
} }
}; };
} }
/// <summary>Pull the latest UPnP snapshot and update the inline status label. Always
/// called on the UI thread (either inline from a change handler or marshaled in from
/// the StatusChanged subscription).</summary>
private void RefreshUpnpStatusLabel()
{
var (status, external, lastError) = getUpnpSnapshot();
// Skip the label entirely while UPnP is off — keeps the dialog quiet for users who
// don't care, and stops the "Disabled" string from showing up next to an unticked
// box (which would just read as redundant noise to NVDA).
if (!upnpEnabledBox.Checked)
{
upnpStatusLabel.Text = "";
upnpStatusLabel.AccessibleName = "UPnP status";
return;
}
var text = status switch
{
RouterMappingStatus.Disabled => "Status: not yet started.",
RouterMappingStatus.Searching => "Status: searching for a router that supports UPnP / NAT-PMP / PCP...",
RouterMappingStatus.Mapped => external is not null
? $"Status: router port opened. Peers can reach you at {external.Address}:{external.Port}."
: "Status: router port opened.",
RouterMappingStatus.NoRouterFound => "Status: no router with UPnP / NAT-PMP / PCP found. Check that the feature is enabled on your router, or forward UDP 47830 manually.",
RouterMappingStatus.CgnatDetected => external is not null
? $"Status: the router opened the port, but the external address ({external.Address}) is on a carrier-grade NAT — peers on the public internet will not be able to reach you. Consider Tailscale or the relay instead."
: "Status: the router opened the port, but you are behind a carrier-grade NAT — peers on the public internet will not be able to reach you. Consider Tailscale or the relay instead.",
RouterMappingStatus.MappingFailed => string.IsNullOrEmpty(lastError)
? "Status: the router rejected the port-mapping request."
: $"Status: the router rejected the port-mapping request — {lastError}",
_ => "",
};
upnpStatusLabel.Text = text;
upnpStatusLabel.AccessibleName = string.IsNullOrEmpty(text) ? "UPnP status" : text;
}
} }
+51 -7
View File
@@ -118,23 +118,67 @@ internal sealed class ProfileSelectionDialog : Form
private void RefreshList() private void RefreshList()
{ {
var prevSelected = listBox.SelectedItem as string; // Remember the previously-selected title (NOT the displayed text — that includes any
// " (read-only)" suffix, which would prevent matching across a refresh).
var prevSelectedTitle = GetSelectedTitle();
listBox.BeginUpdate(); listBox.BeginUpdate();
listBox.Items.Clear(); listBox.Items.Clear();
listBox.Items.Add(BlankTemplateLabel); listBox.Items.Add(BlankTemplateLabel);
foreach (var t in store.ListProfileTitles()) foreach (var t in store.ListProfileTitles())
{ {
listBox.Items.Add(t); // Wrap each title in a ProfileListItem so the displayed text can carry a
// "(read-only)" suffix while the underlying title stays clean for store lookups.
// ListBox.ToString() is what NVDA reads and what's shown visually; the inner
// .Title is what code paths key off. Locked profiles get the suffix so users
// know what they're picking before they hit Enter. 2026-05-22.
listBox.Items.Add(new ProfileListItem(t, store.IsProfileReadOnly(t)));
} }
// Try to restore selection; fall back to first item. // Try to restore selection by title; fall back to first item.
var idx = prevSelected is null ? 0 : Math.Max(0, listBox.Items.IndexOf(prevSelected)); var newIdx = 0;
listBox.SelectedIndex = Math.Min(idx, listBox.Items.Count - 1); if (!string.IsNullOrEmpty(prevSelectedTitle))
{
for (var i = 0; i < listBox.Items.Count; i++)
{
if (string.Equals(TitleOfItem(listBox.Items[i]), prevSelectedTitle, StringComparison.Ordinal))
{
newIdx = i;
break;
}
}
}
listBox.SelectedIndex = Math.Min(newIdx, listBox.Items.Count - 1);
listBox.EndUpdate(); listBox.EndUpdate();
// Keep the folder label in sync so it always reflects what the listbox is reading. // Keep the folder label in sync so it always reflects what the listbox is reading.
folderLabel.Text = "Profiles folder: " + store.BaseDirectory; folderLabel.Text = "Profiles folder: " + store.BaseDirectory;
folderLabel.AccessibleName = folderLabel.Text; folderLabel.AccessibleName = folderLabel.Text;
} }
/// <summary>Returns the currently-selected profile title (or the BlankTemplateLabel
/// constant for the blank template), unwrapping the ProfileListItem if needed. Returns
/// null when nothing is selected. Used by accept / delete to key into the store.</summary>
private string? GetSelectedTitle()
{
var item = listBox.SelectedItem;
return TitleOfItem(item);
}
private static string? TitleOfItem(object? item) => item switch
{
null => null,
string s => s,
ProfileListItem p => p.Title,
_ => item.ToString(),
};
/// <summary>Listbox wrapper for a saved profile. The displayed text (which NVDA reads
/// and which appears visually) decorates the title with "(read-only)" when the profile
/// JSON has the lock flag set; the Title property stays clean so store lookups by title
/// keep working. 2026-05-22.</summary>
private sealed record ProfileListItem(string Title, bool ReadOnly)
{
public override string ToString() => ReadOnly ? $"{Title} (read-only)" : Title;
}
private void OnListKeyDown(object? sender, KeyEventArgs e) private void OnListKeyDown(object? sender, KeyEventArgs e)
{ {
if (e.KeyCode == Keys.Enter) if (e.KeyCode == Keys.Enter)
@@ -153,7 +197,7 @@ internal sealed class ProfileSelectionDialog : Form
private void Accept() private void Accept()
{ {
var selected = listBox.SelectedItem as string; var selected = GetSelectedTitle();
if (string.IsNullOrEmpty(selected)) return; if (string.IsNullOrEmpty(selected)) return;
if (selected == BlankTemplateLabel) if (selected == BlankTemplateLabel)
{ {
@@ -178,7 +222,7 @@ internal sealed class ProfileSelectionDialog : Form
private void DeleteSelected() private void DeleteSelected()
{ {
var selected = listBox.SelectedItem as string; var selected = GetSelectedTitle();
if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return; if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return;
var result = MessageBox.Show(this, var result = MessageBox.Show(this,
$"Delete profile \"{selected}\"? This cannot be undone.", $"Delete profile \"{selected}\"? This cannot be undone.",
+6 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. --> is what the About dialog and the updater both read. -->
<Version>2.0.0</Version> <Version>2.1.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -33,6 +33,11 @@
no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without
sample-data loss. --> sample-data loss. -->
<PackageReference Include="CUETools.Codecs.FLAKE" Version="1.0.5" /> <PackageReference Include="CUETools.Codecs.FLAKE" Version="1.0.5" />
<!-- UPnP / NAT-PMP / PCP client library for automatically opening the audio port on
the user's router so peers on the public internet can reach this machine without
manual port forwarding. Cross-protocol — picks whichever the router speaks. Used
under the AppConfig.UpnpEnabled toggle, off by default. -->
<PackageReference Include="Mono.Nat" Version="3.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+324
View File
@@ -0,0 +1,324 @@
using System;
using System.Net;
using System.Threading;
using Mono.Nat;
namespace RemSound.App;
/// <summary>
/// Status of the router port mapping attempt — used to drive the inline status label in
/// the Preferences dialog.
/// </summary>
internal enum RouterMappingStatus
{
/// <summary>The feature is off (the user hasn't enabled UPnP).</summary>
Disabled,
/// <summary>Looking for a UPnP / NAT-PMP / PCP router on the LAN.</summary>
Searching,
/// <summary>Mapping opened successfully and the router is reachable.</summary>
Mapped,
/// <summary>No router with UPnP / NAT-PMP / PCP support was found. Either the router
/// doesn't support it, has it disabled, or the network blocks discovery.</summary>
NoRouterFound,
/// <summary>A router was found and the mapping was added, but the reported external
/// address is in the carrier-grade NAT (CGNAT) range — peers on the public internet
/// will not be able to reach this machine even though the local router cooperated.</summary>
CgnatDetected,
/// <summary>A router was found but the mapping attempt failed (port already mapped to
/// another device, router rejected the request, etc.). <see cref="LastError"/> has the
/// detail.</summary>
MappingFailed,
}
/// <summary>
/// Asks the user's router to forward inbound UDP <see cref="AudioPort"/> traffic to this
/// machine, using UPnP / NAT-PMP / PCP via the Mono.Nat library. The point is to spare
/// home users from manual port-forwarding when they want peers on the public internet to
/// reach them. Mono.Nat picks whichever protocol the router speaks.
///
/// Off by default and gated by <c>AppConfig.UpnpEnabled</c> — RemSound never pokes the
/// router unless the user has explicitly ticked the Preferences checkbox. Failures are
/// surfaced via <see cref="StatusChanged"/> and the Preferences status label; they never
/// throw or pop a dialog (the network is too lumpy for a popup to be useful).
///
/// Lifecycle:
/// * <see cref="Start"/> kicks off discovery on a background task. When (or if) a router
/// replies, the mapping is added and <see cref="StatusChanged"/> fires with
/// <see cref="RouterMappingStatus.Mapped"/>.
/// * Renewal happens automatically — Mono.Nat extends the lease before it expires.
/// * <see cref="Refresh"/> can be called after a sleep / resume cycle to make sure the
/// router didn't drop the mapping while the machine was off; this re-runs discovery.
/// * <see cref="Stop"/> politely removes the mapping and stops discovery.
///
/// Detects CGNAT by checking whether the router's reported external address falls in
/// <c>100.64.0.0/10</c> (RFC 6598) — when it does, UPnP technically succeeded but the user
/// is still unreachable from the public internet because of an upstream ISP NAT layer.
/// We surface that as a distinct status so the user understands why peers still can't
/// connect and is pointed at Tailscale / the relay instead.
/// </summary>
internal sealed class RouterPortMapper : IDisposable
{
/// <summary>The UDP port RemSound uses for audio + heartbeat.</summary>
public const int AudioPort = 47830;
/// <summary>Lease duration on the port mapping, in seconds. The router (and Mono.Nat's
/// internal renewal) will refresh this before it expires; we set a deliberately
/// short-ish lease so a long sleep on the machine doesn't leave a stale forwarded port
/// pointing at us forever.</summary>
private const int MappingLeaseSeconds = 3600;
private readonly Action<string>? log;
private readonly object gate = new();
private INatDevice? device;
private Mapping? mapping;
private IPAddress? externalAddress;
private string lastError = "";
private RouterMappingStatus status = RouterMappingStatus.Disabled;
private bool searching;
private bool disposed;
/// <summary>Raised whenever <see cref="Status"/> changes. Always fires on a thread-pool
/// thread — the caller is responsible for marshaling onto the UI thread if it touches
/// UI state.</summary>
public event EventHandler? StatusChanged;
public RouterPortMapper(Action<string>? log = null)
{
this.log = log;
}
/// <summary>Current state of the mapping attempt. Read by the Preferences dialog to
/// keep its inline status label up to date.</summary>
public RouterMappingStatus Status
{
get { lock (gate) { return status; } }
}
/// <summary>The external (WAN-side) address and port the router reports for this
/// machine when the mapping is open. Null until <see cref="Status"/> is
/// <see cref="RouterMappingStatus.Mapped"/> or <see cref="RouterMappingStatus.CgnatDetected"/>.</summary>
public IPEndPoint? ExternalEndpoint
{
get
{
lock (gate)
{
return externalAddress is null ? null : new IPEndPoint(externalAddress, AudioPort);
}
}
}
/// <summary>Last error message captured during a failed mapping attempt — surfaced in
/// the status label so the user has a hint at what's going on.</summary>
public string LastError
{
get { lock (gate) { return lastError; } }
}
/// <summary>Start (or restart) the UPnP discovery + mapping cycle. Safe to call multiple
/// times; redundant calls are coalesced.</summary>
public void Start()
{
lock (gate)
{
if (disposed) return;
if (searching) return;
searching = true;
status = RouterMappingStatus.Searching;
lastError = "";
}
RaiseChanged();
try
{
NatUtility.DeviceFound += OnDeviceFound;
NatUtility.StartDiscovery();
log?.Invoke("UPnP discovery started");
}
catch (Exception ex)
{
lock (gate)
{
searching = false;
status = RouterMappingStatus.MappingFailed;
lastError = ex.Message;
}
log?.Invoke($"UPnP discovery could not start: {ex.GetType().Name}: {ex.Message}");
RaiseChanged();
}
// Mono.Nat doesn't fire DeviceFound at all when the network has no UPnP / NAT-PMP /
// PCP router. Without a timeout the status would sit at Searching forever, which the
// user-facing label reads as "still trying" indefinitely. Give it a reasonable window
// and then declare no-router-found if nothing has replied.
ThreadPool.QueueUserWorkItem(_ =>
{
Thread.Sleep(TimeSpan.FromSeconds(8));
bool stillSearching;
lock (gate)
{
stillSearching = searching && status == RouterMappingStatus.Searching;
}
if (!stillSearching) return;
lock (gate)
{
searching = false;
status = RouterMappingStatus.NoRouterFound;
lastError = "";
}
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
log?.Invoke("UPnP discovery timed out — no router responded");
RaiseChanged();
});
}
/// <summary>Re-run discovery and re-create the mapping. Used by the resume handler to
/// recover from routers that drop NAT entries during the user's sleep window.</summary>
public void Refresh()
{
lock (gate)
{
if (disposed) return;
}
log?.Invoke("UPnP refresh requested");
// Drop any existing mapping; Start() will rediscover and remap.
RemoveMappingBestEffort();
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
lock (gate)
{
searching = false;
status = RouterMappingStatus.Disabled;
device = null;
mapping = null;
externalAddress = null;
}
RaiseChanged();
Start();
}
/// <summary>Politely remove the mapping and stop discovery. Safe to call from
/// <c>FormClosing</c> or app shutdown.</summary>
public void Stop()
{
lock (gate)
{
if (disposed) return;
}
RemoveMappingBestEffort();
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
try { NatUtility.DeviceFound -= OnDeviceFound; } catch { /* ignore */ }
lock (gate)
{
searching = false;
status = RouterMappingStatus.Disabled;
device = null;
mapping = null;
externalAddress = null;
lastError = "";
}
log?.Invoke("UPnP stopped");
RaiseChanged();
}
public void Dispose()
{
if (disposed) return;
disposed = true;
try { Stop(); } catch { /* shutting down */ }
}
private void OnDeviceFound(object? sender, DeviceEventArgs args)
{
try
{
var found = args.Device;
log?.Invoke($"UPnP device found: {found.GetType().Name}");
// Add the mapping. Mono.Nat's CreatePortMap is synchronous-but-quick; doing it
// on the discovery thread is acceptable. If the same port is already mapped to
// a different internal IP, the router will reject — surface that as MappingFailed
// so the Preferences label can tell the user.
try
{
var m = new Mapping(Protocol.Udp, AudioPort, AudioPort, MappingLeaseSeconds, "RemSound audio");
found.CreatePortMap(m);
IPAddress? ext = null;
try { ext = found.GetExternalIP(); }
catch (Exception ipEx) { log?.Invoke($"UPnP GetExternalIP failed: {ipEx.GetType().Name}: {ipEx.Message}"); }
lock (gate)
{
device = found;
mapping = m;
externalAddress = ext;
searching = false;
// Detect CGNAT — RFC 6598 reserves 100.64.0.0/10 for carrier-grade NAT.
// If the router's "external" address is in that range, UPnP succeeded
// but we're still behind another tier of NAT we can't open.
if (ext is not null && IsCgnatAddress(ext))
{
status = RouterMappingStatus.CgnatDetected;
lastError = "";
log?.Invoke($"UPnP mapping added but external address {ext} is in the CGNAT range — peers will not reach this machine via UPnP alone");
}
else
{
status = RouterMappingStatus.Mapped;
lastError = "";
log?.Invoke($"UPnP mapping added: external {ext}:{AudioPort} -> internal :{AudioPort}");
}
}
}
catch (Exception ex)
{
lock (gate)
{
searching = false;
status = RouterMappingStatus.MappingFailed;
lastError = ex.Message;
}
log?.Invoke($"UPnP mapping creation failed: {ex.GetType().Name}: {ex.Message}");
}
}
catch (Exception ex)
{
log?.Invoke($"UPnP DeviceFound handler threw: {ex.GetType().Name}: {ex.Message}");
}
RaiseChanged();
}
private void RemoveMappingBestEffort()
{
INatDevice? d;
Mapping? m;
lock (gate)
{
d = device;
m = mapping;
}
if (d is null || m is null) return;
try
{
d.DeletePortMap(m);
log?.Invoke($"UPnP mapping removed (port {AudioPort})");
}
catch (Exception ex)
{
log?.Invoke($"UPnP mapping removal failed (harmless — the router will expire it): {ex.GetType().Name}: {ex.Message}");
}
}
private static bool IsCgnatAddress(IPAddress addr)
{
if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false;
var b = addr.GetAddressBytes();
// 100.64.0.0/10 — RFC 6598 shared address space for CGNAT.
return b[0] == 100 && b[1] >= 64 && b[1] <= 127;
}
private void RaiseChanged()
{
try { StatusChanged?.Invoke(this, EventArgs.Empty); }
catch { /* event handlers shouldn't escape on their own thread */ }
}
}
@@ -0,0 +1,212 @@
using System;
using System.Windows.Forms;
namespace RemSound.App;
/// <summary>
/// Brief heads-up shown when the silent-install path is about to swap the app out from
/// under the user. Why this exists: the startup-on-launch update check + the
/// "silently install" tick combine into a UX where the user opens RemSound, expects it
/// to start streaming, and instead the app exits and rebuilds itself a few seconds in.
/// Without a notice the user has no idea why the window vanished — they assume a crash.
///
/// The dialog is deliberately small and short-lived:
/// * Default focus and AcceptButton sit on "Install now" so a screen-reader user
/// pressing Enter to confirm the dialog gets the same outcome as the countdown
/// elapsing.
/// * "Skip this version" returns <see cref="DialogResult.Ignore"/> so the caller can
/// log the skip and decline to install on this launch.
/// * "Postpone (close)" returns <see cref="DialogResult.Cancel"/> — the install will
/// be re-attempted on the next periodic poll or next launch.
/// * A countdown timer auto-triggers Install after a few seconds so the silent path
/// remains effectively silent — the user who walks away from their desk during boot
/// gets the install they asked for, while a user who's at the keyboard has a moment
/// to intervene. The countdown is announced inline (label text changes), so NVDA
/// reads each tick if the user is reading the dialog when it appears.
///
/// NVDA accessibility: the dialog uses standard WinForms <see cref="Button"/> and
/// <see cref="Label"/>; AccessibleName is set explicitly on the heading and countdown
/// so the screen reader reads both as the dialog opens. AcceptButton + CancelButton
/// are wired so Enter / Esc do the obvious thing.
///
/// The dialog is intentionally NOT a TaskDialog — the verification-checkbox
/// "Do not show me this message again" pattern doesn't fit here because the suppression
/// would defeat the entire point of the notice (silent install with NO indication is
/// exactly the problem we're solving). If the user doesn't want the heads-up, they can
/// untick "Silently install updates" in Preferences.
/// </summary>
internal sealed class UpdateInstallNoticeDialog : Form
{
/// <summary>Seconds to wait before auto-confirming Install. Short enough that a user
/// who walks away from their desk during boot still gets the update they asked for;
/// long enough that someone at the keyboard can read the version and pick a button.</summary>
private const int CountdownSeconds = 8;
private readonly Label headingLabel;
private readonly Label countdownLabel;
private readonly Button installNowButton;
private readonly Button skipButton;
private readonly Button postponeButton;
private readonly System.Windows.Forms.Timer countdownTimer = new();
private int secondsRemaining = CountdownSeconds;
public UpdateInstallNoticeDialog(UpdateInfo info)
{
if (info is null) throw new ArgumentNullException(nameof(info));
Text = "Installing RemSound update";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterParent;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
KeyPreview = true;
ClientSize = new Size(500, 220);
AccessibleName = "Installing RemSound update";
headingLabel = new Label
{
Text = $"RemSound {info.Tag} is ready to install.",
AccessibleName = $"RemSound {info.Tag} is ready to install",
AutoSize = true,
Font = new Font(Font, FontStyle.Bold),
Padding = new Padding(0, 0, 0, 8),
};
// Body — explains what's about to happen so the user isn't surprised by the exit.
// Wrapped Label rather than TextBox because TextBox steals focus from the default
// button and breaks the NVDA reading order.
var bodyLabel = new Label
{
Text = "RemSound will install the update and restart automatically. Your session will pick up again once the new version is running.",
AccessibleName = "RemSound will install the update and restart automatically. Your session will pick up again once the new version is running.",
AutoSize = false,
Width = 460,
Height = 50,
Padding = new Padding(0, 0, 0, 8),
};
countdownLabel = new Label
{
Text = FormatCountdownText(secondsRemaining),
AccessibleName = FormatCountdownText(secondsRemaining),
AutoSize = true,
Padding = new Padding(0, 0, 0, 8),
};
installNowButton = new Button
{
Text = "&Install now",
AccessibleName = "Install now",
AutoSize = true,
DialogResult = DialogResult.OK,
TabIndex = 0,
};
skipButton = new Button
{
Text = "&Skip this version",
AccessibleName = "Skip this version",
AutoSize = true,
DialogResult = DialogResult.Ignore,
TabIndex = 1,
};
postponeButton = new Button
{
Text = "&Postpone",
AccessibleName = "Postpone",
AutoSize = true,
DialogResult = DialogResult.Cancel,
TabIndex = 2,
};
// Any explicit click stops the countdown — the user has made a choice and we
// shouldn't elapse-fire underneath them.
installNowButton.Click += (_, _) => countdownTimer.Stop();
skipButton.Click += (_, _) => countdownTimer.Stop();
postponeButton.Click += (_, _) => countdownTimer.Stop();
var body = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(16, 14, 16, 12),
ColumnCount = 1,
RowCount = 4,
};
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
body.Controls.Add(headingLabel, 0, 0);
body.Controls.Add(bodyLabel, 0, 1);
body.Controls.Add(countdownLabel, 0, 2);
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 0, 16, 12),
};
// RightToLeft fills back to front, so add the rightmost button (Install now) first.
buttonRow.Controls.Add(installNowButton);
buttonRow.Controls.Add(postponeButton);
buttonRow.Controls.Add(skipButton);
Controls.Add(body);
Controls.Add(buttonRow);
AcceptButton = installNowButton;
CancelButton = postponeButton;
// Esc = postpone (matches CancelButton). Avoids the "I just opened the app, where
// did the window go" surprise if the user mashes Esc to dismiss whatever popped up.
KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Escape)
{
countdownTimer.Stop();
DialogResult = DialogResult.Cancel;
Close();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
// Tick once per second, decrement, refresh the label, and fire OK when we hit zero.
countdownTimer.Interval = 1000;
countdownTimer.Tick += (_, _) =>
{
secondsRemaining--;
if (secondsRemaining <= 0)
{
countdownTimer.Stop();
DialogResult = DialogResult.OK;
Close();
return;
}
countdownLabel.Text = FormatCountdownText(secondsRemaining);
countdownLabel.AccessibleName = countdownLabel.Text;
};
// Start the countdown when the dialog appears, not when it's constructed —
// construction can happen a moment before ShowDialog hands the user focus.
Shown += (_, _) =>
{
countdownTimer.Start();
// Make sure the default action is Install (matches AcceptButton). Without this
// tab focus might rest on the first added Control rather than the intended
// primary action.
installNowButton.Focus();
};
FormClosed += (_, _) => countdownTimer.Stop();
}
private static string FormatCountdownText(int seconds)
{
return seconds == 1
? "Installing in 1 second... Press Skip or Postpone to choose otherwise."
: $"Installing in {seconds} seconds... Press Skip or Postpone to choose otherwise.";
}
}
+24
View File
@@ -49,6 +49,14 @@ public sealed class AppConfig
/// itself is the user-visible confirmation, so a follow-up popup is redundant.</summary> /// itself is the user-visible confirmation, so a follow-up popup is redundant.</summary>
public bool SaveProfileConfirmationSuppressed { get; set; } public bool SaveProfileConfirmationSuppressed { get; set; }
/// <summary>True if the user has ticked "do not show me this message again" on the
/// "this profile is read-only, save was skipped" popup. Once ticked, Ctrl+S / File → Save
/// on a read-only profile silently does nothing instead of explaining why — the user
/// has acknowledged that they know it's a no-op. Machine-local (not per-profile) so the
/// preference sticks across profile switches; the prompt itself is the same wording on
/// any read-only profile so a single dismissal applies everywhere. 2026-05-22.</summary>
public bool SaveOnReadOnlyMessageSuppressed { get; set; }
/// <summary>If true, RemSound minimises to the system tray immediately after the main /// <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 /// window finishes loading. Lets the user "boot up the machine and have RemSound
/// already running quietly". Default false.</summary> /// already running quietly". Default false.</summary>
@@ -83,6 +91,22 @@ public sealed class AppConfig
/// RemSound. Default false — the user gets a confirmation dialog before each install.</summary> /// RemSound. Default false — the user gets a confirmation dialog before each install.</summary>
public bool SilentlyInstallUpdates { get; set; } public bool SilentlyInstallUpdates { get; set; }
/// <summary>If true (the default), RemSound runs an update check shortly after launch in
/// addition to whatever <see cref="UpdateCheckFrequency"/> drives in the background. The
/// startup check is what catches users who quit and re-open the app within the polling
/// interval — without it they could miss an update for hours. Set to false to disable the
/// startup check; the periodic timer (if set) still runs.</summary>
public bool CheckForUpdatesOnStartup { get; set; } = true;
/// <summary>If true, RemSound tries to open the audio port (UDP 47830) on the local router
/// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine
/// without manual port forwarding. Default false — the toggle opt-in only, because some
/// networks (corporate, hostile shared) shouldn't have apps poking the router. When
/// successful, RemSound surfaces the external address in the Preferences dialog so the
/// user knows what to give peers. Falls back gracefully when the router doesn't support
/// UPnP — RemSound just doesn't open anything.</summary>
public bool UpnpEnabled { get; set; }
/// <summary>UTC timestamp of the last successful update check. Used by the background /// <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 /// 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 /// "every 24 hours" and re-launch the app three times that day, it still hits the API
+112 -34
View File
@@ -6,11 +6,29 @@ namespace RemSound.Core;
/// between consecutive samples of the same channel; a typical "click" in real audio shows /// between consecutive samples of the same channel; a typical "click" in real audio shows
/// up as a step well above what naturally occurs in music or speech content. /// up as a step well above what naturally occurs in music or speech content.
/// ///
/// Each probe holds the maximum step observed across all calls to <see cref="ScanStereo"/> /// 2026-05-21: split the max into TWO independent counters so we can tell the difference
/// since the last <see cref="TakeMax"/>. The diag log polls TakeMax once per second to /// between a sharp transient inside a buffer (natural-looking real audio content) and a
/// emit the worst step at that pipeline stage. Comparing the max across stages — sender /// discontinuity at the buffer / packet boundary (samples that are not adjacent in time —
/// pre-encode, receiver post-decode, receiver post-ring-read, receiver post-resampler, /// i.e. something we lost, duplicated, or mis-stitched in the pipeline). The plain
/// final output — reveals which stage introduces the click. /// <see cref="TakeMax"/> still returns the larger of the two for back-compat, but the
/// <see cref="TakeMaxCrossBuffer"/> / <see cref="TakeMaxWithinBuffer"/> pair lets the diag
/// logger emit both, so a click event in the log clearly says which side it's on:
///
/// * <c>stepXB</c> — first sample of a new delivered buffer vs the last sample of the
/// previous one (i.e. the cross-buffer carry). A non-zero value here means our chain
/// received samples that aren't contiguous with what came before — driver bug, library
/// misalignment, or a sample drop/duplicate at a boundary. The buffer in question is
/// whatever the caller passes to <see cref="ScanStereo"/> / <see cref="ScanInterleavedChannel"/>:
/// ASIO/WASAPI capture callback in the raw probes, an OnMixedSamples callback in the
/// pre-encode probe, a PCM packet in the post-decode probe, and so on.
/// * <c>stepWB</c> — two samples that ARE adjacent in the same delivered buffer. A
/// non-zero value here is just real audio content with a sharp edge; loud transients,
/// percussion hits, the start of a syllable.
///
/// Each probe holds the maximum step observed since the last drain. The diag log polls the
/// drains once per second to emit the worst step at that pipeline stage. Comparing the max
/// across stages — sender pre-encode, receiver post-decode, receiver post-ring-read,
/// receiver post-resampler, final output — reveals which stage introduces the click.
/// ///
/// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so /// Thread model: writes are lock-free CAS-update of a long-encoded float bit pattern (so
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic. /// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
@@ -20,7 +38,11 @@ namespace RemSound.Core;
/// </summary> /// </summary>
public sealed class AudioStepProbe public sealed class AudioStepProbe
{ {
private long maxStepBits; // Two separate maxes — see class comment. Both are float bits packed into a long so the
// CAS-update loop can flip them atomically from any thread. Encoded as Int32 -> long
// because BitConverter.SingleToInt32Bits is the cheapest float<->int32 bridge.
private long maxCrossBufferStepBits;
private long maxWithinBufferStepBits;
// Remember the last sample on each channel so the next scan can compute the cross-buffer // Remember the last sample on each channel so the next scan can compute the cross-buffer
// step. Without this we'd miss any discontinuity at the buffer boundary (the most // step. Without this we'd miss any discontinuity at the buffer boundary (the most
// suspicious place — that's where copies, format conversions and resampler hand-offs // suspicious place — that's where copies, format conversions and resampler hand-offs
@@ -40,7 +62,8 @@ public sealed class AudioStepProbe
if (!DiagnosticsGate.Enabled) return; if (!DiagnosticsGate.Enabled) return;
if (interleavedFloats.IsEmpty) return; if (interleavedFloats.IsEmpty) return;
if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return; if (channelCount <= 0 || channelIndex < 0 || channelIndex >= channelCount) return;
var max = ReadMax(); var maxCross = ReadMax(ref maxCrossBufferStepBits);
var maxWithin = ReadMax(ref maxWithinBufferStepBits);
// Use lastL as the cross-buffer carry for single-channel scans. (We don't need a // Use lastL as the cross-buffer carry for single-channel scans. (We don't need a
// separate "lastSingle" — every probe is consumed by exactly one caller at a time, so // separate "lastSingle" — every probe is consumed by exactly one caller at a time, so
// reusing the field is fine. The cross-buffer step is what matters for buffer-boundary // reusing the field is fine. The cross-buffer step is what matters for buffer-boundary
@@ -51,15 +74,35 @@ public sealed class AudioStepProbe
for (var i = 0; i < samples; i++) for (var i = 0; i < samples; i++)
{ {
var s = interleavedFloats[i * channelCount + channelIndex]; var s = interleavedFloats[i * channelCount + channelIndex];
if (i == 0 && !seedFromPrev) prev = s; if (i == 0)
var step = s - prev; {
if (step < 0f) step = -step; // First sample of this buffer. If we have a carry from the previous scan,
if (step > max) max = step; // compare it to that — that comparison IS the cross-buffer boundary step.
// If we don't (first ever call), seed prev with this sample so the inner-loop
// step calc starting at i=1 has a sensible reference.
if (seedFromPrev)
{
var step = s - prev;
if (step < 0f) step = -step;
if (step > maxCross) maxCross = step;
}
else
{
prev = s;
}
}
else
{
var step = s - prev;
if (step < 0f) step = -step;
if (step > maxWithin) maxWithin = step;
}
prev = s; prev = s;
} }
lastL = prev; lastL = prev;
hasLast = true; hasLast = true;
WriteMaxIfGreater(max); WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
} }
/// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe /// <summary>Scan an interleaved stereo float span and update the max step. Cheap; safe
@@ -68,62 +111,97 @@ public sealed class AudioStepProbe
{ {
if (!DiagnosticsGate.Enabled) return; if (!DiagnosticsGate.Enabled) return;
if (stereoFloats.IsEmpty) return; if (stereoFloats.IsEmpty) return;
var max = ReadMax(); var maxCross = ReadMax(ref maxCrossBufferStepBits);
var maxWithin = ReadMax(ref maxWithinBufferStepBits);
var prevL = lastL; var prevL = lastL;
var prevR = lastR; var prevR = lastR;
var seedFromPrev = hasLast; var seedFromPrev = hasLast;
// Pair walk. For samples after the first, compare to the previous sample of the // Pair walk. For samples after the first, compare to the previous sample of the
// same channel from THIS buffer. For the first pair, compare to the saved // same channel from THIS buffer (within-buffer). For the first pair, compare to the
// last-sample-from-the-previous-buffer if available. // saved last-sample-from-the-previous-buffer if available (cross-buffer).
for (var i = 0; i + 1 < stereoFloats.Length; i += 2) for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
{ {
var l = stereoFloats[i]; var l = stereoFloats[i];
var r = stereoFloats[i + 1]; var r = stereoFloats[i + 1];
float stepL, stepR;
if (i == 0) if (i == 0)
{ {
if (!seedFromPrev) { prevL = l; prevR = r; } if (seedFromPrev)
stepL = l - prevL; {
stepR = r - prevR; var stepL = l - prevL;
var stepR = r - prevR;
var absL = stepL < 0f ? -stepL : stepL;
var absR = stepR < 0f ? -stepR : stepR;
if (absL > maxCross) maxCross = absL;
if (absR > maxCross) maxCross = absR;
}
// If we don't have a carry, simply skip the comparison — the next iteration's
// within-buffer step (i=2 vs i=0) will be the first real measurement.
} }
else else
{ {
stepL = l - stereoFloats[i - 2]; var stepL = l - stereoFloats[i - 2];
stepR = r - stereoFloats[i - 1]; var stepR = r - stereoFloats[i - 1];
var absL = stepL < 0f ? -stepL : stepL;
var absR = stepR < 0f ? -stepR : stepR;
if (absL > maxWithin) maxWithin = absL;
if (absR > maxWithin) maxWithin = absR;
} }
var absL = stepL < 0f ? -stepL : stepL;
var absR = stepR < 0f ? -stepR : stepR;
if (absL > max) max = absL;
if (absR > max) max = absR;
} }
// Save the last sample of this buffer for the next scan. // Save the last sample of this buffer for the next scan.
var lastIdx = stereoFloats.Length - 2; var lastIdx = stereoFloats.Length - 2;
lastL = stereoFloats[lastIdx]; lastL = stereoFloats[lastIdx];
lastR = stereoFloats[lastIdx + 1]; lastR = stereoFloats[lastIdx + 1];
hasLast = true; hasLast = true;
WriteMaxIfGreater(max); WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
} }
/// <summary>Atomic snapshot of the current max + reset to zero. Returns the value as /// <summary>Atomic snapshot of the current maxes + reset to zero. Returns the larger of
/// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample /// the cross-buffer and within-buffer maxes — preserves the pre-2026-05-21 semantics for
/// step, which is a 6 dB jump and definitely audible).</summary> /// callers that just want "the worst step we saw at this stage". For the cross/within
/// split, use <see cref="TakeMaxCrossBuffer"/> + <see cref="TakeMaxWithinBuffer"/>
/// instead; calling either of those drains its own counter independently of this one,
/// so a caller wanting the split must NOT also call <c>TakeMax</c> in the same
/// 1-second window.</summary>
public float TakeMax() public float TakeMax()
{ {
var bits = Interlocked.Exchange(ref maxStepBits, 0); var c = TakeMaxCrossBuffer();
var w = TakeMaxWithinBuffer();
return c > w ? c : w;
}
/// <summary>Atomic snapshot + reset of the cross-buffer max. A non-zero value here means
/// the first sample of some delivered buffer did NOT continue smoothly from the last
/// sample of the previous buffer — i.e. samples not adjacent in time. Strong signal that
/// the pipeline lost, duplicated, or mis-stitched a buffer boundary.</summary>
public float TakeMaxCrossBuffer()
{
var bits = Interlocked.Exchange(ref maxCrossBufferStepBits, 0);
return BitConverter.Int32BitsToSingle((int)bits); return BitConverter.Int32BitsToSingle((int)bits);
} }
private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits)); /// <summary>Atomic snapshot + reset of the within-buffer max. A non-zero value here just
/// means there was a sharp transient INSIDE a delivered buffer — almost always real audio
/// content (percussion hit, syllable onset, etc.). Useful as the "this is just music"
/// baseline against which the cross-buffer max is interpreted.</summary>
public float TakeMaxWithinBuffer()
{
var bits = Interlocked.Exchange(ref maxWithinBufferStepBits, 0);
return BitConverter.Int32BitsToSingle((int)bits);
}
private void WriteMaxIfGreater(float candidate) private static float ReadMax(ref long field) =>
BitConverter.Int32BitsToSingle((int)Volatile.Read(ref field));
private static void WriteMaxIfGreater(ref long field, float candidate)
{ {
var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate); var candidateBits = (long)BitConverter.SingleToInt32Bits(candidate);
long current; long current;
do do
{ {
current = Volatile.Read(ref maxStepBits); current = Volatile.Read(ref field);
var currentValue = BitConverter.Int32BitsToSingle((int)current); var currentValue = BitConverter.Int32BitsToSingle((int)current);
if (candidate <= currentValue) return; if (candidate <= currentValue) return;
} while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current); } while (Interlocked.CompareExchange(ref field, candidateBits, current) != current);
} }
} }
+13
View File
@@ -23,6 +23,19 @@ public sealed class Profile
/// <summary>Display title and filename stem (sanitised). Required.</summary> /// <summary>Display title and filename stem (sanitised). Required.</summary>
public string Title { get; set; } = ""; public string Title { get; set; } = "";
/// <summary>If true, this profile is loaded for use but the app never writes the user's
/// in-session changes back to disk: Ctrl+S / File → Save politely refuses (with a "use
/// Save As instead" message), and FormClosing skips its usual "save changes?" prompt
/// entirely. Whatever the user fiddled with this session is kept in memory until the
/// app closes and then discarded; the file on disk stays exactly as it was. Off by
/// default. Toggled per-profile via File → Lock profile (read-only). Use case: a
/// "default" profile you want to live in and toggle send/receive on without the close
/// prompt blocking shutdown — important for users who can't reach the prompt because
/// they're remote, or because the screen reader has crashed, or because the laptop is
/// hibernating. The flag is the *only* property the lock-toggle writes back to disk;
/// any other in-session edits stay session-only. 2026-05-22.</summary>
public bool ReadOnly { get; set; }
// === Main form: send / receive === // === Main form: send / receive ===
public bool ReceiveAudioOn { get; set; } public bool ReceiveAudioOn { get; set; }
public bool SendAudioOn { get; set; } public bool SendAudioOn { get; set; }
+23
View File
@@ -67,6 +67,29 @@ public sealed class ProfileStore
} }
} }
/// <summary>Returns whether the profile with the given title has its ReadOnly flag set
/// on disk, without doing a full <see cref="Load"/>. Used by the startup profile picker
/// to label locked profiles in the list ("Title (read-only)") so the user knows what
/// they're picking. Returns false on any error — the picker treats unreadable profiles
/// as not-read-only, which is the safer default (the worst case is the user gets the
/// normal save-prompt behaviour on close, which is what they're already used to).</summary>
public bool IsProfileReadOnly(string title)
{
if (string.IsNullOrWhiteSpace(title)) return false;
var path = PathFor(title);
if (!File.Exists(path)) return false;
try
{
var json = File.ReadAllText(path);
var profile = JsonSerializer.Deserialize<Profile>(json);
return profile?.ReadOnly ?? false;
}
catch
{
return false;
}
}
/// <summary>Loads a profile by title. Returns null if the file is missing or /// <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 — /// 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> /// the caller can surface a diagnostic and fall back to a blank template.</summary>
+45 -1
View File
@@ -241,6 +241,13 @@ public sealed class AudioReceiver : IDisposable
/// contributions. Resets on read.</summary> /// contributions. Resets on read.</summary>
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs(); public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
/// <summary>Worst inter-packet arrival gap (ms) at the user-space UDP socket since the
/// last call. Resets on read. Compared with the sender's per-callback gap on the other
/// machine, this localises a stall: if the sender's send-callback gap is small but this
/// is large, the OS/network between sender and receiver delayed delivery (NIC IRQ
/// servicing, scheduler not waking our receive thread, kernel batching). 2026-05-21.</summary>
public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in /// <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 /// 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 /// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
@@ -299,7 +306,8 @@ public sealed class AudioReceiver : IDisposable
/// <summary>Take the worst post-decode single-sample step magnitude across all active /// <summary>Take the worst post-decode single-sample step magnitude across all active
/// stream sessions since the last call, resetting each session's probe. Used by the /// stream sessions since the last call, resetting each session's probe. Used by the
/// diag log to pinpoint where in the pipeline audio discontinuities are being /// diag log to pinpoint where in the pipeline audio discontinuities are being
/// introduced.</summary> /// introduced. Returns max-of-(cross, within); for the split values use the XB/WB
/// methods below and do NOT also call this in the same drain window.</summary>
public float TakeMaxPostDecodeStep() public float TakeMaxPostDecodeStep()
{ {
lock (sessionsLock) lock (sessionsLock)
@@ -314,6 +322,38 @@ public sealed class AudioReceiver : IDisposable
} }
} }
/// <summary>Cross-buffer (packet-boundary) max post-decode step across all sessions.
/// Drains each session's cross-buffer counter. 2026-05-21 addition for the click hunt.</summary>
public float TakeMaxPostDecodeStepCrossBuffer()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
}
/// <summary>Within-buffer (in-packet content) max post-decode step across all sessions.
/// Drains each session's within-buffer counter. 2026-05-21 addition for the click hunt.</summary>
public float TakeMaxPostDecodeStepWithinBuffer()
{
lock (sessionsLock)
{
var max = 0f;
foreach (var s in sessions.Values)
{
var v = s.TakeMaxPostDecodeStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
}
public long PcmFrameDiscardedPartials public long PcmFrameDiscardedPartials
{ {
get get
@@ -395,8 +435,12 @@ public sealed class AudioReceiver : IDisposable
/// <summary>Take the worst single-sample step out of the ring buffer (after decode + /// <summary>Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.</summary> /// SessionPlayout.Write, before resampler) since the last call.</summary>
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep(); public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
public float TakeMaxPostRingReadStepCrossBuffer() => playoutEngine.TakeMaxPostRingReadStepCrossBuffer();
public float TakeMaxPostRingReadStepWithinBuffer() => playoutEngine.TakeMaxPostRingReadStepWithinBuffer();
/// <summary>Take the worst single-sample step out of the resampler since the last call.</summary> /// <summary>Take the worst single-sample step out of the resampler since the last call.</summary>
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep(); public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
public float TakeMaxPostResamplerStepCrossBuffer() => playoutEngine.TakeMaxPostResamplerStepCrossBuffer();
public float TakeMaxPostResamplerStepWithinBuffer() => playoutEngine.TakeMaxPostResamplerStepWithinBuffer();
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain /// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or /// 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 /// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
+33 -1
View File
@@ -31,6 +31,18 @@ internal sealed class NetworkListener : IDisposable
public int TakeMaxOnPacketMs() => public int TakeMaxOnPacketMs() =>
(int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency); (int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency);
// Inter-packet arrival gap at the user-space socket. Measures the elapsed time between
// consecutive `ReceiveFrom` returns. This is the diagnostic that splits "the sender
// stalled" from "the network jittered" from "the OS sat on packets before delivering
// them to our process" — by comparing this with the sender's per-callback gap on the
// other machine, we can tell which side introduced the arrival gap that triggered a
// concealment-fire / underrun. The probe is gated on DiagnosticsGate.Enabled exactly
// like the OnPacket timer above; pays nothing when logs are off. 2026-05-21.
private long maxInterPacketGapTicks;
private long lastReceiveTicks;
public int TakeMaxInterPacketGapMs() =>
(int)(Interlocked.Exchange(ref maxInterPacketGapTicks, 0) * 1000 / Stopwatch.Frequency);
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic) public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
{ {
this.onPacket = onPacket; this.onPacket = onPacket;
@@ -72,6 +84,11 @@ internal sealed class NetworkListener : IDisposable
thread = null; thread = null;
cts?.Dispose(); cts?.Dispose();
cts = null; cts = null;
// Reset the inter-packet timestamp so a Restart doesn't measure the long pause
// between the previous session's last packet and the new session's first as a
// spurious huge gap.
Interlocked.Exchange(ref lastReceiveTicks, 0);
Interlocked.Exchange(ref maxInterPacketGapTicks, 0);
} }
public void Dispose() => Stop(); public void Dispose() => Stop();
@@ -104,7 +121,22 @@ internal sealed class NetworkListener : IDisposable
// per packet for a number nobody is going to log. // per packet for a number nobody is going to log.
if (RemSound.Core.DiagnosticsGate.Enabled) if (RemSound.Core.DiagnosticsGate.Enabled)
{ {
var dispatchStart = Stopwatch.GetTimestamp(); var nowTicks = Stopwatch.GetTimestamp();
// Inter-packet arrival gap. First packet seeds lastReceiveTicks without
// recording a gap (no previous to compare to). Subsequent packets compute
// elapsed-since-previous-ReceiveFrom-returned. The window includes our
// onPacket processing, but that's typically sub-millisecond — so a spike
// here points at the OS/network layer below us, not at our dispatch work.
// Our work shows up separately in maxOnPacketTicks.
var prevReceiveTicks = Interlocked.Exchange(ref lastReceiveTicks, nowTicks);
if (prevReceiveTicks != 0)
{
var gap = nowTicks - prevReceiveTicks;
long curGap;
do { curGap = Volatile.Read(ref maxInterPacketGapTicks); }
while (gap > curGap && Interlocked.CompareExchange(ref maxInterPacketGapTicks, gap, curGap) != curGap);
}
var dispatchStart = nowTicks;
onPacket(buffer, received, remote); onPacket(buffer, received, remote);
var elapsed = Stopwatch.GetTimestamp() - dispatchStart; var elapsed = Stopwatch.GetTimestamp() - dispatchStart;
long current; long current;
+57 -2
View File
@@ -495,7 +495,9 @@ internal sealed class PlayoutEngine : IWaveProvider
/// <summary>Worst single-sample step seen out of the ring buffer since the last call. /// <summary>Worst single-sample step seen out of the ring buffer since the last call.
/// Compared against the sender's pre-encode probe and the session's post-resampler /// Compared against the sender's pre-encode probe and the session's post-resampler
/// probe, this locates where in the pipeline an audio discontinuity was introduced. /// probe, this locates where in the pipeline an audio discontinuity was introduced.
/// Takes the max across all sessions and resets each.</summary> /// Takes the max across all sessions and resets each. Returns max-of-(cross, within);
/// for the split, use the XB/WB variants and do NOT also call this in the same drain
/// window.</summary>
public float TakeMaxPostRingReadStep() public float TakeMaxPostRingReadStep()
{ {
var snap = sessionsSnapshot; var snap = sessionsSnapshot;
@@ -508,9 +510,36 @@ internal sealed class PlayoutEngine : IWaveProvider
return max; return max;
} }
/// <summary>Cross-buffer (read-boundary) max post-ring-read step across all sessions.</summary>
public float TakeMaxPostRingReadStepCrossBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Within-buffer max post-ring-read step across all sessions.</summary>
public float TakeMaxPostRingReadStepWithinBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostRingReadStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Worst single-sample step in the resampler output since the last call. /// <summary>Worst single-sample step in the resampler output since the last call.
/// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the /// Significantly larger than <see cref="TakeMaxPostRingReadStep"/> would point the
/// finger at the resampler integration.</summary> /// finger at the resampler integration. Returns max-of-(cross, within); use the XB/WB
/// variants for the split.</summary>
public float TakeMaxPostResamplerStep() public float TakeMaxPostResamplerStep()
{ {
var snap = sessionsSnapshot; var snap = sessionsSnapshot;
@@ -523,6 +552,32 @@ internal sealed class PlayoutEngine : IWaveProvider
return max; return max;
} }
/// <summary>Cross-buffer max post-resampler step across all sessions.</summary>
public float TakeMaxPostResamplerStepCrossBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStepCrossBuffer();
if (v > max) max = v;
}
return max;
}
/// <summary>Within-buffer max post-resampler step across all sessions.</summary>
public float TakeMaxPostResamplerStepWithinBuffer()
{
var snap = sessionsSnapshot;
var max = 0f;
foreach (var s in snap)
{
var v = s.TakeMaxPostResamplerStepWithinBuffer();
if (v > max) max = v;
}
return max;
}
// === WASAPI render thread === // === WASAPI render thread ===
/// <summary> /// <summary>
+4
View File
@@ -226,6 +226,10 @@ internal sealed class SessionPlayout : IDisposable
private readonly AudioStepProbe postResamplerStepProbe = new(); private readonly AudioStepProbe postResamplerStepProbe = new();
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax(); public float TakeMaxPostRingReadStep() => postRingReadStepProbe.TakeMax();
public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax(); public float TakeMaxPostResamplerStep() => postResamplerStepProbe.TakeMax();
public float TakeMaxPostRingReadStepCrossBuffer() => postRingReadStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostRingReadStepWithinBuffer() => postRingReadStepProbe.TakeMaxWithinBuffer();
public float TakeMaxPostResamplerStepCrossBuffer() => postResamplerStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostResamplerStepWithinBuffer() => postResamplerStepProbe.TakeMaxWithinBuffer();
// Concealment vs partial-read counters split from the legacy "Underruns" — that one // Concealment vs partial-read counters split from the legacy "Underruns" — that one
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0 // increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
+2
View File
@@ -53,6 +53,8 @@ internal sealed class StreamSession : IDisposable
// samples a moment later (after riding through the ring buffer). // samples a moment later (after riding through the ring buffer).
private readonly AudioStepProbe postDecodeStepProbe = new(); private readonly AudioStepProbe postDecodeStepProbe = new();
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax(); public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
public float TakeMaxPostDecodeStepCrossBuffer() => postDecodeStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPostDecodeStepWithinBuffer() => postDecodeStepProbe.TakeMaxWithinBuffer();
// === Wire-level sequence tracking (Phase 5, 2026-05-14) === // === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
// Every audio packet carries a wire sequence number that monotonically increases per // Every audio packet carries a wire sequence number that monotonically increases per
@@ -88,6 +88,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
onMixedSamples = callback; onMixedSamples = callback;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax(); public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public bool IsRunning => asio is not null; public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount); public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
+11
View File
@@ -141,12 +141,23 @@ public sealed class AudioSender : IDisposable
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep(); public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep(); public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
// Cross-buffer (boundary) and within-buffer (content) split — see AudioStepProbe for the
// diagnostic distinction. Used by the per-second diag logger to emit two extra columns so
// an offline log inspection can tell a real audio transient apart from a buffer-boundary
// glitch. 2026-05-21 addition.
public float TakeMaxPreEncodeStepWasapiLaneCrossBuffer() => defaultLane.TakeMaxPreEncodeStepCrossBuffer();
public float TakeMaxPreEncodeStepWasapiLaneWithinBuffer() => defaultLane.TakeMaxPreEncodeStepWithinBuffer();
public float TakeMaxPreEncodeStepAsioLaneCrossBuffer() => asioLane.TakeMaxPreEncodeStepCrossBuffer();
public float TakeMaxPreEncodeStepAsioLaneWithinBuffer() => asioLane.TakeMaxPreEncodeStepWithinBuffer();
// Raw capture-side step probe — now lives inside each <see cref="ICaptureBackend"/> // Raw capture-side step probe — now lives inside each <see cref="ICaptureBackend"/>
// implementation so the ASIO path and the WASAPI path each measure their own buffers // implementation so the ASIO path and the WASAPI path each measure their own buffers
// independently. The aggregate just asks the backend for the max since last read; in // independently. The aggregate just asks the backend for the max since last read; in
// BothIndependent mode the composite backend forwards to both inners and returns the // BothIndependent mode the composite backend forwards to both inners and returns the
// larger value. // larger value.
public float TakeMaxSenderRawCaptureStep() => engine.TakeMaxRawCaptureStep(); public float TakeMaxSenderRawCaptureStep() => engine.TakeMaxRawCaptureStep();
public float TakeMaxSenderRawCaptureStepCrossBuffer() => engine.TakeMaxRawCaptureStepCrossBuffer();
public float TakeMaxSenderRawCaptureStepWithinBuffer() => engine.TakeMaxRawCaptureStepWithinBuffer();
// Snapshot the cumulative "hit the hard clamp" sample counter. The sender's mix path // Snapshot the cumulative "hit the hard clamp" sample counter. The sender's mix path
// clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path // clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path
@@ -129,6 +129,22 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
return w > a ? w : a; return w > a ? w : a;
} }
/// <summary>Cross-buffer (boundary) max across both inner backends. Drains BOTH.</summary>
public float TakeMaxRawCaptureStepCrossBuffer()
{
var w = wasapi?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f;
var a = asio?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f;
return w > a ? w : a;
}
/// <summary>Within-buffer max across both inner backends. Drains BOTH.</summary>
public float TakeMaxRawCaptureStepWithinBuffer()
{
var w = wasapi?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f;
var a = asio?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f;
return w > a ? w : a;
}
public void Start(IReadOnlyList<CaptureSourceSpec> specs) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
lock (gate) lock (gate)
+17 -1
View File
@@ -49,9 +49,25 @@ internal interface ICaptureBackend : IDisposable
/// the last call; resets on read. Each backend owns its own probe instance so the /// the last call; resets on read. Each backend owns its own probe instance so the
/// cross-buffer step measurement doesn't get fooled by another backend's interleaved /// cross-buffer step measurement doesn't get fooled by another backend's interleaved
/// callbacks (which is what produced spurious 0.4-0.5 readings in BothIndependent mode /// callbacks (which is what produced spurious 0.4-0.5 readings in BothIndependent mode
/// before 2026-05-15). Backends that can't sensibly expose raw samples return 0.</summary> /// before 2026-05-15). Backends that can't sensibly expose raw samples return 0.
/// Returns the max-of-(cross-buffer, within-buffer); for the split values use
/// <see cref="TakeMaxRawCaptureStepCrossBuffer"/> + <see cref="TakeMaxRawCaptureStepWithinBuffer"/>
/// and do NOT also call this in the same drain window.</summary>
float TakeMaxRawCaptureStep(); float TakeMaxRawCaptureStep();
/// <summary>Worst CROSS-BUFFER (boundary) raw-capture step since the last call. Non-zero =
/// the first sample of some delivered capture buffer didn't continue smoothly from the
/// last sample of the previous one. The clicks-at-buffer-boundary signal we're hunting.
/// Resets on read. Backends that can't sensibly expose raw samples return 0.</summary>
float TakeMaxRawCaptureStepCrossBuffer();
/// <summary>Worst WITHIN-BUFFER raw-capture step since the last call. Non-zero = a sharp
/// edge between two consecutive samples inside the same delivered buffer — typically
/// real audio content (percussion, syllable onset) rather than a pipeline glitch. The
/// "this is just music" baseline against which the cross-buffer reading is interpreted.
/// Resets on read. Backends that can't sensibly expose raw samples return 0.</summary>
float TakeMaxRawCaptureStepWithinBuffer();
void Start(IReadOnlyList<CaptureSourceSpec> specs); void Start(IReadOnlyList<CaptureSourceSpec> specs);
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes /// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
+2
View File
@@ -118,6 +118,8 @@ internal sealed class MixingEngine : ICaptureBackend
/// <see cref="PushModeWasapiBackend"/> instead. Stays at zero here; if a future /// <see cref="PushModeWasapiBackend"/> instead. Stays at zero here; if a future
/// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.</summary> /// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.</summary>
public float TakeMaxRawCaptureStep() => 0f; public float TakeMaxRawCaptureStep() => 0f;
public float TakeMaxRawCaptureStepCrossBuffer() => 0f;
public float TakeMaxRawCaptureStepWithinBuffer() => 0f;
/// <summary> /// <summary>
/// Starts the mix loop with the given initial source set. If already running, the existing /// Starts the mix loop with the given initial source set. If already running, the existing
@@ -109,6 +109,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
public int TakeMaxCallbackGapMs() => 0; public int TakeMaxCallbackGapMs() => 0;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax(); public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public void Start(IReadOnlyList<CaptureSourceSpec> specs) public void Start(IReadOnlyList<CaptureSourceSpec> specs)
{ {
+2
View File
@@ -62,6 +62,8 @@ internal sealed class SenderLane
// that without changing what the probe measures. // that without changing what the probe measures.
private readonly AudioStepProbe preEncodeStepProbe = new(); private readonly AudioStepProbe preEncodeStepProbe = new();
public float TakeMaxPreEncodeStep() => preEncodeStepProbe.TakeMax(); public float TakeMaxPreEncodeStep() => preEncodeStepProbe.TakeMax();
public float TakeMaxPreEncodeStepCrossBuffer() => preEncodeStepProbe.TakeMaxCrossBuffer();
public float TakeMaxPreEncodeStepWithinBuffer() => preEncodeStepProbe.TakeMaxWithinBuffer();
// Which render route this lane announces in its format packets. The receiver reads the // 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 // Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine