diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 4d2ab2d..22bf122 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -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
-1. Download `RemSound-v2.0.zip` from this release.
+1. Download `RemSound-v2.1.zip` from this release.
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.
Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch.
## 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.
diff --git a/readme.html b/readme.html
index fdaa47a..316b670 100644
--- a/readme.html
+++ b/readme.html
@@ -153,6 +153,35 @@ ul, ol { padding-left: 1.4em; }
Window position and size — Windows itself remembers these.
+Locking a profile (read-only)
+
+By default, RemSound treats your profile like a document: if you change something while it's running, you'll be asked “save changes?” when you exit. Most of the time that's exactly what you want — you don't lose work by accident.
+
+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) — because then there's a save prompt sitting on screen that nobody can dismiss, and the app can't actually close.
+
+Locking the profile solves this. When ticked:
+
+
+- The profile loads normally and everything in the app works the same way it always did.
+- Anything you change during the session — ticking a device, sliding the volume, toggling send or receive, picking a peer — still works for that session. RemSound just doesn't write any of it back to the profile file on disk.
+- When you close RemSound, there is no save prompt. 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.
+- The window title shows “(read-only)” so you can always tell at a glance.
+- The startup profile picker also shows “(read-only)” next to locked profiles, so you know what you're picking before you hit Enter.
+- Pressing Save (Ctrl+S) shows a one-time message telling you the profile is locked and suggesting Save as… instead. There's a tick on the message to stop it showing again.
+
+
+How to lock or unlock: open the File menu (Alt+F) and pick Lock profile (read-only) (Alt+F, L). It's a tickable menu item — 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.
+
+If you decide to keep your changes: two ways:
+
+- Unlock the profile first (untick the menu item), then press Save (Ctrl+S). Your changes are saved to the profile.
+- Or use Save as… directly. That always works, even on a locked profile, and the new copy starts out unlocked — so you can edit it normally afterwards.
+
+
+
+If a save prompt is blocking your shutdown right now: close it by pressing Esc (or click Cancel if you can see it), unlock by going File → Lock profile (read-only), then close RemSound. From this launch forward there'll be no prompt.
+
+
4. The main window: menu bar + three tabs
The main window has three parts, stacked top to bottom:
@@ -185,8 +214,9 @@ ul, ol { padding-left: 1.4em; }
| Save | Ctrl+S | 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. |
| Save as… | Alt+F, A | 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. |
| Rename current profile… | Alt+F, M | Renames the current profile's file and updates the window title. Does nothing on the blank template (there's no profile to rename). |
+| Lock profile (read-only) (tickable) | Alt+F, L | 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 “(read-only)” 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 “save changes?” — 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 Locking a profile for the full story. |
| Minimise to tray | Alt+F, N | 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 “Show or hide window” global hotkey (set in the Keyboard shortcuts dialog, default Ctrl+Shift+F10). |
-| Exit | Alt+F, X (or Alt+F4) | Closes RemSound. If you have unsaved profile changes, it asks you first. |
+| Exit | Alt+F, X (or Alt+F4) | Closes RemSound. If you have unsaved profile changes (and the profile isn't locked), it asks you first. |
Record menu
@@ -209,7 +239,7 @@ ul, ol { padding-left: 1.4em; }
| Recording settings… | Alt+O, S | Opens the Recording settings dialog. Up to five lists: Recording source (Alt+S), File format (Alt+F), Audio format attributes (Alt+A), FLAC compression level (Alt+L — only shown when FLAC is chosen), and Channels (Alt+C). The attributes list changes to match the format you pick. OK saves to the current profile; Cancel discards. |
| Keyboard shortcuts… | Ctrl+K, or Alt+O, K | Opens the global hotkey dialog (mute, volume, show/hide window, start/stop recording, remote-control commands). |
| Startup behaviour… | Alt+O, T | 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. |
-| Preferences… | Ctrl+P, or Alt+O, P | 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 Cue sounds (Alt+N) 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. |
+| Preferences… | Ctrl+P, or Alt+O, P | 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 Audio cue sounds (Alt+N) 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. |
Help menu
@@ -398,7 +428,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
Even if a peer is sending sound your way, you won't hear it until you've ticked their checkbox. This is deliberate — 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.
Connection health
-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 pending, stale or unreachable 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 Cue sounds in the Preferences dialog (Options → Preferences, or Ctrl+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 pending, stale or unreachable 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 Audio cue sounds in the Preferences dialog (Options → Preferences, or Ctrl+P).
11. How the network works (LAN, WAN, Tailscale)
@@ -425,9 +455,29 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
Connecting two computers directly across the internet needs one of these:
- A VPN that puts both computers on the same private network — Tailscale 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
100.something) and they can reach each other directly.
-- Or, port forwarding on each end's router (this is more involved and isn't covered here).
+- Or, let RemSound ask your router to open the audio port for you automatically — see Automatic router port opening (UPnP) below. Off by default; one tick to turn it on.
+- Or, port forwarding on each end's router by hand (this is more involved and isn't covered here).
+Automatic router port opening (UPnP)
+
+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.
+
+How to turn it on. Open Options → Preferences (Ctrl+P) and tick Automatically open my router for incoming connections (UPnP) (Alt+O). Off by default — 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:
+
+
+| Status line says… | What it means | What to do |
+| “Searching for a router that supports UPnP / NAT-PMP / PCP…” | RemSound is asking around on your network for a router that speaks one of these languages. Usually finishes within a few seconds. | Wait a moment. |
+| “Router port opened. Peers can reach you at X.X.X.X:47830.” | 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 Add peer by IP. | Pass that address (the part before the colon) to whoever you want to connect to. |
+| “No router with UPnP / NAT-PMP / PCP found.” | 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. | Try turning UPnP on in your router's settings page (look for “UPnP” or “NAT-PMP”), or use Tailscale instead. |
+| “The router opened the port, but the external address is on a carrier-grade NAT.” | 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. | Use Tailscale or the relay server instead — both work fine through carrier-grade NAT. |
+| “The router rejected the port-mapping request.” | The router found the request but said no — usually because another device on your network already has the same port forwarded, or because the router has UPnP set to a restrictive mode. | Check your router's UPnP settings, or fall back to manual port forwarding or Tailscale. |
+
+
+Across sleep and reboots. If your computer goes to sleep, RemSound asks the router to reopen the port automatically when it wakes up — 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.
+
+Why this is off by default. Some networks — corporate offices, shared accommodation, hotel Wi-Fi — 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.
+
Finding peers on Tailscale and other VPNs
The ordinary “I'm here” 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:
@@ -742,11 +792,24 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
| Setting | Shortcut | What it does |
-| Check for updates (drop-down) | Alt+U | How often RemSound checks for a newer version in the background. Choices: Never, Every hour, Every 6 hours, Every 24 hours. The default is Every 24 hours. Your choice is remembered between launches; if you set it to Never, the only way an update arrives is through the manual button below. |
+| Check for updates on startup (checkbox) | Alt+S | When ticked, RemSound has a quiet look for a newer version a few seconds after each launch. On by default. Combined with Silently install updates 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. |
+| Then check every (drop-down) | Alt+U | How often RemSound checks for a newer version in the background after launch. Choices: Never, Every hour, Every 6 hours, Every 24 hours. The default is Every 24 hours. Your choice is remembered between launches; if you set it to Never and you've also unticked the startup check, the only way an update arrives is through the manual button below. |
| Check for updates now (button) | Alt+N | 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). |
-| Silently install updates when available (checkbox) | Alt+I | When ticked, the background check installs any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The manual “Check for updates now” button always asks first, no matter how this checkbox is set. |
+| Silently install updates when available (checkbox) | Alt+I | When ticked, the background and startup checks install any available update without asking — 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 manual “Check for updates now” button always asks first, no matter how this checkbox is set. |
+The brief notice before a silent update installs
+
+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 “RemSound vX.X is ready to install” with three buttons:
+
+
+- Install now — installs straight away. This is the default; press Enter or wait through the countdown to pick it.
+- Skip this version — leaves the update alone for this launch. (RemSound may offer it again next time it checks.)
+- Postpone — close the notice without installing now. The next scheduled background check will pick it up again.
+
+
+A short countdown picks Install now automatically if you don't choose anything — 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.
+
What happens during an install
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:
@@ -805,7 +868,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
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 record start.wav and record stop.wav in the RemSound install folder — you can drop in your own sounds to customise them, as long as you keep the same filenames.
-Each cue can be turned off on its own via Preferences → Cue sounds (Alt+N). 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.
+Each cue can be turned off on its own via Preferences → Audio cue sounds (Alt+N). 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.
Where recordings go
@@ -923,6 +986,18 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
The device list shows old devices that are no longer plugged in
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 — Windows' own device list occasionally needs a nudge.
+No sound after the computer wakes from sleep
+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 — you'll briefly see a small “Reconnecting to audio driver” 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 (none) and then re-pick your driver). That triggers the same full rebuild manually. As a last resort, quit and reopen RemSound.
+
+UPnP says “no router found” even though my router supports it
+The most common reasons:
+
+- UPnP is disabled in your router's settings. Look for a checkbox marked “UPnP”, “NAT-PMP”, or “Allow apps to automatically forward ports” in the router's admin page. It's often off by default.
+- Your Windows network is set to “Public” rather than “Private”. Public mode blocks the discovery messages RemSound uses to find the router. In Windows' network settings, switch your home network to Private.
+- 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.
+
+If none of those apply, just fall back to Tailscale — it works without involving the router at all.
+
21. Glossary
@@ -941,6 +1016,9 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
| Heartbeat | 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). |
| Discovery | The way RemSound computers find each other on the network without you having to know each other's addresses up front. |
| Tailscale | 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. |
+| UPnP | Short for “Universal Plug and Play”. 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 “Automatically open my router for incoming connections” in Preferences. Off by default. |
+| NAT | Short for “Network Address Translation”. The way your router lets several computers share a single internet connection — 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. |
+| Carrier-grade NAT | 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 — the way through it is a VPN like Tailscale, or the relay server. |
| Auto-tune | 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. |
| Profile | A saved snapshot of every RemSound setting and choice — 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 → Open profile. |
| Blank template | An entry in the startup profile picker that begins a session with all the defaults — 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. |
diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs
index b6a16c5..3dc8a3c 100644
--- a/src/RemSound.App/AboutDialog.cs
+++ b/src/RemSound.App/AboutDialog.cs
@@ -20,6 +20,87 @@ internal sealed class AboutDialog : Form
/// updates" path.
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
A smoother startup when a profile uses an ASIO driver.
diff --git a/src/RemSound.App/AsioLoadingSplash.cs b/src/RemSound.App/AsioLoadingSplash.cs
index 7b7bfed..3411a30 100644
--- a/src/RemSound.App/AsioLoadingSplash.cs
+++ b/src/RemSound.App/AsioLoadingSplash.cs
@@ -27,16 +27,21 @@ namespace RemSound.App;
///
internal sealed class AsioLoadingSplash
{
+ /// Default message used by on first launch.
+ public const string DefaultMessage = "Loading audio driver, please wait...";
+
private readonly Thread thread;
private readonly ManualResetEventSlim shown = new(false);
+ private readonly string message;
private volatile Form? form;
- private AsioLoadingSplash()
+ private AsioLoadingSplash(string message)
{
+ this.message = message;
thread = new Thread(RunSplash)
{
IsBackground = true,
- Name = "RemSound startup splash",
+ Name = "RemSound audio-driver splash",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
@@ -51,12 +56,20 @@ internal sealed class AsioLoadingSplash
/// 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.
///
- public static AsioLoadingSplash? StartIfNeeded(Profile? profile)
+ public static AsioLoadingSplash? StartIfNeeded(Profile? profile) =>
+ StartIfAsioDriverName(profile?.AsioDriverName, DefaultMessage);
+
+ ///
+ /// Generic version of : 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.
+ ///
+ public static AsioLoadingSplash? StartIfAsioDriverName(string? asioDriverName, string? message = null)
{
- if (string.IsNullOrWhiteSpace(profile?.AsioDriverName)) return null;
+ if (string.IsNullOrWhiteSpace(asioDriverName)) return null;
try
{
- return new AsioLoadingSplash();
+ return new AsioLoadingSplash(message ?? DefaultMessage);
}
catch
{
@@ -87,8 +100,8 @@ internal sealed class AsioLoadingSplash
{
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
- Text = "Loading audio driver, please wait...",
- AccessibleName = "Loading audio driver, please wait",
+ Text = message,
+ AccessibleName = message.TrimEnd('.', ' '),
});
splash.Shown += (_, _) => shown.Set();
form = splash;
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 7b1d0b8..69b7075 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -38,6 +38,17 @@ public sealed class MainForm : Form
private readonly MainFormHotkeyController hotkeyController;
private readonly MainFormTrayController trayController;
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
// the visible text + accessibility name between "Start recording" and "Stop recording"
// 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
// could be saturating.
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
// 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."
private ProfileStore? profileStore;
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;
/// Full filesystem path of the active profile's JSON file. Tracked separately
/// from because Save As (2026-05-10) lets the user
/// write a profile to an arbitrary path outside .
@@ -469,6 +509,10 @@ public sealed class MainForm : Form
catch { /* benign — recents tracking is a convenience, not load-critical */ }
}
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
// 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.
@@ -901,6 +945,17 @@ public sealed class MainForm : Form
PushDiscoveryUnicastHints();
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 += (_, _) =>
{
statusTimer.Stop();
@@ -908,6 +963,8 @@ public sealed class MainForm : Form
continuousTuneTimer.Stop();
updateCheckTimer.Stop();
asioDriverChangeDebounce.Stop();
+ try { powerResumeHandler?.Dispose(); } catch { }
+ try { routerPortMapper?.Dispose(); } catch { }
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean
// these up on process exit anyway, but doing it explicitly releases the power
// request handle and matches our timeBeginPeriod with a timeEndPeriod.
@@ -971,6 +1028,41 @@ public sealed class MainForm : Form
{
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();
@@ -1090,6 +1182,30 @@ public sealed class MainForm : Form
};
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")
{
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
@@ -1115,6 +1231,7 @@ public sealed class MainForm : Form
saveItem,
saveAsItem,
renameItem,
+ lockProfileMenuItem,
new ToolStripSeparator(),
minimiseItem,
new ToolStripSeparator(),
@@ -1441,13 +1558,53 @@ public sealed class MainForm : Form
}
/// 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.
+ /// 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).
private void SaveOrSaveAs()
{
+ if (currentProfileReadOnly)
+ {
+ if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
+ {
+ ShowSaveBlockedByReadOnlyDialog();
+ }
+ return;
+ }
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
else UpdateExistingProfile();
}
+ /// 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 . NVDA reads the heading +
+ /// body + checkbox as part of the normal tab order. 2026-05-22.
+ 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");
+ }
+ }
+
/// Rename the currently-active profile JSON on disk. No-op on the blank
/// template (nothing to rename). Renames update window title + active-profile state
/// in place — no reload required.
@@ -1550,7 +1707,26 @@ public sealed class MainForm : Form
},
writeLogsNow: () => logFile.Event("user requested write logs now"),
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);
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
}
@@ -1596,6 +1772,9 @@ public sealed class MainForm : Form
if (info is null) return;
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);
return;
}
@@ -1607,6 +1786,79 @@ public sealed class MainForm : Form
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
+ /// Startup-poll path. Fired ~4 s after the main window finishes loading when
+ /// is true. Distinct from
+ /// 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.
+ 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);
+ }
+
/// Download the new release, stage it, spawn the install helper and exit. On
/// any failure shows a MessageBox and stays running — partial installs leave the app
/// untouched.
@@ -2816,6 +3068,20 @@ public sealed class MainForm : Form
IReadOnlyList wasapiInputs;
IReadOnlyList asioInputChoices = [];
IReadOnlyList 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
{
wasapiOutputs = AudioDeviceCatalog.LoadOutputs();
@@ -2831,6 +3097,17 @@ public sealed class MainForm : Form
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
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)
@@ -2842,8 +3119,22 @@ public sealed class MainForm : Form
var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature);
var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature);
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature);
- var asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
- var asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
+ bool asioSendChanged;
+ 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)
{
@@ -3236,6 +3527,81 @@ public sealed class MainForm : Form
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
}
+ ///
+ /// Called by 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.
+ ///
+ private void OnSystemResume()
+ {
+ try
+ {
+ if (IsDisposed) return;
+ BeginInvoke(ReinitAudioBackendsForResume);
+ }
+ catch (ObjectDisposedException) { /* form torn down — nothing to do */ }
+ catch (InvalidOperationException) { /* handle not created yet — same */ }
+ }
+
+ ///
+ /// 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
+ /// always tears down and rebuilds its render backend, which is exactly the reset we
+ /// want. The sender's 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 WasapiOnly
+ /// first to force the ASIO driver to be disposed, then
+ /// 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.
+ ///
+ 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 =====================
private void RefreshKnownPeers()
@@ -3896,11 +4262,32 @@ public sealed class MainForm : Form
var emitMs = sender.TakeMaxEmitMs();
var sendCallMs = sender.TakeMaxSendCallMs();
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
// active render lane should sit at ~0; non-zero says the FanOut is sitting on
// samples that aren't reaching the audio output, i.e. extra perceived latency
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
+ // 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
+ // 30–50 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
// pipeline a click is introduced:
// 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-
// stream artefact that the old shared probe registered when both lanes'
// 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 stepRawCap = sender.TakeMaxSenderRawCaptureStep();
+ var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
+ var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
+ var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
var clippedNow = sender.ClippedSampleCount;
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
- var stepPostDec = receiver.TakeMaxPostDecodeStep();
- var stepPostRing = receiver.TakeMaxPostRingReadStep();
- var stepPostRsm = receiver.TakeMaxPostResamplerStep();
+ var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
+ var stepPostDecWB = receiver.TakeMaxPostDecodeStepWithinBuffer();
+ 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
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
// 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 " +
$"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} " +
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"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} " +
+ $"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} " +
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
@@ -3968,16 +4384,42 @@ public sealed class MainForm : Form
var sendCallMs = sender.TakeMaxSendCallMs();
// Per-lane pre-encode probes — see the full-diag comment above for the
// rationale (per-lane fixes the cross-stream artefact in BothIndependent).
- var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
- var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
+ // 2026-05-21: drain XB / WB separately so we can localise click events at
+ // 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;
// Raw-capture step: now per-backend (each backend owns its own probe). The
// accessor returns max across all backends. PushModeWasapiBackend has been
// 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 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:
@@ -4183,11 +4625,16 @@ public sealed class MainForm : Form
/// Window title shows the active profile name explicitly so the user knows what
/// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or
- /// just "RemSound" (blank template).
- private static string FormatWindowTitle(string? loadedTitle) =>
- string.IsNullOrEmpty(loadedTitle)
- ? AppName
- : $"{AppName} — Active profile: {loadedTitle}";
+ /// just "RemSound" (blank template). Read-only profiles get a " (read-only)" suffix so
+ /// NVDA announces the lock state on every title change and sighted users see it at a
+ /// glance — important context that "anything I change here won't be saved".
+ private string FormatWindowTitle(string? loadedTitle)
+ {
+ var readOnlySuffix = currentProfileReadOnly ? " (read-only)" : "";
+ return string.IsNullOrEmpty(loadedTitle)
+ ? $"{AppName}{readOnlySuffix}"
+ : $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
+ }
/// 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
@@ -4261,6 +4708,18 @@ public sealed class MainForm : Form
currentProfileTitle = title;
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);
AccessibleName = Text;
UpdateProfileButtonsVisibility();
@@ -4377,6 +4836,74 @@ public sealed class MainForm : Form
unsavedChanges = true;
}
+ /// 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 .
+ /// 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.
+ 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)"}\"");
+ }
+
+ /// Write JUST the ReadOnly flag back to the profile file on disk, without
+ /// touching any of the user's in-session edits. Used by
+ /// 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.
+ 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(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}");
+ }
+ }
+
+ /// Serialise an arbitrary in the same shape
+ /// 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.
+ private static string SerializeProfileForDirtyDiff(Profile profile) =>
+ JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
+
/// Serializes the current control state as if the user had just clicked Save.
/// Used for the unsaved-changes-on-close diff. Mirrors
/// but doesn't write anywhere.
@@ -5250,7 +5777,16 @@ public sealed class MainForm : Form
// controlled close paths where the user has already confirmed their intent via the
// management dialog, and the MainForm gets reconstructed under the new profile
// immediately afterwards.
- var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch;
+ //
+ // 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)
{
diff --git a/src/RemSound.App/PowerResumeHandler.cs b/src/RemSound.App/PowerResumeHandler.cs
new file mode 100644
index 0000000..4e21c78
--- /dev/null
+++ b/src/RemSound.App/PowerResumeHandler.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Win32;
+
+namespace RemSound.App;
+
+///
+/// Subscribes to Windows' system power-state changes and fires a single callback on
+/// — 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 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 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 of
+/// the previous one so the audio backend isn't torn down and rebuilt twice for one wake.
+///
+internal sealed class PowerResumeHandler : IDisposable
+{
+ /// How long to wait after Resume before firing the callback — gives the USB
+ /// bus and audio drivers time to finish re-enumerating.
+ public static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(1500);
+
+ /// A second Resume event within this window of the first is treated as a
+ /// duplicate and ignored.
+ public static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(5);
+
+ private readonly Action onResume;
+ private readonly Action? log;
+ private readonly object gate = new();
+ private DateTime lastResumeUtc = DateTime.MinValue;
+ private bool disposed;
+
+ /// 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.
+ /// Optional sink for diagnostic lines — wire to the app's log if you
+ /// want resume events visible there.
+ public PowerResumeHandler(Action onResume, Action? 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 */ }
+ }
+}
diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs
index 79dd4da..80e4f22 100644
--- a/src/RemSound.App/PreferencesDialog.cs
+++ b/src/RemSound.App/PreferencesDialog.cs
@@ -1,3 +1,4 @@
+using System.Net;
using RemSound.Core;
namespace RemSound.App;
@@ -7,12 +8,19 @@ namespace RemSound.App;
/// right:
/// * Browse for RemSound profiles folder — picks the directory the profile picker scans
/// next launch.
-/// * Cue sounds — per-cue enable list (connect, disconnect, recording start/stop). One
-/// CheckedListBox; ticked items play, unticked are silent. Replaced the old single
+/// * Audio cue sounds — per-cue enable list (connect, disconnect, recording start/stop).
+/// 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
-/// 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.
-/// * 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.
///
/// Startup behaviour was previously a button here that opened ;
@@ -39,8 +47,8 @@ internal sealed class PreferencesDialog : Form
// the CueIndex enum below so the ItemCheck handler can dispatch by index.
private readonly Label cueListLabel = new()
{
- Text = "Cue sou&nds (Alt+N):",
- AccessibleName = "Cue sounds",
+ Text = "Audio cue sou&nds (Alt+N):",
+ AccessibleName = "Audio cue sounds",
AutoSize = true,
Padding = new Padding(0, 6, 0, 4),
};
@@ -51,7 +59,7 @@ internal sealed class PreferencesDialog : Form
IntegralHeight = false,
Height = 100,
Width = 360,
- AccessibleName = "Cue sounds",
+ AccessibleName = "Audio cue sounds",
};
private enum CueIndex
@@ -69,14 +77,24 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
- // Update settings — frequency dropdown, manual check button, silent-install checkbox.
- // Sits above the logging row so users meet it during setup; the canonical order in the
- // dialog is "things related to the program staying current" before "things related to
- // diagnosing how it's running".
+ // Update settings — startup-check checkbox, frequency dropdown, manual check button,
+ // silent-install checkbox. Sits above the logging row so users meet it during setup; the
+ // canonical order in the dialog is "things related to the program staying current" before
+ // "things related to diagnosing how it's running".
+ private readonly AccessibleCheckBox checkForUpdatesOnStartupBox = new()
+ {
+ Text = "Check for updates on &startup",
+ AccessibleName = "Check for updates on startup",
+ AutoSize = true,
+ };
+
private readonly Label updateFrequencyLabel = new()
{
- Text = "Check for updates (Alt+&U):",
- AccessibleName = "Check for updates frequency",
+ // "Then check every" — reads as a continuation of the startup-check checkbox above,
+ // 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,
};
@@ -84,7 +102,7 @@ internal sealed class PreferencesDialog : Form
{
DropDownStyle = ComboBoxStyle.DropDownList,
Width = 200,
- AccessibleName = "Check for updates (Alt+U)",
+ AccessibleName = "Then check every (Alt+U)",
};
private readonly Button checkForUpdatesNowButton = new()
@@ -101,6 +119,24 @@ internal sealed class PreferencesDialog : Form
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()
{
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).
public bool ChangedAnyProfileSetting { get; private set; }
+ private readonly Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot;
+ private EventHandler? upnpStatusSubscription;
+
public PreferencesDialog(
RemSoundSettingsStore settings,
ProfileStore? profileStore,
@@ -134,8 +173,14 @@ internal sealed class PreferencesDialog : Form
Action applyLoggingEnabled,
Action writeLogsNow,
Action checkForUpdatesNow,
- Action onUpdateFrequencyChanged)
+ Action onUpdateFrequencyChanged,
+ Action applyUpnpEnabled,
+ Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot,
+ Action subscribeUpnpStatusChanged,
+ Action unsubscribeUpnpStatusChanged)
{
+ this.getUpnpSnapshot = getUpnpSnapshot;
+
Text = "Preferences";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
@@ -143,7 +188,7 @@ internal sealed class PreferencesDialog : Form
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
- ClientSize = new Size(560, 540);
+ ClientSize = new Size(580, 640);
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
@@ -214,8 +259,17 @@ internal sealed class PreferencesDialog : Form
// either side stays in lockstep.
updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" });
var cfgForLoad = AppConfig.Load();
+ checkForUpdatesOnStartupBox.Checked = cfgForLoad.CheckForUpdatesOnStartup;
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
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 += (_, _) =>
{
var cfg = AppConfig.Load();
@@ -231,6 +285,39 @@ internal sealed class PreferencesDialog : Form
};
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.CheckedChanged += (_, _) =>
{
@@ -251,26 +338,28 @@ internal sealed class PreferencesDialog : Form
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
- RowCount = 9,
+ RowCount = 13,
};
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));
- // Tab order top-to-bottom: browse, cue-sound list, accept remote, update frequency,
- // check-now, silent install, enable logs, write logs now, close. Updates sit above
- // the log row so a user setting up the app meets them first. The Startup behaviour
- // button used to live here at tab index 3; it moved to the Options menu in the
- // 2026-05-15 reorg.
+ // Tab order top-to-bottom: browse, cue-sound list, accept remote, check-on-startup,
+ // frequency, check-now, silent install, UPnP, enable logs, write logs now, close.
+ // Updates sit above the log row so a user setting up the app meets them first. The
+ // Startup behaviour button used to live here at tab index 3; it moved to the Options
+ // menu in the 2026-05-15 reorg.
browseProfilesFolderButton.TabIndex = 0;
cueList.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2;
- updateFrequencyBox.TabIndex = 3;
- checkForUpdatesNowButton.TabIndex = 4;
- silentlyInstallUpdatesBox.TabIndex = 5;
- loggingBox.TabIndex = 6;
- writeLogsNowButton.TabIndex = 7;
- closeButton.TabIndex = 8;
+ checkForUpdatesOnStartupBox.TabIndex = 3;
+ updateFrequencyBox.TabIndex = 4;
+ checkForUpdatesNowButton.TabIndex = 5;
+ silentlyInstallUpdatesBox.TabIndex = 6;
+ upnpEnabledBox.TabIndex = 7;
+ loggingBox.TabIndex = 8;
+ writeLogsNowButton.TabIndex = 9;
+ closeButton.TabIndex = 10;
// 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.
@@ -305,11 +394,14 @@ internal sealed class PreferencesDialog : Form
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(cueGroup, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
- panel.Controls.Add(freqRow, 0, 3);
- panel.Controls.Add(checkForUpdatesNowButton, 0, 4);
- panel.Controls.Add(silentlyInstallUpdatesBox, 0, 5);
- panel.Controls.Add(loggingBox, 0, 6);
- panel.Controls.Add(writeLogsNowButton, 0, 7);
+ panel.Controls.Add(checkForUpdatesOnStartupBox, 0, 3);
+ panel.Controls.Add(freqRow, 0, 4);
+ panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
+ panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
+ 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
{
@@ -336,4 +428,39 @@ internal sealed class PreferencesDialog : Form
}
};
}
+
+ /// 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).
+ 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;
+ }
}
diff --git a/src/RemSound.App/ProfileSelectionDialog.cs b/src/RemSound.App/ProfileSelectionDialog.cs
index 6cdb670..73c20da 100644
--- a/src/RemSound.App/ProfileSelectionDialog.cs
+++ b/src/RemSound.App/ProfileSelectionDialog.cs
@@ -118,23 +118,67 @@ internal sealed class ProfileSelectionDialog : Form
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.Items.Clear();
listBox.Items.Add(BlankTemplateLabel);
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.
- var idx = prevSelected is null ? 0 : Math.Max(0, listBox.Items.IndexOf(prevSelected));
- listBox.SelectedIndex = Math.Min(idx, listBox.Items.Count - 1);
+ // Try to restore selection by title; fall back to first item.
+ var newIdx = 0;
+ 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();
// Keep the folder label in sync so it always reflects what the listbox is reading.
folderLabel.Text = "Profiles folder: " + store.BaseDirectory;
folderLabel.AccessibleName = folderLabel.Text;
}
+ /// 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.
+ 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(),
+ };
+
+ /// 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.
+ private sealed record ProfileListItem(string Title, bool ReadOnly)
+ {
+ public override string ToString() => ReadOnly ? $"{Title} (read-only)" : Title;
+ }
+
private void OnListKeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
@@ -153,7 +197,7 @@ internal sealed class ProfileSelectionDialog : Form
private void Accept()
{
- var selected = listBox.SelectedItem as string;
+ var selected = GetSelectedTitle();
if (string.IsNullOrEmpty(selected)) return;
if (selected == BlankTemplateLabel)
{
@@ -178,7 +222,7 @@ internal sealed class ProfileSelectionDialog : Form
private void DeleteSelected()
{
- var selected = listBox.SelectedItem as string;
+ var selected = GetSelectedTitle();
if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return;
var result = MessageBox.Show(this,
$"Delete profile \"{selected}\"? This cannot be undone.",
diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj
index 57ff199..1c70500 100644
--- a/src/RemSound.App/RemSound.App.csproj
+++ b/src/RemSound.App/RemSound.App.csproj
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
- 2.0.0
+ 2.1.0
@@ -33,6 +33,11 @@
no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without
sample-data loss. -->
+
+
diff --git a/src/RemSound.App/RouterPortMapper.cs b/src/RemSound.App/RouterPortMapper.cs
new file mode 100644
index 0000000..a0b0d5d
--- /dev/null
+++ b/src/RemSound.App/RouterPortMapper.cs
@@ -0,0 +1,324 @@
+using System;
+using System.Net;
+using System.Threading;
+using Mono.Nat;
+
+namespace RemSound.App;
+
+///
+/// Status of the router port mapping attempt — used to drive the inline status label in
+/// the Preferences dialog.
+///
+internal enum RouterMappingStatus
+{
+ /// The feature is off (the user hasn't enabled UPnP).
+ Disabled,
+ /// Looking for a UPnP / NAT-PMP / PCP router on the LAN.
+ Searching,
+ /// Mapping opened successfully and the router is reachable.
+ Mapped,
+ /// 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.
+ NoRouterFound,
+ /// 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.
+ CgnatDetected,
+ /// A router was found but the mapping attempt failed (port already mapped to
+ /// another device, router rejected the request, etc.). has the
+ /// detail.
+ MappingFailed,
+}
+
+///
+/// Asks the user's router to forward inbound UDP 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 AppConfig.UpnpEnabled — RemSound never pokes the
+/// router unless the user has explicitly ticked the Preferences checkbox. Failures are
+/// surfaced via and the Preferences status label; they never
+/// throw or pop a dialog (the network is too lumpy for a popup to be useful).
+///
+/// Lifecycle:
+/// * kicks off discovery on a background task. When (or if) a router
+/// replies, the mapping is added and fires with
+/// .
+/// * Renewal happens automatically — Mono.Nat extends the lease before it expires.
+/// * 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.
+/// * politely removes the mapping and stops discovery.
+///
+/// Detects CGNAT by checking whether the router's reported external address falls in
+/// 100.64.0.0/10 (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.
+///
+internal sealed class RouterPortMapper : IDisposable
+{
+ /// The UDP port RemSound uses for audio + heartbeat.
+ public const int AudioPort = 47830;
+
+ /// 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.
+ private const int MappingLeaseSeconds = 3600;
+
+ private readonly Action? 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;
+
+ /// Raised whenever changes. Always fires on a thread-pool
+ /// thread — the caller is responsible for marshaling onto the UI thread if it touches
+ /// UI state.
+ public event EventHandler? StatusChanged;
+
+ public RouterPortMapper(Action? log = null)
+ {
+ this.log = log;
+ }
+
+ /// Current state of the mapping attempt. Read by the Preferences dialog to
+ /// keep its inline status label up to date.
+ public RouterMappingStatus Status
+ {
+ get { lock (gate) { return status; } }
+ }
+
+ /// The external (WAN-side) address and port the router reports for this
+ /// machine when the mapping is open. Null until is
+ /// or .
+ public IPEndPoint? ExternalEndpoint
+ {
+ get
+ {
+ lock (gate)
+ {
+ return externalAddress is null ? null : new IPEndPoint(externalAddress, AudioPort);
+ }
+ }
+ }
+
+ /// 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.
+ public string LastError
+ {
+ get { lock (gate) { return lastError; } }
+ }
+
+ /// Start (or restart) the UPnP discovery + mapping cycle. Safe to call multiple
+ /// times; redundant calls are coalesced.
+ 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();
+ });
+ }
+
+ /// 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.
+ 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();
+ }
+
+ /// Politely remove the mapping and stop discovery. Safe to call from
+ /// FormClosing or app shutdown.
+ 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 */ }
+ }
+}
diff --git a/src/RemSound.App/UpdateInstallNoticeDialog.cs b/src/RemSound.App/UpdateInstallNoticeDialog.cs
new file mode 100644
index 0000000..063ae1d
--- /dev/null
+++ b/src/RemSound.App/UpdateInstallNoticeDialog.cs
@@ -0,0 +1,212 @@
+using System;
+using System.Windows.Forms;
+
+namespace RemSound.App;
+
+///
+/// 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 so the caller can
+/// log the skip and decline to install on this launch.
+/// * "Postpone (close)" returns — 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 and
+/// ; 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.
+///
+internal sealed class UpdateInstallNoticeDialog : Form
+{
+ /// 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.
+ 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.";
+ }
+}
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 0be07d4..6fc5a2d 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -49,6 +49,14 @@ public sealed class AppConfig
/// itself is the user-visible confirmation, so a follow-up popup is redundant.
public bool SaveProfileConfirmationSuppressed { get; set; }
+ /// 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.
+ public bool SaveOnReadOnlyMessageSuppressed { get; set; }
+
/// If true, RemSound minimises to the system tray immediately after the main
/// window finishes loading. Lets the user "boot up the machine and have RemSound
/// already running quietly". Default false.
@@ -83,6 +91,22 @@ public sealed class AppConfig
/// RemSound. Default false — the user gets a confirmation dialog before each install.
public bool SilentlyInstallUpdates { get; set; }
+ /// If true (the default), RemSound runs an update check shortly after launch in
+ /// addition to whatever 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.
+ public bool CheckForUpdatesOnStartup { get; set; } = true;
+
+ /// 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.
+ public bool UpnpEnabled { get; set; }
+
/// UTC timestamp of the last successful update check. Used by the background
/// update timer to space out polls across launches — if you set the frequency to
/// "every 24 hours" and re-launch the app three times that day, it still hits the API
diff --git a/src/RemSound.Core/AudioStepProbe.cs b/src/RemSound.Core/AudioStepProbe.cs
index 77947f7..9cb156a 100644
--- a/src/RemSound.Core/AudioStepProbe.cs
+++ b/src/RemSound.Core/AudioStepProbe.cs
@@ -6,11 +6,29 @@ namespace RemSound.Core;
/// 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.
///
-/// Each probe holds the maximum step observed across all calls to
-/// since the last . The diag log polls TakeMax 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.
+/// 2026-05-21: split the max into TWO independent counters so we can tell the difference
+/// between a sharp transient inside a buffer (natural-looking real audio content) and a
+/// discontinuity at the buffer / packet boundary (samples that are not adjacent in time —
+/// i.e. something we lost, duplicated, or mis-stitched in the pipeline). The plain
+/// still returns the larger of the two for back-compat, but the
+/// / pair lets the diag
+/// logger emit both, so a click event in the log clearly says which side it's on:
+///
+/// * stepXB — 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 / :
+/// 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.
+/// * stepWB — 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
/// one probe can be hit from multiple threads if needed). Read-and-reset is also atomic.
@@ -20,7 +38,11 @@ namespace RemSound.Core;
///
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
// 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
@@ -40,7 +62,8 @@ public sealed class AudioStepProbe
if (!DiagnosticsGate.Enabled) return;
if (interleavedFloats.IsEmpty) 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
// 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
@@ -51,15 +74,35 @@ public sealed class AudioStepProbe
for (var i = 0; i < samples; i++)
{
var s = interleavedFloats[i * channelCount + channelIndex];
- if (i == 0 && !seedFromPrev) prev = s;
- var step = s - prev;
- if (step < 0f) step = -step;
- if (step > max) max = step;
+ if (i == 0)
+ {
+ // First sample of this buffer. If we have a carry from the previous scan,
+ // 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;
}
lastL = prev;
hasLast = true;
- WriteMaxIfGreater(max);
+ WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
+ WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
}
/// 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 (stereoFloats.IsEmpty) return;
- var max = ReadMax();
+ var maxCross = ReadMax(ref maxCrossBufferStepBits);
+ var maxWithin = ReadMax(ref maxWithinBufferStepBits);
var prevL = lastL;
var prevR = lastR;
var seedFromPrev = hasLast;
// 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
- // last-sample-from-the-previous-buffer if available.
+ // same channel from THIS buffer (within-buffer). For the first pair, compare to the
+ // saved last-sample-from-the-previous-buffer if available (cross-buffer).
for (var i = 0; i + 1 < stereoFloats.Length; i += 2)
{
var l = stereoFloats[i];
var r = stereoFloats[i + 1];
- float stepL, stepR;
if (i == 0)
{
- if (!seedFromPrev) { prevL = l; prevR = r; }
- stepL = l - prevL;
- stepR = r - prevR;
+ if (seedFromPrev)
+ {
+ 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
{
- stepL = l - stereoFloats[i - 2];
- stepR = r - stereoFloats[i - 1];
+ var stepL = l - stereoFloats[i - 2];
+ 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.
var lastIdx = stereoFloats.Length - 2;
lastL = stereoFloats[lastIdx];
lastR = stereoFloats[lastIdx + 1];
hasLast = true;
- WriteMaxIfGreater(max);
+ WriteMaxIfGreater(ref maxCrossBufferStepBits, maxCross);
+ WriteMaxIfGreater(ref maxWithinBufferStepBits, maxWithin);
}
- /// Atomic snapshot of the current max + reset to zero. Returns the value as
- /// a float in the same units as the input (i.e. 0.5 = a 0.5-magnitude single-sample
- /// step, which is a 6 dB jump and definitely audible).
+ /// Atomic snapshot of the current maxes + reset to zero. Returns the larger of
+ /// the cross-buffer and within-buffer maxes — preserves the pre-2026-05-21 semantics for
+ /// callers that just want "the worst step we saw at this stage". For the cross/within
+ /// split, use +
+ /// instead; calling either of those drains its own counter independently of this one,
+ /// so a caller wanting the split must NOT also call TakeMax in the same
+ /// 1-second window.
public float TakeMax()
{
- var bits = Interlocked.Exchange(ref maxStepBits, 0);
+ var c = TakeMaxCrossBuffer();
+ var w = TakeMaxWithinBuffer();
+ return c > w ? c : w;
+ }
+
+ /// 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.
+ public float TakeMaxCrossBuffer()
+ {
+ var bits = Interlocked.Exchange(ref maxCrossBufferStepBits, 0);
return BitConverter.Int32BitsToSingle((int)bits);
}
- private float ReadMax() => BitConverter.Int32BitsToSingle((int)Volatile.Read(ref maxStepBits));
+ /// 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.
+ 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);
long current;
do
{
- current = Volatile.Read(ref maxStepBits);
+ current = Volatile.Read(ref field);
var currentValue = BitConverter.Int32BitsToSingle((int)current);
if (candidate <= currentValue) return;
- } while (Interlocked.CompareExchange(ref maxStepBits, candidateBits, current) != current);
+ } while (Interlocked.CompareExchange(ref field, candidateBits, current) != current);
}
}
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index 28ce4f4..644fef6 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -23,6 +23,19 @@ public sealed class Profile
/// Display title and filename stem (sanitised). Required.
public string Title { get; set; } = "";
+ /// 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.
+ public bool ReadOnly { get; set; }
+
// === Main form: send / receive ===
public bool ReceiveAudioOn { get; set; }
public bool SendAudioOn { get; set; }
diff --git a/src/RemSound.Core/ProfileStore.cs b/src/RemSound.Core/ProfileStore.cs
index 964faaa..317155e 100644
--- a/src/RemSound.Core/ProfileStore.cs
+++ b/src/RemSound.Core/ProfileStore.cs
@@ -67,6 +67,29 @@ public sealed class ProfileStore
}
}
+ /// Returns whether the profile with the given title has its ReadOnly flag set
+ /// on disk, without doing a full . 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).
+ 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(json);
+ return profile?.ReadOnly ?? false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
/// Loads a profile by title. Returns null if the file is missing or
/// unreadable. Malformed JSON is treated as "not found" rather than throwing —
/// the caller can surface a diagnostic and fall back to a blank template.
diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs
index c4dfee3..b3d1394 100644
--- a/src/RemSound.Receiver/AudioReceiver.cs
+++ b/src/RemSound.Receiver/AudioReceiver.cs
@@ -241,6 +241,13 @@ public sealed class AudioReceiver : IDisposable
/// contributions. Resets on read.
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
+ /// 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.
+ public int TakeMaxInterPacketGapMs() => listener.TakeMaxInterPacketGapMs();
+
/// Worst FanOutSource cache-occupancy seen since the last call, expressed in
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
@@ -299,7 +306,8 @@ public sealed class AudioReceiver : IDisposable
/// 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
/// diag log to pinpoint where in the pipeline audio discontinuities are being
- /// introduced.
+ /// 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.
public float TakeMaxPostDecodeStep()
{
lock (sessionsLock)
@@ -314,6 +322,38 @@ public sealed class AudioReceiver : IDisposable
}
}
+ /// 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.
+ 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;
+ }
+ }
+
+ /// 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.
+ 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
{
get
@@ -395,8 +435,12 @@ public sealed class AudioReceiver : IDisposable
/// Take the worst single-sample step out of the ring buffer (after decode +
/// SessionPlayout.Write, before resampler) since the last call.
public float TakeMaxPostRingReadStep() => playoutEngine.TakeMaxPostRingReadStep();
+ public float TakeMaxPostRingReadStepCrossBuffer() => playoutEngine.TakeMaxPostRingReadStepCrossBuffer();
+ public float TakeMaxPostRingReadStepWithinBuffer() => playoutEngine.TakeMaxPostRingReadStepWithinBuffer();
/// Take the worst single-sample step out of the resampler since the last call.
public float TakeMaxPostResamplerStep() => playoutEngine.TakeMaxPostResamplerStep();
+ public float TakeMaxPostResamplerStepCrossBuffer() => playoutEngine.TakeMaxPostResamplerStepCrossBuffer();
+ public float TakeMaxPostResamplerStepWithinBuffer() => playoutEngine.TakeMaxPostResamplerStepWithinBuffer();
/// RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
diff --git a/src/RemSound.Receiver/NetworkListener.cs b/src/RemSound.Receiver/NetworkListener.cs
index bdd4b2e..0bc7521 100644
--- a/src/RemSound.Receiver/NetworkListener.cs
+++ b/src/RemSound.Receiver/NetworkListener.cs
@@ -31,6 +31,18 @@ internal sealed class NetworkListener : IDisposable
public int TakeMaxOnPacketMs() =>
(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 onPacket, Action onDiagnostic)
{
this.onPacket = onPacket;
@@ -72,6 +84,11 @@ internal sealed class NetworkListener : IDisposable
thread = null;
cts?.Dispose();
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();
@@ -104,7 +121,22 @@ internal sealed class NetworkListener : IDisposable
// per packet for a number nobody is going to log.
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);
var elapsed = Stopwatch.GetTimestamp() - dispatchStart;
long current;
diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs
index af4c54f..220208a 100644
--- a/src/RemSound.Receiver/PlayoutEngine.cs
+++ b/src/RemSound.Receiver/PlayoutEngine.cs
@@ -495,7 +495,9 @@ internal sealed class PlayoutEngine : IWaveProvider
/// 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
/// probe, this locates where in the pipeline an audio discontinuity was introduced.
- /// Takes the max across all sessions and resets each.
+ /// 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.
public float TakeMaxPostRingReadStep()
{
var snap = sessionsSnapshot;
@@ -508,9 +510,36 @@ internal sealed class PlayoutEngine : IWaveProvider
return max;
}
+ /// Cross-buffer (read-boundary) max post-ring-read step across all sessions.
+ 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;
+ }
+
+ /// Within-buffer max post-ring-read step across all sessions.
+ 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;
+ }
+
/// Worst single-sample step in the resampler output since the last call.
/// Significantly larger than would point the
- /// finger at the resampler integration.
+ /// finger at the resampler integration. Returns max-of-(cross, within); use the XB/WB
+ /// variants for the split.
public float TakeMaxPostResamplerStep()
{
var snap = sessionsSnapshot;
@@ -523,6 +552,32 @@ internal sealed class PlayoutEngine : IWaveProvider
return max;
}
+ /// Cross-buffer max post-resampler step across all sessions.
+ 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;
+ }
+
+ /// Within-buffer max post-resampler step across all sessions.
+ 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 ===
///
diff --git a/src/RemSound.Receiver/SessionPlayout.cs b/src/RemSound.Receiver/SessionPlayout.cs
index af98d2b..13cea8e 100644
--- a/src/RemSound.Receiver/SessionPlayout.cs
+++ b/src/RemSound.Receiver/SessionPlayout.cs
@@ -226,6 +226,10 @@ internal sealed class SessionPlayout : IDisposable
private readonly AudioStepProbe postResamplerStepProbe = new();
public float TakeMaxPostRingReadStep() => postRingReadStepProbe.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
// increments on ANY short read at the AudioRingBuffer level (whether framesRead==0
diff --git a/src/RemSound.Receiver/StreamSession.cs b/src/RemSound.Receiver/StreamSession.cs
index 3ac7468..e57fa28 100644
--- a/src/RemSound.Receiver/StreamSession.cs
+++ b/src/RemSound.Receiver/StreamSession.cs
@@ -53,6 +53,8 @@ internal sealed class StreamSession : IDisposable
// samples a moment later (after riding through the ring buffer).
private readonly AudioStepProbe postDecodeStepProbe = new();
public float TakeMaxPostDecodeStep() => postDecodeStepProbe.TakeMax();
+ public float TakeMaxPostDecodeStepCrossBuffer() => postDecodeStepProbe.TakeMaxCrossBuffer();
+ public float TakeMaxPostDecodeStepWithinBuffer() => postDecodeStepProbe.TakeMaxWithinBuffer();
// === Wire-level sequence tracking (Phase 5, 2026-05-14) ===
// Every audio packet carries a wire sequence number that monotonically increases per
diff --git a/src/RemSound.Sender/AsioCaptureBackend.cs b/src/RemSound.Sender/AsioCaptureBackend.cs
index 980bb79..7d52b2c 100644
--- a/src/RemSound.Sender/AsioCaptureBackend.cs
+++ b/src/RemSound.Sender/AsioCaptureBackend.cs
@@ -88,6 +88,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
onMixedSamples = callback;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
+ public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
+ public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public bool IsRunning => asio is not null;
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs
index 36ea23e..4afc041 100644
--- a/src/RemSound.Sender/AudioSender.cs
+++ b/src/RemSound.Sender/AudioSender.cs
@@ -141,12 +141,23 @@ public sealed class AudioSender : IDisposable
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.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
// 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
// BothIndependent mode the composite backend forwards to both inners and returns the
// larger value.
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
// clamps any sample whose magnitude exceeds 1.0 (avoids producing samples the int24 path
diff --git a/src/RemSound.Sender/CompositeCaptureBackend.cs b/src/RemSound.Sender/CompositeCaptureBackend.cs
index 87004ac..3fa3ba9 100644
--- a/src/RemSound.Sender/CompositeCaptureBackend.cs
+++ b/src/RemSound.Sender/CompositeCaptureBackend.cs
@@ -129,6 +129,22 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
return w > a ? w : a;
}
+ /// Cross-buffer (boundary) max across both inner backends. Drains BOTH.
+ public float TakeMaxRawCaptureStepCrossBuffer()
+ {
+ var w = wasapi?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f;
+ var a = asio?.TakeMaxRawCaptureStepCrossBuffer() ?? 0f;
+ return w > a ? w : a;
+ }
+
+ /// Within-buffer max across both inner backends. Drains BOTH.
+ public float TakeMaxRawCaptureStepWithinBuffer()
+ {
+ var w = wasapi?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f;
+ var a = asio?.TakeMaxRawCaptureStepWithinBuffer() ?? 0f;
+ return w > a ? w : a;
+ }
+
public void Start(IReadOnlyList specs)
{
lock (gate)
diff --git a/src/RemSound.Sender/ICaptureBackend.cs b/src/RemSound.Sender/ICaptureBackend.cs
index 126fbcc..a9ad8dd 100644
--- a/src/RemSound.Sender/ICaptureBackend.cs
+++ b/src/RemSound.Sender/ICaptureBackend.cs
@@ -49,9 +49,25 @@ internal interface ICaptureBackend : IDisposable
/// 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
/// 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.
+ /// 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
+ /// +
+ /// and do NOT also call this in the same drain window.
float TakeMaxRawCaptureStep();
+ /// 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.
+ float TakeMaxRawCaptureStepCrossBuffer();
+
+ /// 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.
+ float TakeMaxRawCaptureStepWithinBuffer();
+
void Start(IReadOnlyList specs);
/// Live-update of the active source set without stopping the mix loop. Adds/removes
diff --git a/src/RemSound.Sender/MixingEngine.cs b/src/RemSound.Sender/MixingEngine.cs
index 83c76bd..237d135 100644
--- a/src/RemSound.Sender/MixingEngine.cs
+++ b/src/RemSound.Sender/MixingEngine.cs
@@ -118,6 +118,8 @@ internal sealed class MixingEngine : ICaptureBackend
/// instead. Stays at zero here; if a future
/// multi-source WASAPI test needs the probe, add it per-source in CaptureSource.
public float TakeMaxRawCaptureStep() => 0f;
+ public float TakeMaxRawCaptureStepCrossBuffer() => 0f;
+ public float TakeMaxRawCaptureStepWithinBuffer() => 0f;
///
/// Starts the mix loop with the given initial source set. If already running, the existing
diff --git a/src/RemSound.Sender/PushModeWasapiBackend.cs b/src/RemSound.Sender/PushModeWasapiBackend.cs
index 4786db6..76ccf02 100644
--- a/src/RemSound.Sender/PushModeWasapiBackend.cs
+++ b/src/RemSound.Sender/PushModeWasapiBackend.cs
@@ -109,6 +109,8 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
public int TakeMaxCallbackGapMs() => 0;
public float TakeMaxRawCaptureStep() => rawCaptureStepProbe.TakeMax();
+ public float TakeMaxRawCaptureStepCrossBuffer() => rawCaptureStepProbe.TakeMaxCrossBuffer();
+ public float TakeMaxRawCaptureStepWithinBuffer() => rawCaptureStepProbe.TakeMaxWithinBuffer();
public void Start(IReadOnlyList specs)
{
diff --git a/src/RemSound.Sender/SenderLane.cs b/src/RemSound.Sender/SenderLane.cs
index 69b24d6..6ab9b09 100644
--- a/src/RemSound.Sender/SenderLane.cs
+++ b/src/RemSound.Sender/SenderLane.cs
@@ -62,6 +62,8 @@ internal sealed class SenderLane
// that without changing what the probe measures.
private readonly AudioStepProbe preEncodeStepProbe = new();
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
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine