Release v3.5: USB-card recovery, per-card adaptive buffer, one user folder, audit fixes

- Recover an unplugged/replugged output sound card automatically (issue #5):
  detect the dead WASAPI device and remember the receive-output selection so it
  re-ticks and re-opens when the card returns.
- Adaptive per-card WASAPI buffer target sized to each card's pull chunk, held
  stable so it never flits about under CPU/network load.
- Consolidate all per-user data (config, profiles, logs, sounds) into one
  "user settings and logs" folder; migrate every older layout; exclude it from
  the updater so custom cue sounds now survive updates.
- Mic-privacy detector: warn once when a Windows-blocked mic is switched on, or a
  profile loads with one already on.
- All warning/notice dialogs now come to the foreground even when minimised.
- Apply volume + mute on profile load (were saved but not restored).
- Crash-safe (atomic) profile/config saves.
- Fix two resource leaks (push-mode capture MMDevice; UPnP DeviceFound handler).
- Remove dead code (baseline-diff machinery, dead ASIO probes, no-op stubs).
- Docs: readme.html, MANUAL.md, About-box changelog and release notes for v3.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-10 11:34:17 +01:00
co-authored by Claude Opus 4.8
parent 946e6f4be4
commit 4dbe9a47a0
21 changed files with 681 additions and 305 deletions
+25 -11
View File
@@ -109,14 +109,14 @@ Action| How
**Rename the current profile**| File → Rename current profile (Alt+F, M). It asks for the new name and renames the profile's file; the window title updates straight away. **Rename the current profile**| File → Rename current profile (Alt+F, M). It asks for the new name and renames the profile's file; the window title updates straight away.
**Delete a profile**| File → Open profile, then right-click the entry in the Windows file picker and choose Delete. RemSound lets Windows handle this rather than having its own delete button. **Delete a profile**| File → Open profile, then right-click the entry in the Windows file picker and choose Delete. RemSound lets Windows handle this rather than having its own delete button.
### Where profiles are stored ### Where your files are stored
Each profile is one small file on your computer. By default they live in a `config` folder inside RemSound, at: Everything RemSound keeps for you on this computer — your settings, your profiles, your logs, and your cue sounds — lives together in one folder inside RemSound called **user settings and logs**. Each profile is one small file, stored at:
<RemSound folder>\config\profiles\<your computer name>\<profile name> <RemSound folder>\user settings and logs\profiles\<your computer name>\<profile name>
(If you're upgrading from an older version, RemSound moves your existing settings and profiles into this `config` folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.) (If you're upgrading from an older version, RemSound moves all of this into the **user settings and logs** folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.) From this version on, RemSound updates never touch that folder — so anything of your own in there, including custom cue sounds, stays safe when you update.
The folder named after your computer keeps each machine's profiles separate. If you used the **Browse …** button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list. The folder named after your computer keeps each machine's profiles separate. If you used the **Browse …** button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.
@@ -213,6 +213,8 @@ Item| Shortcut| What it does
**Keyboard access:** the tray icon is reachable through standard Windows shortcuts — **Windows + B** moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse. **Keyboard access:** the tray icon is reachable through standard Windows shortcuts — **Windows + B** moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.
**Important warnings always come to the front.** Even when RemSound is hidden in the tray, a warning it needs you to read — such as the “your files have moved” notice, a microphone-blocked warning, or an update prompt — pops up in front of whatever you're doing, with focus, so your screen reader reads it straight away. RemSound stays in the tray; only the warning comes forward.
### Only one copy of RemSound runs at a time ### Only one copy of RemSound runs at a time
RemSound only ever runs as a single copy. If you try to open it while it's already running — for example by double-clicking it when it's already sitting in the system tray — it won't start a second one. Instead it asks what you'd like to do: RemSound only ever runs as a single copy. If you try to open it while it's already running — for example by double-clicking it when it's already sitting in the system tray — it won't start a second one. Instead it asks what you'd like to do:
@@ -331,6 +333,8 @@ Tick any combination across the three lists. RemSound mixes them together into o
> **Capturing your speakers can cause an echo loop.** If you tick the same device both in “WASAPI outputs to send” and in “WASAPI outputs for received sound”, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once. > **Capturing your speakers can cause an echo loop.** If you tick the same device both in “WASAPI outputs to send” and in “WASAPI outputs for received sound”, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once.
> **If your microphone sends silence:** Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in **WASAPI inputs to send** while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both _Microphone access_ and _Let desktop apps access your microphone_. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic.
## 8. Audio profile tab ## 8. Audio profile tab
Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the _priority mode_ checkbox — it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: **Audio send parameters** first, then **Audio receive parameters**. Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the _priority mode_ checkbox — it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: **Audio send parameters** first, then **Audio receive parameters**.
@@ -573,6 +577,10 @@ Latency is the small delay between sound leaving one computer and arriving at th
Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default. Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.
### The sound-card cushion is automatic
Separately from the controls above — which manage the cushion against _network_ jitter — RemSound also keeps a small cushion at the sound card itself, to smooth over the tiny timing differences between your two computers' sound clocks. From this version, RemSound sizes that cushion to each card automatically: a card that moves sound in bigger chunks (some onboard and USB cards do) gets a little more room, while a fast professional interface stays tight. You don't set this or think about it — it settles on the right amount for whatever card you're using.
### Audio latency control ### Audio latency control
The **Audio latency** control tells the receiving side how much sound to keep in reserve as a cushion against uneven network timing. A bigger cushion means more delay but fewer clicks. A smaller cushion means less delay but more clicks when the network wobbles. The **Audio latency** control tells the receiving side how much sound to keep in reserve as a cushion against uneven network timing. A bigger cushion means more delay but fewer clicks. A smaller cushion means less delay but more clicks when the network wobbles.
@@ -879,11 +887,11 @@ Custom sound choices are **saved with the active profile** , the same way the ti
### Going back to the default sound ### Going back to the default sound
To revert a cue to its default sound, **right-click** the _Browse for [cue name] …_ button and pick **Use default sound**. The custom path is forgotten and the cue goes back to playing the default WAV that ships with RemSound. The right-click option is greyed out when the cue is already using its default. (Alternatively, click _Browse_ and pick a file from inside RemSound's own `sounds` folder — RemSound recognises that as “use default” and clears the override automatically.) To revert a cue to its default sound, **right-click** the _Browse for [cue name] …_ button and pick **Use default sound**. The custom path is forgotten and the cue goes back to playing the default WAV that ships with RemSound. The right-click option is greyed out when the cue is already using its default. (Alternatively, click _Browse_ and pick a file from RemSound's own `sounds` folder — inside **user settings and logs** — and RemSound treats that as “use default” and clears the override automatically.)
### Where the default sounds live ### Where the cue sounds live
The default WAV files are in the `sounds` folder next to `RemSound.exe`. If you don't pick a custom file for a cue, RemSound plays the matching default from there: RemSound keeps the cue WAV files in a `sounds` folder inside **user settings and logs** — the same folder your settings and profiles live in. It puts the defaults there for you; if you don't pick a custom file for a cue, RemSound plays the matching one from this folder:
* `sounds\connect.wav` — connect cue * `sounds\connect.wav` — connect cue
* `sounds\disconnect.wav` — disconnect cue * `sounds\disconnect.wav` — disconnect cue
@@ -896,6 +904,8 @@ The default WAV files are in the `sounds` folder next to `RemSound.exe`. If you
Because this folder is inside **user settings and logs** , RemSound updates never overwrite it. So if you drop your own WAV files in here in place of the defaults, your versions stay put when you update — you don't have to set them up again.
If a cue's WAV file is missing — either the default file doesn't exist or a custom path points at a file you've since deleted — the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened. If a cue's WAV file is missing — either the default file doesn't exist or a custom path points at a file you've since deleted — the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.
> **Tip for sound designers:** the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day. > **Tip for sound designers:** the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day.
@@ -991,7 +1001,7 @@ Format| What you get| When to pick it
### Start and stop sound cues ### Start and stop sound cues
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. These are two of the seven cues described in Audio cue sounds. You can turn either or both off, replace them with your own WAV files, and preview them from Preferences. The defaults live at `sounds\record start.wav` and `sounds\record stop.wav` next to `RemSound.exe`. 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. These are two of the eight cues described in Audio cue sounds. You can turn either or both off, replace them with your own WAV files, and preview them from Preferences. The defaults live at `sounds\record start.wav` and `sounds\record stop.wav` inside the **user settings and logs** folder.
### Where recordings go ### Where recordings go
@@ -1030,7 +1040,7 @@ OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc disc
## 21. Logs and diagnostics ## 21. Logs and diagnostics
If logging is turned on (the **Enable logs** checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a `logs` folder next to the RemSound program. One file per launch. If logging is turned on (the **Enable logs** checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a `logs` folder inside **user settings and logs** — the same folder your settings and profiles live in. One file per launch.
The file contains two kinds of rows: The file contains two kinds of rows:
@@ -1067,7 +1077,7 @@ Logs are plain text and can be opened in any text editor, or in a spreadsheet. T
1. Audio inputs and outputs tab: is **Send my audio** ticked? 1. Audio inputs and outputs tab: is **Send my audio** ticked?
2. Same tab: is at least one capture source ticked across the three send lists? 2. Same tab: is at least one capture source ticked across the three send lists?
3. If you're using a microphone: is Windows' microphone privacy setting allowing apps to use it? (Settings → Privacy → Microphone.) 3. If you're using a microphone: is Windows allowing apps to use it? RemSound now pops up a warning when you switch on a microphone Windows is blocking — including when a profile loads with one already on — but to check by hand, open Settings → Privacy & security → Microphone and make sure both _Microphone access_ and _Let desktop apps access your microphone_ are on. (When Windows blocks it the mic sends silence rather than failing, so it's easy to miss.)
4. Have they ticked _your_ name in their Discovered peers list? 4. Have they ticked _your_ name in their Discovered peers list?
@@ -1117,12 +1127,16 @@ You probably picked Realtek ASIO. It's a generic driver, not tied to Realtek har
### The device list shows old devices that are no longer plugged in ### 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. RemSound reacts the moment a device is plugged in or unplugged, so an unplugged device should disappear within a second or two. If one lingers, restart RemSound — Windows' own device list occasionally needs a nudge.
### No sound after the computer wakes from sleep ### 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. 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.
### A sound card you were listening through was unplugged
If a sound card you're playing received audio through is unplugged and then plugged back in, RemSound now re-opens it on its own and the sound resumes — you don't have to re-tick it in the output list. This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket. If you move it to a different USB socket and Windows treats it as a brand-new device, just tick it again in the output list.
### UPnP says “no router found” even though my router supports it ### UPnP says “no router found” even though my router supports it
The most common reasons: The most common reasons:
+21 -27
View File
@@ -1,51 +1,45 @@
# RemSound v3.4 # RemSound v3.5
A round of quality-of-life and reliability improvements: a fast new way to switch profiles from anywhere, a safety net for the troublesome Realtek ASIO driver, your screen reader now reads out global hotkeys, and WASAPI connections stay rock-steady over long sessions. A reliability-and-tidiness release: sound cards recover on their own if you unplug them, the audio cushion now sizes itself to each card, everything you own lives in one neat folder that updates can't touch, and a stack of under-the-hood fixes from a top-to-bottom code review.
## Quick profile switch ## Recover a sound card you unplug
There's a new global hotkey — **Quick profile switch** — that pops up a small list of all your profiles, wherever you are and whatever you're doing. Arrow to the one you want, press Enter, and RemSound switches straight to it. If a USB sound card you're listening through is unplugged and plugged back in, **RemSound now re-opens it automatically and the sound resumes** — you don't have to re-tick it. (This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket.)
- The profile you're currently on is marked in the list. ## The audio cushion sizes itself to each card
- A sound plays as the list opens, and the switch cue plays the moment you pick one.
- If RemSound was tucked away in the system tray, **it stays there** — no window jumping up in front of whatever you're working on.
It's unset by default. Give it a key under **Options → Keyboard shortcuts**. RemSound keeps a small cushion at the sound card to smooth over the tiny timing difference between two machines' sound clocks. It now **sizes that cushion to each card automatically** — a card that moves sound in bigger chunks gets a little more room, a fast interface stays tight — so there's nothing to fiddle with. It just settles on the right amount.
## A safety net for the Realtek ASIO driver ## A microphone-privacy heads-up
Realtek's bundled ASIO driver leaks Windows resources every time it's opened, and can make audio unstable. RemSound now **spots it on startup and offers, just once, to disable it** — say yes and RemSound will never touch that driver again. If you'd rather keep it, RemSound won't nag you about it after that first time. Windows can silently block apps from using your microphone, and when it does, a mic sends silence rather than failing — easy to miss. RemSound now **warns you** when you switch on a mic Windows is blocking (including when a profile loads with one already on), and points you at the exact two Windows settings to turn on.
You can flip it back on, or off, whenever you like from **Options → Enable / Disable Realtek ASIO driver in RemSound**. ## Warnings always come to the front
## Your screen reader now reads out global hotkeys Even when RemSound is minimised to the system tray, an important warning — the "your files have moved" notice, a microphone warning, an update prompt — now **pops up in front with focus**, so your screen reader reads it straight away. RemSound stays in the tray; only the warning comes forward.
When you move over a menu item, or a control that has a global hotkey assigned, your screen reader now reads the hotkey along with everything else — for example, "press Ctrl+Shift+M anywhere". So you can learn and double-check your shortcuts just by arrowing around the window, without opening the shortcuts dialog. ## One tidy folder for everything
## Smoother long sessions on WASAPI Everything this machine keeps for you — your settings, profiles, logs, and cue sounds — now lives together in one folder inside RemSound called **user settings and logs**. It moves there automatically the first time you run v3.5, and RemSound tells you once. Nothing is lost. **From now on, updates never touch that folder** — so any custom cue sounds you put there survive updates.
On WASAPI — the ordinary Windows sound path — two machines' sound clocks run at very slightly different speeds. Over a long session that tiny difference used to add up, and the delay would slowly drift. RemSound now measures and gently corrects that drift the whole time, so a WASAPI connection is as tight at the end of a three-hour session as it was at the start. (ASIO already kept itself in step; this brings WASAPI up to the same standard.) ## Smaller things
## Smaller improvements - **Volume and mute** now come back correctly when you load a profile.
- A round of reliability fixes from a full code review: two resource leaks closed, profile and settings saves are now crash-safe, and a good clear-out of dead code.
- **A new "profile menu open" cue** sounds when the Quick profile switch popup appears. Like every other cue, it has its own mute toggle and custom-sound option in Preferences.
- **The profile-switch cue now plays the instant you switch**, and no longer plays on a fresh start into your first profile — so you won't hear it stacked on top of the connect sound at launch.
- **Faster device detection.** RemSound now reacts the moment you plug in or unplug a sound device, instead of checking every few seconds. This also closed a slow resource leak on the receiving side.
- **Tidier folder.** Your settings and profiles now live in a `config` folder inside RemSound. The move happens automatically the first time you run v3.4, and RemSound tells you once that it's done it. Nothing is lost, and everything works exactly as before.
## Compatibility ## Compatibility
**v3.4 talks to v3.3 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (You still need everyone on **v3.3 or newer**, because that's where end-to-end encryption came in.) **v3.5 talks to v3.3 and v3.4 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (You still need everyone on **v3.3 or newer**, because that's where end-to-end encryption came in.)
## Install ## Install
1. Download `RemSound-v3.4.zip` from this release. 1. Download `RemSound-v3.5.zip` from this release.
2. Close RemSound. 2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings or recordings. 3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings, recordings, or sounds.
4. Run `RemSound.exe`. 4. Run `RemSound.exe`. The first launch tidies your files into the new folder and tells you once.
## Upgrading ## Upgrading
**From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.4 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.4 installs itself shortly after launch. **From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.5 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.5 installs itself shortly after launch.
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates, so Check for updates will download v3.4 but not apply it. Install v3.4 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 installing updates, so Check for updates will download v3.5 but not apply it. Install v3.5 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
+4 -4
View File
@@ -103,10 +103,10 @@ if (Test-Path $syncScript) {
# Anything matching these must NEVER appear in a release. Folders by name; files by # Anything matching these must NEVER appear in a release. Folders by name; files by
# extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are # extension / exact name. RemSound.deps.json and RemSound.runtimeconfig.json are
# legitimate app files and are deliberately NOT matched (different names). # legitimate app files and are deliberately NOT matched (different names).
# 'config' added 2026-06-07: the config\ folder now holds global config (config\global config.json) # 'user settings and logs' added 2026-06-10: that one folder now holds global config, profiles,
# AND profiles (config\profiles\), so forbidding the whole folder catches both in one rule. The # logs AND cue sounds, so forbidding it catches all the user state in one rule. The legacy 'config',
# legacy 'profiles' and 'remsound.config.json' rules stay for any pre-migration leftovers. # 'profiles' and 'remsound.config.json' rules stay for any pre-migration leftovers.
$forbiddenFolders = @('logs', 'profiles', 'recordings', 'config') $forbiddenFolders = @('logs', 'profiles', 'recordings', 'config', 'user settings and logs')
function Test-Forbidden([string]$path) { function Test-Forbidden([string]$path) {
$p = $path -replace '\\', '/' $p = $path -replace '\\', '/'
foreach ($f in $forbiddenFolders) { foreach ($f in $forbiddenFolders) {
+26 -11
View File
@@ -135,13 +135,13 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Delete a profile</strong></td><td>File &rarr; Open profile, then right-click the entry in the Windows file picker and choose Delete. RemSound lets Windows handle this rather than having its own delete button.</td></tr> <tr><td><strong>Delete a profile</strong></td><td>File &rarr; Open profile, then right-click the entry in the Windows file picker and choose Delete. RemSound lets Windows handle this rather than having its own delete button.</td></tr>
</table> </table>
<h3>Where profiles are stored</h3> <h3>Where your files are stored</h3>
<p>Each profile is one small file on your computer. By default they live in a <code>config</code> folder inside RemSound, at:</p> <p>Everything RemSound keeps for you on this computer &mdash; your settings, your profiles, your logs, and your cue sounds &mdash; lives together in one folder inside RemSound called <strong>user settings and logs</strong>. Each profile is one small file, stored at:</p>
<pre>&lt;RemSound folder&gt;\config\profiles\&lt;your computer name&gt;\&lt;profile name&gt;</pre> <pre>&lt;RemSound folder&gt;\user settings and logs\profiles\&lt;your computer name&gt;\&lt;profile name&gt;</pre>
<p>(If you're upgrading from an older version, RemSound moves your existing settings and profiles into this <code>config</code> folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.)</p> <p>(If you're upgrading from an older version, RemSound moves all of this into the <strong>user settings and logs</strong> folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.) From this version on, RemSound updates never touch that folder &mdash; so anything of your own in there, including custom cue sounds, stays safe when you update.</p>
<p>The folder named after your computer keeps each machine's profiles separate. If you used the <strong>Browse&hellip;</strong> button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder &mdash; with no per-computer subfolder &mdash; so two computers pointed at the same shared folder see exactly the same list.</p> <p>The folder named after your computer keeps each machine's profiles separate. If you used the <strong>Browse&hellip;</strong> button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder &mdash; with no per-computer subfolder &mdash; so two computers pointed at the same shared folder see exactly the same list.</p>
@@ -245,6 +245,8 @@ ul, ol { padding-left: 1.4em; }
<p><strong>Keyboard access:</strong> the tray icon is reachable through standard Windows shortcuts &mdash; <strong>Windows + B</strong> moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.</p> <p><strong>Keyboard access:</strong> the tray icon is reachable through standard Windows shortcuts &mdash; <strong>Windows + B</strong> moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.</p>
<p><strong>Important warnings always come to the front.</strong> Even when RemSound is hidden in the tray, a warning it needs you to read &mdash; such as the &ldquo;your files have moved&rdquo; notice, a microphone-blocked warning, or an update prompt &mdash; pops up in front of whatever you're doing, with focus, so your screen reader reads it straight away. RemSound stays in the tray; only the warning comes forward.</p>
<h3>Only one copy of RemSound runs at a time</h3> <h3>Only one copy of RemSound runs at a time</h3>
<p>RemSound only ever runs as a single copy. If you try to open it while it's already running &mdash; for example by double-clicking it when it's already sitting in the system tray &mdash; it won't start a second one. Instead it asks what you'd like to do:</p> <p>RemSound only ever runs as a single copy. If you try to open it while it's already running &mdash; for example by double-clicking it when it's already sitting in the system tray &mdash; it won't start a second one. Instead it asks what you'd like to do:</p>
@@ -371,6 +373,10 @@ ul, ol { padding-left: 1.4em; }
<strong>Capturing your speakers can cause an echo loop.</strong> If you tick the same device both in &ldquo;WASAPI outputs to send&rdquo; and in &ldquo;WASAPI outputs for received sound&rdquo;, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once. <strong>Capturing your speakers can cause an echo loop.</strong> If you tick the same device both in &ldquo;WASAPI outputs to send&rdquo; and in &ldquo;WASAPI outputs for received sound&rdquo;, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once.
</div> </div>
<div class="note">
<strong>If your microphone sends silence:</strong> Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence &mdash; so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in <strong>WASAPI inputs to send</strong> while Windows is blocking it &mdash; or load a profile that already has one ticked &mdash; a message pops up telling you, with the exact two settings to turn on &mdash; open Windows Settings &rarr; Privacy &amp; security &rarr; Microphone, then turn on both <em>Microphone access</em> and <em>Let desktop apps access your microphone</em>. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive &mdash; only sending your own mic.
</div>
<h2 id="tab-profile">8. Audio profile tab</h2> <h2 id="tab-profile">8. Audio profile tab</h2>
<p>Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the <em>priority mode</em> checkbox &mdash; it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: <strong>Audio send parameters</strong> first, then <strong>Audio receive parameters</strong>.</p> <p>Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the <em>priority mode</em> checkbox &mdash; it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: <strong>Audio send parameters</strong> first, then <strong>Audio receive parameters</strong>.</p>
@@ -611,6 +617,10 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.</p> <p>Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.</p>
<h3>The sound-card cushion is automatic</h3>
<p>Separately from the controls above &mdash; which manage the cushion against <em>network</em> jitter &mdash; RemSound also keeps a small cushion at the sound card itself, to smooth over the tiny timing differences between your two computers' sound clocks. From this version, RemSound sizes that cushion to each card automatically: a card that moves sound in bigger chunks (some onboard and USB cards do) gets a little more room, while a fast professional interface stays tight. You don't set this or think about it &mdash; it settles on the right amount for whatever card you're using.</p>
<h3>Audio latency control</h3> <h3>Audio latency control</h3>
<p>The <strong>Audio latency</strong> control tells the receiving side how much sound to keep in reserve as a cushion against uneven network timing. A bigger cushion means more delay but fewer clicks. A smaller cushion means less delay but more clicks when the network wobbles.</p> <p>The <strong>Audio latency</strong> control tells the receiving side how much sound to keep in reserve as a cushion against uneven network timing. A bigger cushion means more delay but fewer clicks. A smaller cushion means less delay but more clicks when the network wobbles.</p>
@@ -935,11 +945,11 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<h3>Going back to the default sound</h3> <h3>Going back to the default sound</h3>
<p>To revert a cue to its default sound, <strong>right-click</strong> the <em>Browse for [cue name]&hellip;</em> button and pick <strong>Use default sound</strong>. The custom path is forgotten and the cue goes back to playing the default WAV that ships with RemSound. The right-click option is greyed out when the cue is already using its default. (Alternatively, click <em>Browse</em> and pick a file from inside RemSound's own <code>sounds</code> folder &mdash; RemSound recognises that as &ldquo;use default&rdquo; and clears the override automatically.)</p> <p>To revert a cue to its default sound, <strong>right-click</strong> the <em>Browse for [cue name]&hellip;</em> button and pick <strong>Use default sound</strong>. The custom path is forgotten and the cue goes back to playing the default WAV that ships with RemSound. The right-click option is greyed out when the cue is already using its default. (Alternatively, click <em>Browse</em> and pick a file from RemSound's own <code>sounds</code> folder &mdash; inside <strong>user settings and logs</strong> &mdash; and RemSound treats that as &ldquo;use default&rdquo; and clears the override automatically.)</p>
<h3>Where the default sounds live</h3> <h3>Where the cue sounds live</h3>
<p>The default WAV files are in the <code>sounds</code> folder next to <code>RemSound.exe</code>. If you don't pick a custom file for a cue, RemSound plays the matching default from there:</p> <p>RemSound keeps the cue WAV files in a <code>sounds</code> folder inside <strong>user settings and logs</strong> &mdash; the same folder your settings and profiles live in. It puts the defaults there for you; if you don't pick a custom file for a cue, RemSound plays the matching one from this folder:</p>
<ul> <ul>
<li><code>sounds\connect.wav</code> &mdash; connect cue</li> <li><code>sounds\connect.wav</code> &mdash; connect cue</li>
@@ -952,6 +962,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<li><code>sounds\update.wav</code> &mdash; update cue</li> <li><code>sounds\update.wav</code> &mdash; update cue</li>
</ul> </ul>
<p>Because this folder is inside <strong>user settings and logs</strong>, RemSound updates never overwrite it. So if you drop your own WAV files in here in place of the defaults, your versions stay put when you update &mdash; you don't have to set them up again.</p>
<p>If a cue's WAV file is missing &mdash; either the default file doesn't exist or a custom path points at a file you've since deleted &mdash; the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.</p> <p>If a cue's WAV file is missing &mdash; either the default file doesn't exist or a custom path points at a file you've since deleted &mdash; the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.</p>
<div class="note"> <div class="note">
@@ -1051,7 +1063,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<h3>Start and stop sound cues</h3> <h3>Start and stop sound cues</h3>
<p>RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. These are two of the seven cues described in <a href="#audio-cues">Audio cue sounds</a>. You can turn either or both off, replace them with your own WAV files, and preview them from Preferences. The defaults live at <code>sounds\record start.wav</code> and <code>sounds\record stop.wav</code> next to <code>RemSound.exe</code>.</p> <p>RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. These are two of the eight cues described in <a href="#audio-cues">Audio cue sounds</a>. You can turn either or both off, replace them with your own WAV files, and preview them from Preferences. The defaults live at <code>sounds\record start.wav</code> and <code>sounds\record stop.wav</code> inside the <strong>user settings and logs</strong> folder.</p>
<h3>Where recordings go</h3> <h3>Where recordings go</h3>
@@ -1090,7 +1102,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<h2 id="logs">21. Logs and diagnostics</h2> <h2 id="logs">21. Logs and diagnostics</h2>
<p>If logging is turned on (the <strong>Enable logs</strong> checkbox in the Preferences dialog &mdash; Options &rarr; Preferences, or Ctrl+P &mdash; on by default), RemSound writes a log file each session into a <code>logs</code> folder next to the RemSound program. One file per launch.</p> <p>If logging is turned on (the <strong>Enable logs</strong> checkbox in the Preferences dialog &mdash; Options &rarr; Preferences, or Ctrl+P &mdash; on by default), RemSound writes a log file each session into a <code>logs</code> folder inside <strong>user settings and logs</strong> &mdash; the same folder your settings and profiles live in. One file per launch.</p>
<p>The file contains two kinds of rows:</p> <p>The file contains two kinds of rows:</p>
@@ -1126,7 +1138,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<ol> <ol>
<li>Audio inputs and outputs tab: is <strong>Send my audio</strong> ticked?</li> <li>Audio inputs and outputs tab: is <strong>Send my audio</strong> ticked?</li>
<li>Same tab: is at least one capture source ticked across the three send lists?</li> <li>Same tab: is at least one capture source ticked across the three send lists?</li>
<li>If you're using a microphone: is Windows' microphone privacy setting allowing apps to use it? (Settings &rarr; Privacy &rarr; Microphone.)</li> <li>If you're using a microphone: is Windows allowing apps to use it? RemSound now pops up a warning when you switch on a microphone Windows is blocking &mdash; including when a profile loads with one already on &mdash; but to check by hand, open Settings &rarr; Privacy &amp; security &rarr; Microphone and make sure both <em>Microphone access</em> and <em>Let desktop apps access your microphone</em> are on. (When Windows blocks it the mic sends silence rather than failing, so it's easy to miss.)</li>
<li>Have they ticked <em>your</em> name in their Discovered peers list?</li> <li>Have they ticked <em>your</em> name in their Discovered peers list?</li>
</ol> </ol>
@@ -1167,11 +1179,14 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<p>You probably picked Realtek ASIO. It's a generic driver, not tied to Realtek hardware, and it tends to grab whatever Windows treats as the default sound device &mdash; usually the same one your screen reader is using. Set the ASIO driver picker back to <em>(none)</em>, or pick a different ASIO driver.</p> <p>You probably picked Realtek ASIO. It's a generic driver, not tied to Realtek hardware, and it tends to grab whatever Windows treats as the default sound device &mdash; usually the same one your screen reader is using. Set the ASIO driver picker back to <em>(none)</em>, or pick a different ASIO driver.</p>
<h3>The device list shows old devices that are no longer plugged in</h3> <h3>The device list shows old devices that are no longer plugged in</h3>
<p>RemSound refreshes its lists every second. If a device has really been unplugged it should disappear within a few seconds. If it lingers, restart RemSound &mdash; Windows' own device list occasionally needs a nudge.</p> <p>RemSound reacts the moment a device is plugged in or unplugged, so an unplugged device should disappear within a second or two. If one lingers, restart RemSound &mdash; Windows' own device list occasionally needs a nudge.</p>
<h3>No sound after the computer wakes from sleep</h3> <h3>No sound after the computer wakes from sleep</h3>
<p>RemSound notices when the computer has just woken up, waits a moment for any USB sound devices to come back to life, and rebuilds its audio engine from scratch &mdash; you'll briefly see a small &ldquo;Reconnecting to audio driver&rdquo; window during the rebuild, then sound should resume on its own. If sound still doesn't come back, click on the ASIO driver picker on the Audio inputs and outputs tab and re-pick the same driver (or pick <em>(none)</em> and then re-pick your driver). That triggers the same full rebuild manually. As a last resort, quit and reopen RemSound.</p> <p>RemSound notices when the computer has just woken up, waits a moment for any USB sound devices to come back to life, and rebuilds its audio engine from scratch &mdash; you'll briefly see a small &ldquo;Reconnecting to audio driver&rdquo; window during the rebuild, then sound should resume on its own. If sound still doesn't come back, click on the ASIO driver picker on the Audio inputs and outputs tab and re-pick the same driver (or pick <em>(none)</em> and then re-pick your driver). That triggers the same full rebuild manually. As a last resort, quit and reopen RemSound.</p>
<h3>A sound card you were listening through was unplugged</h3>
<p>If a sound card you're playing received audio through is unplugged and then plugged back in, RemSound now re-opens it on its own and the sound resumes &mdash; you don't have to re-tick it in the output list. This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket. If you move it to a different USB socket and Windows treats it as a brand-new device, just tick it again in the output list.</p>
<h3>UPnP says &ldquo;no router found&rdquo; even though my router supports it</h3> <h3>UPnP says &ldquo;no router found&rdquo; even though my router supports it</h3>
<p>The most common reasons:</p> <p>The most common reasons:</p>
<ul> <ul>
+31
View File
@@ -20,6 +20,37 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary> /// updates" path.</summary>
private const string ReleaseNotes = private const string ReleaseNotes =
""" """
RemSound v3.5
Recover a sound card you unplug: if a USB sound card
you're listening through is pulled out and plugged back
in, RemSound re-opens it on its own and audio resumes
no need to re-tick it.
Each sound card now gets the right audio cushion
automatically. RemSound sizes it to the card, so one that
needs a little more gets it without any fiddling, and a
fast card stays tight.
A microphone-privacy heads-up: if Windows is blocking
microphone access and you switch a mic on, RemSound warns
you once, so you're not left wondering why no one can
hear you.
Warnings always come to the front now, even when RemSound
is tucked away in the system tray so you never miss one.
Everything this machine keeps for you settings,
profiles, logs and your cue sounds now lives in one
tidy folder called "user settings and logs". It moves
there automatically the first time you run this version.
From now on, updates never touch that folder, so any
custom cue sounds you put there are safe.
Also: your volume and mute now come back correctly when
you load a profile, plus a batch of under-the-hood
reliability and tidy-up work.
RemSound v3.4 RemSound v3.4
Quick profile switch: a new global hotkey pops up a Quick profile switch: a new global hotkey pops up a
+76
View File
@@ -0,0 +1,76 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace RemSound.App;
/// <summary>
/// Shows a dialog FRONT-AND-CENTRE with keyboard focus, WITHOUT disturbing the main window — it can
/// stay minimised in the tray the whole time. We give the dialog a momentary, top-most, off-taskbar
/// 1×1 owner window at screen centre and force THAT owner to the foreground (the AttachThreadInput
/// dance bypasses Windows' focus-stealing lock), so the modal dialog opens on top with focus and a
/// screen reader lands on it wherever RemSound happens to be sitting. Used for every warning/notice
/// the app raises (mic-privacy, Realtek, About-after-update, config-moved), so a minimised RemSound
/// never leaves a blind user with a dialog dinging away behind everything.
/// </summary>
internal static class ForegroundDialog
{
/// <summary>Run <paramref name="show"/> with a foreground 1×1 owner; returns its result.</summary>
public static T Show<T>(Func<IWin32Window, T> show)
{
var area = Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 800, 600);
using var owner = new Form
{
ShowInTaskbar = false,
FormBorderStyle = FormBorderStyle.None,
StartPosition = FormStartPosition.Manual,
Size = new Size(1, 1),
Location = new Point(area.X + area.Width / 2, area.Y + area.Height / 2),
TopMost = true,
};
owner.Show();
ForceForeground(owner.Handle);
try { return show(owner); }
finally { try { owner.Close(); } catch { /* ignore */ } }
}
/// <summary>Void convenience overload.</summary>
public static void Show(Action<IWin32Window> show) =>
Show<object?>(owner => { show(owner); return null; });
/// <summary>Force <paramref name="hWnd"/> to the foreground even when RemSound isn't the active
/// app. A plain SetForegroundWindow from a background process is refused by Windows; attaching
/// our input queue to the current foreground thread for the call lifts that restriction. The
/// owner is top-most regardless, so this is belt-and-braces for focus.</summary>
private static void ForceForeground(IntPtr hWnd)
{
try
{
var foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
var thisThread = GetCurrentThreadId();
if (foreThread != 0 && foreThread != thisThread)
{
AttachThreadInput(foreThread, thisThread, true);
try
{
BringWindowToTop(hWnd);
SetForegroundWindow(hWnd);
}
finally { AttachThreadInput(foreThread, thisThread, false); }
}
else
{
BringWindowToTop(hWnd);
SetForegroundWindow(hWnd);
}
}
catch { /* best-effort; the owner is top-most anyway */ }
}
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd);
}
+151 -124
View File
@@ -383,6 +383,11 @@ public sealed class MainForm : Form
// Windows audio endpoint-change notifier — drives the debounced device-list refresh. Null until // Windows audio endpoint-change notifier — drives the debounced device-list refresh. Null until
// wired in the constructor; disposed in FormClosing (which unregisters the COM callback). // wired in the constructor; disposed in FormClosing (which unregisters the COM callback).
private AudioDeviceChangeNotifier? deviceChangeNotifier; private AudioDeviceChangeNotifier? deviceChangeNotifier;
// Receive-output device IDs the user/profile selected — kept even while a device is unplugged,
// so a card that returns is silently re-ticked and re-opened (issue #5: recover after USB
// unplug). Receive-only: the send lists deliberately don't persist selection (AudioDeviceCatalog).
private readonly HashSet<string> rememberedReceiveOutputIds = new(StringComparer.OrdinalIgnoreCase);
// Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler // Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler
// wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts // wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts
// (NVDA users typically press a few keys in quick succession to scan through items), // (NVDA users typically press a few keys in quick succession to scan through items),
@@ -537,13 +542,6 @@ public sealed class MainForm : Form
/// when non-null — it deserialises the JSON from this exact path, not from the active /// when non-null — it deserialises the JSON from this exact path, not from the active
/// store's base directory. Lets Open profile work for files saved outside that folder.</summary> /// store's base directory. Lets Open profile work for files saved outside that folder.</summary>
public string? NextProfilePathToLoad { get; private set; } public string? NextProfilePathToLoad { get; private set; }
// Baseline JSON snapshot of "what the loaded profile was at open / after the last save".
// OnFormClosing compares the current state's JSON to this; if they differ, prompt the
// user. Captured ~3 s after profile-apply (or app start for blank template) so async
// peer-reconnects have settled into the baseline. Null until that timer fires; if it's
// null at close (e.g. user closed within 3 s of opening) we skip the prompt — treating
// very-fast-close as "user knew what they wanted".
private string? baselineProfileJson;
// Set true by MarkProfileDirty() when the user actively changes something. Used as a // Set true by MarkProfileDirty() when the user actively changes something. Used as a
// fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip // fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip
// the diff entirely when no user action has happened. Cleared on save and on profile load. // the diff entirely when no user action has happened. Cleared on save and on profile load.
@@ -993,11 +991,37 @@ public sealed class MainForm : Form
sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox); sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox);
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); }; volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device"); WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } }; receiveOutputDevicesList.ItemCheck += (_, e) =>
{
if (suppressDeviceCheckChange) return;
// Track the user's intent so a card that's later unplugged is re-ticked + re-opened when
// it returns (issue #5). See ReapplyRememberedReceiveOutputs.
if (receiveOutputDevicesList.Items[e.Index] is AudioDeviceChoice c && c.DeviceId is { } rid)
{
if (e.NewValue == CheckState.Checked) rememberedReceiveOutputIds.Add(rid);
else rememberedReceiveOutputIds.Remove(rid);
}
BeginInvoke(ApplyReceiveDevices);
MarkProfileDirty();
};
WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device"); WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device");
WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device"); WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device");
sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
sendInputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } }; sendInputDevicesList.ItemCheck += (_, args) =>
{
if (suppressDeviceCheckChange) return;
BeginInvoke(ApplyAudioRuntime);
MarkProfileDirty();
// Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app
// microphone access — capture would open but silently send nothing. Deferred so the
// tick commits first and the modal doesn't re-enter ItemCheck. Skipped while a profile
// is being applied: the startup check (MaybeWarnMicBlockedOnStartup) owns the warning
// then, so a launch into a blocked-mic profile doesn't pop it twice.
if (!applyingProfile && args.NewValue == CheckState.Checked && IsMicrophoneBlockedByWindowsPrivacy())
{
BeginInvoke(new Action(WarnMicrophoneBlockedByWindowsPrivacy));
}
};
// ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones. // ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones.
WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel"); WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel");
WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel"); WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel");
@@ -1176,17 +1200,11 @@ public sealed class MainForm : Form
// checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are // checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are
// populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.) // populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.)
// were already pushed into the in-memory settings cache in the constructor. // were already pushed into the in-memory settings cache in the constructor.
// ApplyPendingProfileToControls() schedules its own baseline capture; for the
// blank-template case (no pendingProfile) we schedule it here.
if (pendingProfile is null) ScheduleBaselineCapture();
ApplyPendingProfileToControls(); ApplyPendingProfileToControls();
// The profile-switch cue is played ON CLICK by the switch entry points (Recent menu, // The profile-switch cue is played ON CLICK by the switch entry points (Recent menu,
// quick switch, File open) — NOT here. A fresh launch into the first profile must stay // quick switch, File open) — NOT here. A fresh launch into the first profile must stay
// silent: hearing the switch cue and then the connect cue at startup is confusing // silent: hearing the switch cue and then the connect cue at startup is confusing
// (Ed, 2026-06-08). So the rebuilt form never replays it. // (Ed, 2026-06-08). So the rebuilt form never replays it.
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
// profile or the blank template.
UpdateProfileButtonsVisibility();
// Andre's app gets focus inside the active tab page for free because his form is // Andre's app gets focus inside the active tab page for free because his form is
// a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the // a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the
// chain TabControl → active TabPage → first child. Our form is the main window, // chain TabControl → active TabPage → first child. Our form is the main window,
@@ -1300,6 +1318,8 @@ public sealed class MainForm : Form
MaybeShowWhatsNewAfterUpdate(); MaybeShowWhatsNewAfterUpdate();
if (IsDisposed) return; if (IsDisposed) return;
MaybeWarnAboutRealtekAsio(); MaybeWarnAboutRealtekAsio();
if (IsDisposed) return;
MaybeWarnMicBlockedOnStartup();
} }
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the /// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
@@ -1325,7 +1345,7 @@ public sealed class MainForm : Form
{ {
logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}"); logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}");
using var dlg = new AboutDialog(); using var dlg = new AboutDialog();
dlg.ShowDialog(this); ForegroundDialog.Show(owner => dlg.ShowDialog(owner));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -2253,8 +2273,8 @@ public sealed class MainForm : Form
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?" ? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var choice = MessageBox.Show(this, summary, "Update available", var choice = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
} }
@@ -2310,7 +2330,7 @@ public sealed class MainForm : Form
// "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short // "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short
// countdown but lets the user pick Install now / Skip / Postpone before then. // countdown but lets the user pick Install now / Skip / Postpone before then.
using var notice = new UpdateInstallNoticeDialog(info); using var notice = new UpdateInstallNoticeDialog(info);
var choice = notice.ShowDialog(this); var choice = ForegroundDialog.Show(owner => notice.ShowDialog(owner));
switch (choice) switch (choice)
{ {
case DialogResult.OK: case DialogResult.OK:
@@ -2337,8 +2357,8 @@ public sealed class MainForm : Form
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes) var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?" ? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?"; : $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var pick = MessageBox.Show(this, summary, "Update available", var pick = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true); if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
} }
@@ -2375,9 +2395,9 @@ public sealed class MainForm : Form
// Nothing was staged — allow a later attempt rather than wedging the updater off // Nothing was staged — allow a later attempt rather than wedging the updater off
// for the rest of the session. // for the rest of the session.
updateInstallStarted = false; updateInstallStarted = false;
MessageBox.Show(this, ForegroundDialog.Show(owner => MessageBox.Show(owner,
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}", $"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning); "Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning));
return; return;
} }
// The helper is staged and launched. We MUST now exit cleanly so it can replace our // The helper is staged and launched. We MUST now exit cleanly so it can replace our
@@ -3754,6 +3774,7 @@ public sealed class MainForm : Form
{ {
ApplyAudioRuntime(); ApplyAudioRuntime();
} }
if (receiveOutputChanged) ReapplyRememberedReceiveOutputs();
if (receiveOutputChanged || asioReceiveChanged) if (receiveOutputChanged || asioReceiveChanged)
{ {
ApplyReceiveDevices(); ApplyReceiveDevices();
@@ -3800,6 +3821,67 @@ public sealed class MainForm : Form
return null; return null;
} }
/// <summary>Reads Windows' microphone privacy setting and returns true when DESKTOP apps (which
/// RemSound is) are blocked from the mic. When blocked, WASAPI capture still opens but returns
/// pure silence, so the user "sends" but the peer hears nothing. Registry-based: the
/// CapabilityAccessManager ConsentStore "Value" is "Allow"/"Deny"; either the general per-user
/// gate or the NonPackaged (desktop-app) gate set to Deny blocks us. Best-effort — any failure
/// returns false so a registry hiccup never stops the user enabling their mic.</summary>
private static bool IsMicrophoneBlockedByWindowsPrivacy()
{
try
{
const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone";
return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent)
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent + @"\NonPackaged")
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent);
}
catch
{
return false;
}
}
private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey)
{
using var key = root.OpenSubKey(subKey);
return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase);
}
/// <summary>One-shot, OK-only message telling the user Windows is blocking desktop-app mic
/// access (so their mic would send silence) and exactly which two toggles to turn on. Shown
/// when they tick a WASAPI mic on while the block is in place.</summary>
private void WarnMicrophoneBlockedByWindowsPrivacy()
{
ForegroundDialog.Show(owner => MessageBox.Show(owner,
"Windows is currently blocking desktop apps from using your microphone, so RemSound can "
+ "switch the mic on but will only send silence - the people you're connected to won't "
+ "hear you.\n\n"
+ "To fix it, open Windows Settings, go to Privacy & security, then Microphone, and turn "
+ "ON both of these:\n\n"
+ " - Microphone access\n"
+ " - Let desktop apps access your microphone\n\n"
+ "Then your mic will work. This doesn't affect sound you receive - only sending your "
+ "own microphone.",
"Windows is blocking your microphone",
MessageBoxButtons.OK, MessageBoxIcon.Warning));
}
/// <summary>On profile load, if a WASAPI microphone is already ticked but Windows is blocking
/// desktop-app mic access, show the warning once — the per-tick warning only fires on a fresh
/// tick, so this covers a profile that loads with the mic already on. Same OK-only message.</summary>
private void MaybeWarnMicBlockedOnStartup()
{
if (IsDisposed) return;
if (!IsMicrophoneBlockedByWindowsPrivacy()) return;
var anyWasapiMicChecked = false;
for (var i = 0; i < sendInputDevicesList.Items.Count; i++)
{
if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; }
}
if (anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
}
/// <summary> /// <summary>
/// On startup, if a Realtek ASIO driver is installed and we haven't already disabled it or /// On startup, if a Realtek ASIO driver is installed and we haven't already disabled it or
/// shown the warning, offer (once) to disable it — Realtek's ASIO driver leaks OS handles on /// shown the warning, offer (once) to disable it — Realtek's ASIO driver leaks OS handles on
@@ -3847,7 +3929,10 @@ public sealed class MainForm : Form
+ "This driver is known to cause compatibility issues with ASIO software, including " + "This driver is known to cause compatibility issues with ASIO software, including "
+ "RemSound — it leaks system resources and can make audio unstable.\n\n" + "RemSound — it leaks system resources and can make audio unstable.\n\n"
+ "Would you like to disable it in RemSound? RemSound will then never touch this " + "Would you like to disable it in RemSound? RemSound will then never touch this "
+ "driver. You can re-enable it any time from the Options menu.", + "driver.\n\n"
+ "Whichever you choose now, you can change it at any time: the Options menu has an "
+ "\"Enable / Disable Realtek ASIO driver\" item that turns this driver on or off for "
+ "RemSound whenever you like.",
Icon = TaskDialogIcon.Warning, Icon = TaskDialogIcon.Warning,
}; };
var yes = new TaskDialogButton("&Yes, disable it (recommended)"); var yes = new TaskDialogButton("&Yes, disable it (recommended)");
@@ -3855,7 +3940,7 @@ public sealed class MainForm : Form
page.Buttons.Add(yes); page.Buttons.Add(yes);
page.Buttons.Add(no); page.Buttons.Add(no);
page.DefaultButton = yes; page.DefaultButton = yes;
return TaskDialog.ShowDialog(this, page) == yes; return ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)) == yes;
} }
/// <summary>Options-menu handler: flip every installed Realtek ASIO driver between disabled and /// <summary>Options-menu handler: flip every installed Realtek ASIO driver between disabled and
@@ -4058,6 +4143,32 @@ public sealed class MainForm : Form
receiver.SetOutputDevices(ids); receiver.SetOutputDevices(ids);
} }
/// <summary>A receive-output card that was unplugged drops out of the WASAPI list (and its tick
/// with it). When it returns, re-tick it from <see cref="rememberedReceiveOutputIds"/> so audio
/// resumes automatically (issue #5). Only RE-ticks present-but-unticked remembered devices; it
/// never unticks — that's a deliberate user action handled in the ItemCheck handler. Receive-only
/// on purpose: the send lists keep their "re-tick each session" behaviour (see AudioDeviceCatalog).</summary>
private void ReapplyRememberedReceiveOutputs()
{
if (rememberedReceiveOutputIds.Count == 0) return;
var changed = false;
suppressDeviceCheckChange = true;
try
{
for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++)
{
if (receiveOutputDevicesList.Items[i] is not AudioDeviceChoice c || c.DeviceId is null) continue;
if (rememberedReceiveOutputIds.Contains(c.DeviceId) && !receiveOutputDevicesList.GetItemChecked(i))
{
receiveOutputDevicesList.SetItemChecked(i, true);
changed = true;
}
}
}
finally { suppressDeviceCheckChange = false; }
if (changed) logFile.Event("receive output: re-ticked a returning device from the remembered selection");
}
/// <summary> /// <summary>
/// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective /// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective
/// modes after the 2026-05-11 cleanup: /// modes after the 2026-05-11 cleanup:
@@ -4839,19 +4950,6 @@ public sealed class MainForm : Form
if (!list.Focused) list.Focus(); if (!list.Focused) list.Focus();
} }
private void RemoveSelectedManualPeer(CheckedListBox list)
{
if (list.SelectedItem is not PeerListItem selected) return;
manualPeers.Remove(selected.Peer.InstanceId);
DeselectPeer(selected.Peer.InstanceId);
foreach (var pair in rememberedPeerInstanceIds.Where(kv => kv.Value == selected.Peer.InstanceId).ToList())
{
rememberedPeerInstanceIds.Remove(pair.Key);
}
RefreshKnownPeers();
ApplyAudioRuntime();
}
private void RemoveSelectedRememberedPeer(CheckedListBox list) private void RemoveSelectedRememberedPeer(CheckedListBox list)
{ {
if (list.SelectedItem is not RememberedPeerItem selected) return; if (list.SelectedItem is not RememberedPeerItem selected) return;
@@ -5494,11 +5592,20 @@ public sealed class MainForm : Form
{ {
// Volume first — affects what's audible during the rest of this method. // Volume first — affects what's audible during the rest of this method.
volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum); volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum);
// Push volume + mute to the engine. Assigning .Value does NOT fire the Scroll handler, so
// without this a profile saved at e.g. 50% would show 50% but play at full volume until
// the slider was nudged. Mute is restored from the saved state for the same reason.
receiver.Volume = volumeBar.Value / 100f;
receiver.IsMuted = p.Muted;
// Tick checkboxes. Order matters: setting Checked fires runtime apply paths // Tick checkboxes. Order matters: setting Checked fires runtime apply paths
// (Connect/Disconnect) so the side-effect cascade has to happen here, not in // (Connect/Disconnect) so the side-effect cascade has to happen here, not in
// the constructor where the engines aren't fully wired up yet. // the constructor where the engines aren't fully wired up yet.
ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs); ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs);
// Seed remembered receive-output intent from the profile so a selected card that's
// absent now (or unplugged later) is re-ticked + re-opened when it appears (issue #5).
rememberedReceiveOutputIds.Clear();
foreach (var rid in p.SelectedWasapiReceiveOutputs) rememberedReceiveOutputIds.Add(rid);
ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs); ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs);
ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs); ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs);
ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs); ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs);
@@ -5524,9 +5631,6 @@ public sealed class MainForm : Form
pendingProfile = null; pendingProfile = null;
applyingProfile = false; applyingProfile = false;
} }
// Schedule baseline capture for the unsaved-changes-on-close check. Done as a
// delayed snapshot so async peer-reconnects have settled.
ScheduleBaselineCapture();
} }
/// <summary>Tick the items in <paramref name="list"/> whose DeviceId appears in /// <summary>Tick the items in <paramref name="list"/> whose DeviceId appears in
@@ -5561,21 +5665,6 @@ public sealed class MainForm : Form
: $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}"; : $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
} }
/// <summary>Show/hide the Update button based on whether a profile is currently loaded.
/// Update only makes sense when there's an existing profile to overwrite; Save-as is
/// always available (and the only way to save from a blank template). Both Visible and
/// Enabled are toggled — Visible to keep NVDA / sighted users from seeing it, Enabled
/// so the Alt+U hotkey is a no-op even if focus somehow lands on it.</summary>
private void UpdateProfileButtonsVisibility()
{
// Retained as a stub — multiple call sites still poke this on profile load /
// save-as / rename. With the Profiles tab retired (2026-05-08) there's no UI to
// refresh; the Save / Rename actions on the File menu work for both
// blank-template and loaded-profile states because the menu handlers branch on
// currentProfileTitle internally. The window title is updated where the profile
// title actually changes (SaveProfileTo, RenameCurrentProfile, profile-load).
}
/// <summary>Update existing profile button. Overwrites the active profile with current /// <summary>Update existing profile button. Overwrites the active profile with current
/// state. No prompt — user explicitly chose this button to commit. Hidden when no /// state. No prompt — user explicitly chose this button to commit. Hidden when no
/// profile is loaded.</summary> /// profile is loaded.</summary>
@@ -5647,11 +5736,7 @@ public sealed class MainForm : Form
} }
Text = FormatWindowTitle(title); Text = FormatWindowTitle(title);
AccessibleName = Text; AccessibleName = Text;
UpdateProfileButtonsVisibility();
AppendLogEntry($"profile saved: \"{title}\" → {path}"); AppendLogEntry($"profile saved: \"{title}\" → {path}");
// Refresh baseline so the diff against unsaved-changes uses the just-saved state.
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* baseline failure shouldn't block save */ }
unsavedChanges = false; unsavedChanges = false;
// A freshly created profile has no password yet, and encryption is always on — so // A freshly created profile has no password yet, and encryption is always on — so
// ask for one now and write it straight into the file we just saved. OK requires a // ask for one now and write it straight into the file we just saved. OK requires a
@@ -5659,7 +5744,7 @@ public sealed class MainForm : Form
// streaming gate will ask again when needed. // streaming gate will ask again when needed.
if (string.IsNullOrEmpty(currentProfilePassword)) if (string.IsNullOrEmpty(currentProfilePassword))
{ {
var pw = ProfilePasswordDialog.Show(this, title, "", requireNonEmpty: true); var pw = ProfilePasswordDialog.Show(title, "", requireNonEmpty: true);
if (!string.IsNullOrEmpty(pw)) if (!string.IsNullOrEmpty(pw))
{ {
currentProfilePassword = pw; currentProfilePassword = pw;
@@ -5728,11 +5813,6 @@ public sealed class MainForm : Form
// profile flag; the cue is silent if the user has unticked it in Preferences or if // profile flag; the cue is silent if the user has unticked it in Preferences or if
// sounds\save.wav doesn't exist and no custom override has been set. // sounds\save.wav doesn't exist and no custom override has been set.
if (settings.LoadEnableSaveCue()) saveSound?.Play(); if (settings.LoadEnableSaveCue()) saveSound?.Play();
// Refresh the unsaved-changes baseline so this saved state becomes the new
// "no changes" reference. The Title field changes on save-as, so the next
// diff comparison must use the new state as baseline, not the pre-save one.
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* baseline failure shouldn't block save */ }
unsavedChanges = false; unsavedChanges = false;
if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed) if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed)
{ {
@@ -5778,7 +5858,6 @@ public sealed class MainForm : Form
currentProfileTitle = title; currentProfileTitle = title;
Text = FormatWindowTitle(title); Text = FormatWindowTitle(title);
AccessibleName = Text; AccessibleName = Text;
UpdateProfileButtonsVisibility();
} }
/// <summary>Mark the profile as having unsaved user changes. No-op while a profile is /// <summary>Mark the profile as having unsaved user changes. No-op while a profile is
@@ -5829,16 +5908,6 @@ public sealed class MainForm : Form
profile.ReadOnly = readOnly; profile.ReadOnly = readOnly;
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson); 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) catch (Exception ex)
{ {
@@ -5851,14 +5920,6 @@ public sealed class MainForm : Form
} }
} }
/// <summary>Serialise an arbitrary <see cref="Profile"/> in the same shape
/// <see cref="SerializeCurrentStateAsProfile"/> uses for the dirty-diff. Lives here so
/// the lock-flag persistence path can refresh the baseline against the rewritten file
/// contents (a partial overwrite of the profile file) without flushing the user's
/// in-session edits. 2026-05-22.</summary>
private static string SerializeProfileForDirtyDiff(Profile profile) =>
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
/// <summary>File → Change this profile's password. Shows the current password (plain text, /// <summary>File → Change this profile's password. Shows the current password (plain text,
/// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST /// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST
/// the password back to the profile file straight away — same immediate-persist approach as /// the password back to the profile file straight away — same immediate-persist approach as
@@ -5874,7 +5935,7 @@ public sealed class MainForm : Form
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
var entered = ProfilePasswordDialog.Show(this, currentProfileTitle, currentProfilePassword); var entered = ProfilePasswordDialog.Show(currentProfileTitle, currentProfilePassword);
if (entered is null) return; // cancelled if (entered is null) return; // cancelled
currentProfilePassword = entered; currentProfilePassword = entered;
RecomputeAudioCrypto(); RecomputeAudioCrypto();
@@ -5898,10 +5959,6 @@ public sealed class MainForm : Form
profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword); profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword);
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson); File.WriteAllText(currentProfilePath, newJson);
// Refresh the dirty-diff baseline against the rewritten file so the password change
// we just persisted doesn't read back as an unsaved change on close.
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
catch { /* baseline refresh is best-effort */ }
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -5946,7 +6003,7 @@ public sealed class MainForm : Form
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle; var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
var entered = ProfilePasswordDialog.Show(this, label, "", requireNonEmpty: true); var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true);
if (string.IsNullOrEmpty(entered)) if (string.IsNullOrEmpty(entered))
{ {
// No password → can't stream. Put the box back without re-firing this gate. // No password → can't stream. Put the box back without re-firing this gate.
@@ -5960,9 +6017,9 @@ public sealed class MainForm : Form
// Offer to remember it on the profile (if we're on a saved one). // Offer to remember it on the profile (if we're on a saved one).
if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath)) if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath))
{ {
var save = MessageBox.Show(this, var save = ForegroundDialog.Show(owner => MessageBox.Show(owner,
$"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?", $"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?",
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question); AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question));
if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword); if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword);
} }
return true; return true;
@@ -6032,31 +6089,6 @@ public sealed class MainForm : Form
} }
} }
/// <summary>Serializes the current control state as if the user had just clicked Save.
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
/// but doesn't write anywhere.</summary>
private string SerializeCurrentStateAsProfile() =>
JsonSerializer.Serialize(BuildCurrentProfile(currentProfileTitle ?? ""));
/// <summary>Capture the "this is what no-changes-since-load looks like" baseline 3 seconds
/// after the profile has been applied (or the app has started, for blank template). The
/// delay lets async peer-reconnects finish so they're folded into the baseline rather
/// than seen as user-initiated changes. If the user closes within those 3 seconds the
/// baseline is null and we just close without prompting (treating fast-close as
/// confident-close).</summary>
private void ScheduleBaselineCapture()
{
var timer = new System.Windows.Forms.Timer { Interval = 3000 };
timer.Tick += (_, _) =>
{
timer.Stop();
timer.Dispose();
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* ignore — baseline just stays null */ }
};
timer.Start();
}
private static List<string> ExtractCheckedDeviceIds(CheckedListBox list) private static List<string> ExtractCheckedDeviceIds(CheckedListBox list)
{ {
var result = new List<string>(); var result = new List<string>();
@@ -6297,7 +6329,7 @@ public sealed class MainForm : Form
} }
else else
{ {
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName); var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
if (File.Exists(defaultPath)) if (File.Exists(defaultPath))
{ {
path = defaultPath; path = defaultPath;
@@ -6794,11 +6826,6 @@ public sealed class MainForm : Form
// can't make the ASIO lane's auto-tune defer (and vice versa). // can't make the ASIO lane's auto-tune defer (and vice versa).
if (settings.LoadAudioMode() == AudioMode.BothIndependent) if (settings.LoadAudioMode() == AudioMode.BothIndependent)
{ {
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps
// window is populated by every incoming packet regardless of lane, so without
// this gate a route with no audio would still react to the OTHER route's
// gap signal and silently inflate its target before any of its own audio has
// arrived.
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps // Skip ticking a lane that has no active sessions. The shared recentMaxGaps
// window is populated by every incoming packet regardless of lane, so without // window is populated by every incoming packet regardless of lane, so without
// this gate a route with no audio would still react to the OTHER route's // this gate a route with no audio would still react to the OTHER route's
+2 -2
View File
@@ -622,7 +622,7 @@ internal sealed class PreferencesDialog : Form
_ => null, _ => null,
}; };
if (defaultFileName is null) return null; if (defaultFileName is null) return null;
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName); var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
return File.Exists(defaultPath) ? defaultPath : null; return File.Exists(defaultPath) ? defaultPath : null;
} }
@@ -667,7 +667,7 @@ internal sealed class PreferencesDialog : Form
/// fires on the way out.</summary> /// fires on the way out.</summary>
private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings) private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings)
{ {
var soundsFolder = Path.Combine(AppContext.BaseDirectory, "sounds"); var soundsFolder = AppConfig.SoundsDirectory;
var existing = settings.LoadCustomCuePath(cue.CueId); var existing = settings.LoadCustomCuePath(cue.CueId);
var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing) var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing)
? Path.GetDirectoryName(existing) ?? soundsFolder ? Path.GetDirectoryName(existing) ?? soundsFolder
+8 -2
View File
@@ -13,7 +13,7 @@ namespace RemSound.App;
/// </summary> /// </summary>
internal static class ProfilePasswordDialog internal static class ProfilePasswordDialog
{ {
public static string? Show(IWin32Window owner, string profileTitle, string currentPassword, bool requireNonEmpty = false) public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
{ {
using var dialog = new Form using var dialog = new Form
{ {
@@ -99,6 +99,12 @@ internal static class ProfilePasswordDialog
dialog.AcceptButton = okButton; dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton; dialog.CancelButton = cancelButton;
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null; // Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
// sitting minimised in the tray — e.g. a quick profile switch to a passwordless-but-
// streaming profile trips the password gate mid-switch, and the user must be able to read
// and answer it there and then. Centres on screen, forces focus, then closes.
return ForegroundDialog.Show(owner => dialog.ShowDialog(owner)) == DialogResult.OK
? textBox.Text.Trim()
: null;
} }
} }
+92 -17
View File
@@ -24,10 +24,19 @@ internal static class Program
ApplicationConfiguration.Initialize(); ApplicationConfiguration.Initialize();
// Relocate any pre-2026-06-07 config/profiles into config\ before anything reads them. // Consolidate every older layout (loose files, or the interim config\ folder) into the single
// Idempotent and best-effort; also upgrades users coming from an older build. The result // "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
// is shown to the user once (after the single-instance guard) if files actually moved. // best-effort; upgrades users from any older build. Shown to the user once if files moved.
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded(); var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
// Move the cue sounds into that folder too — seeded from the shipped defaults (see method).
ConsolidateSounds();
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
// sounds\. A robocopy update copies the new sounds\ tree but uses /E (not /PURGE), so it
// never deletes these orphans — they just linger in the root. Best-effort + idempotent:
// a no-op once they're gone. 2026-06-08.
CleanUpLegacyRootSounds();
// Single-instance guard. RemSound must never run as two copies at once: with the // Single-instance guard. RemSound must never run as two copies at once: with the
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two // auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
@@ -84,7 +93,7 @@ internal static class Program
// here, after the guard and before the profile picker, so the user reads it once up front. // here, after the guard and before the profile picker, so the user reads it once up front.
if (layoutMigration.MovedAnything) if (layoutMigration.MovedAnything)
{ {
ShowLayoutMigrationNotice(layoutMigration); ShowLayoutMigrationNotice();
} }
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session. // Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
@@ -248,28 +257,94 @@ internal static class Program
} }
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved /// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
/// into the new <c>config\</c> folder. Only called when a real migration happened. TaskDialog /// into the new "user settings and logs" folder. Only called when a real migration happened. TaskDialog
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary> /// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
private static void ShowLayoutMigrationNotice(RemSound.Core.AppConfig.LayoutMigrationResult migration) private static void ShowLayoutMigrationNotice()
{ {
var moved = new System.Collections.Generic.List<string>();
if (migration.MovedGlobalConfig) moved.Add("- Your settings are now in: config\\global config.json");
if (migration.MovedProfiles) moved.Add("- Your saved profiles are now in: config\\profiles\\");
var page = new TaskDialogPage var page = new TaskDialogPage
{ {
Caption = "RemSound settings location", Caption = "RemSound files location",
Heading = "Your settings now live in a \"config\" folder", Heading = "Your RemSound files have moved into one folder",
Text = "To keep the RemSound folder tidy, this update moved your existing settings into a new " Text = "To keep the RemSound folder tidy and stop updates from ever touching your own files, "
+ "\"config\" folder inside RemSound:\n\n" + "this update moved everything this machine owns into a single folder inside RemSound "
+ string.Join("\n", moved) + "called \"user settings and logs\":\n\n"
+ "\n\nNothing was lost and RemSound works exactly as before. You will only see this message once.", + "- Your settings (global config)\n"
+ "- Your saved profiles\n"
+ "- Your logs\n"
+ "- Your cue sounds\n\n"
+ "Nothing was lost and RemSound works exactly as before. From now on, RemSound updates "
+ "leave that folder completely untouched. You will only see this message once.",
Icon = TaskDialogIcon.Information, Icon = TaskDialogIcon.Information,
Buttons = { TaskDialogButton.OK }, Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK, DefaultButton = TaskDialogButton.OK,
AllowCancel = true, AllowCancel = true,
}; };
try { TaskDialog.ShowDialog(page); } // Give the notice a momentary top-most, foreground owner so it opens FRONT and CENTRE —
// RemSound may have launched straight into the tray (auto-start / start-minimised), and a
// parent-less TaskDialog can otherwise open behind everything where a screen-reader user
// can't read it.
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
catch { /* a notice must never stop RemSound from starting */ } catch { /* a notice must never stop RemSound from starting */ }
} }
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; a robocopy update copies
/// the new tree but never removes the old root copies). Best-effort and idempotent — runs
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
/// touched, never anything else in the folder.</summary>
private static void CleanUpLegacyRootSounds()
{
try
{
var root = AppContext.BaseDirectory;
string[] cueBaseNames =
{
"connect", "disconnect", "record start", "record stop",
"save", "profile", "profile menu open", "update",
};
foreach (var baseName in cueBaseNames)
{
foreach (var fileName in new[] { baseName + ".wav", baseName + ".sfk", baseName + ".wav.sfk" })
{
try
{
var path = Path.Combine(root, fileName);
if (File.Exists(path)) File.Delete(path);
}
catch { /* a locked / unremovable file must never stop startup */ }
}
}
}
catch { /* never let cleanup disturb startup */ }
}
/// <summary>Consolidate the cue WAVs into the per-user sounds folder. The release ships the
/// default cues in <c>&lt;exe&gt;\sounds\</c>; this copies any cue MISSING from the per-user
/// <c>...\user settings and logs\sounds\</c> across (so a fresh install, or a release that adds a
/// new cue, gets seeded) WITHOUT overwriting one already there (so the user's own cue files
/// survive), then removes the shipped folder to keep the install root tidy. The app reads cues
/// only from the per-user folder, which the updater leaves untouched — so a user's custom cue
/// WAVs are no longer clobbered by an update. Best-effort + idempotent. 2026-06-10.</summary>
private static void ConsolidateSounds()
{
try
{
var userSounds = AppConfig.SoundsDirectory;
Directory.CreateDirectory(userSounds);
var shippedSounds = Path.Combine(AppContext.BaseDirectory, "sounds");
if (!Directory.Exists(shippedSounds)) return;
foreach (var src in Directory.GetFiles(shippedSounds))
{
try
{
var dest = Path.Combine(userSounds, Path.GetFileName(src));
if (!File.Exists(dest)) File.Copy(src, dest);
}
catch { /* one unreadable cue mustn't stop the rest */ }
}
try { Directory.Delete(shippedSounds, recursive: true); }
catch { /* leave it if locked — the app reads the per-user copy anyway */ }
}
catch { /* never let cue consolidation disturb startup */ }
}
} }
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. --> is what the About dialog and the updater both read. -->
<Version>3.4.0</Version> <Version>3.5.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+1 -1
View File
@@ -68,7 +68,7 @@ internal sealed class RemSoundLog : IDisposable
if (fileCreationFailed) return false; if (fileCreationFailed) return false;
try try
{ {
var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs"); var dir = RemSound.Core.AppConfig.LogsDirectory;
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log"; var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
Path = System.IO.Path.Combine(dir, name); Path = System.IO.Path.Combine(dir, name);
+12 -6
View File
@@ -317,6 +317,7 @@ internal sealed class RemSoundUpdater : IDisposable
echo. >> "%LOG%" echo. >> "%LOG%"
echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%" echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%"
echo %DATE% %TIME% install dir=[%~dp0] >> "%LOG%"
:wait_loop :wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
@@ -326,13 +327,18 @@ internal sealed class RemSoundUpdater : IDisposable
) )
echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%" echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%"
rem /XF + /XD keep the update from ever overwriting the USER's own state: their rem /XF + /XD keep the update from ever overwriting the USER's own state: everything under
rem machine-local config (remsound.config.json holds the profiles-folder choice and rem "user settings and logs" (global config, profiles, logs, sounds including any custom cue
rem startup settings) and their data folders (logs / profiles / recordings). An update rem WAVs the user dropped in) plus the legacy loose config. An update replaces APP files only.
rem replaces APP files only. build-release.ps1 already keeps those out of the release rem build-release.ps1 keeps those out of the release zip; this is the second line of defence so
rem zip; this is the second line of defence so a bad zip still can't clobber them. rem a bad zip still can't clobber them. The bare logs/profiles/recordings excludes stay for any
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%" rem older layout still mid-migration.
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update "user settings and logs" /LOG+:"%LOG%"
set "ROBO_EXIT=%ERRORLEVEL%" set "ROBO_EXIT=%ERRORLEVEL%"
rem Guard against an empty exit code (e.g. robocopy never ran / ERRORLEVEL was clobbered):
rem an empty %ROBO_EXIT% turns the GEQ test below into a parse error. Default it to a
rem clear non-zero so the failure path is taken cleanly and logged with a real number.
if not defined ROBO_EXIT set "ROBO_EXIT=99"
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%" echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
if %ROBO_EXIT% GEQ 8 ( if %ROBO_EXIT% GEQ 8 (
+5
View File
@@ -130,6 +130,11 @@ internal sealed class RouterPortMapper : IDisposable
RaiseChanged(); RaiseChanged();
try try
{ {
// Subscribe-once: Refresh() (fired on every sleep/wake) calls Start() again without a
// prior unsubscribe, and DeviceFound is a STATIC Mono.Nat event — a bare += would stack
// a new handler every resume (duplicate port-maps + this object kept alive forever).
// Remove first so there's only ever one subscription.
NatUtility.DeviceFound -= OnDeviceFound;
NatUtility.DeviceFound += OnDeviceFound; NatUtility.DeviceFound += OnDeviceFound;
NatUtility.StartDiscovery(); NatUtility.StartDiscovery();
log?.Invoke("UPnP discovery started"); log?.Invoke("UPnP discovery started");
+8 -9
View File
@@ -162,8 +162,10 @@ internal sealed class StartupBehaviourDialog : Form
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); } try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); }
}; };
var suppressStartWithUserHandler = false;
startWithUserBox.CheckedChanged += (_, _) => startWithUserBox.CheckedChanged += (_, _) =>
{ {
if (suppressStartWithUserHandler) return;
// Source of truth for the auto-start state is the registry — we don't keep a // Source of truth for the auto-start state is the registry — we don't keep a
// duplicate in AppConfig. So this just flips the registry entry directly. // duplicate in AppConfig. So this just flips the registry entry directly.
var ok = startWithUserBox.Checked var ok = startWithUserBox.Checked
@@ -176,21 +178,18 @@ internal sealed class StartupBehaviourDialog : Form
"Auto-start change failed", "Auto-start change failed",
MessageBoxButtons.OK, MessageBoxButtons.OK,
MessageBoxIcon.Warning); MessageBoxIcon.Warning);
// Re-read truth and reflect it without re-firing this handler. // Re-read truth and reflect it WITHOUT re-firing this handler. The suppress flag
// genuinely gates it; the old detach/re-attach targeted an empty handler that was
// never in the invocation list, so it did nothing and the corrective set re-fired.
var actual = StartupAutoStart.IsEnabled; var actual = StartupAutoStart.IsEnabled;
if (startWithUserBox.Checked != actual) if (startWithUserBox.Checked != actual)
{ {
// Temporarily detach the handler to avoid a recursive call. suppressStartWithUserHandler = true;
var savedChecked = actual; try { startWithUserBox.Checked = actual; }
startWithUserBox.CheckedChanged -= AutoStartReentryGuard; finally { suppressStartWithUserHandler = false; }
startWithUserBox.Checked = savedChecked;
startWithUserBox.CheckedChanged += AutoStartReentryGuard;
} }
} }
}; };
// Empty handler used as a target-for-removal in the re-entry-guard path above.
// Kept so the +=/-= pair is symmetrical even though it does nothing on its own.
void AutoStartReentryGuard(object? _, EventArgs __) { }
startWithProfileBox.CheckedChanged += (_, _) => startWithProfileBox.CheckedChanged += (_, _) =>
{ {
+103 -35
View File
@@ -206,57 +206,112 @@ public sealed class AppConfig
!string.IsNullOrWhiteSpace(driverName) !string.IsNullOrWhiteSpace(driverName)
&& driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase); && driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase);
/// <summary>The config folder next to the exe (<c>&lt;exe&gt;\config\</c>). Holds the global /// <summary>The single per-user folder next to the exe <c>&lt;exe&gt;\user settings and logs\</c> —
/// config file and the <c>profiles\</c> subfolder. 2026-06-07: everything non-recording config /// that holds EVERYTHING this machine's user owns: the global config file, the <c>profiles\</c>
/// moved in here from loose files beside the exe, so the install root stays tidy.</summary> /// subfolder, <c>logs\</c>, and <c>sounds\</c>. 2026-06-10: consolidated here from the loose files
public static string ConfigDirectory => Path.Combine(AppContext.BaseDirectory, "config"); /// / the earlier <c>config\</c> folder so the install root stays tidy and the auto-updater can
/// exclude one folder to leave ALL user state (including custom cue WAVs) untouched.</summary>
public const string UserDataFolderName = "user settings and logs";
public static string UserDataDirectory => Path.Combine(AppContext.BaseDirectory, UserDataFolderName);
private static string ConfigPath => Path.Combine(ConfigDirectory, "global config.json"); /// <summary>Where the per-machine log files are written.</summary>
public static string LogsDirectory => Path.Combine(UserDataDirectory, "logs");
/// <summary>Where the cue WAVs live (seeded from the shipped defaults; see Program.ConsolidateSounds).</summary>
public static string SoundsDirectory => Path.Combine(UserDataDirectory, "sounds");
/// <summary>The base profiles folder (ProfileStore appends the per-machine subfolder).</summary>
public static string ProfilesBaseDirectory => Path.Combine(UserDataDirectory, "profiles");
private static string ConfigPath => Path.Combine(UserDataDirectory, "global config.json");
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> relocated this launch. True only on the
/// one launch where an older layout was found and moved — the caller uses it to show a one-time
/// "everything moved" notice.</summary>
public readonly record struct LayoutMigrationResult(bool MovedAnything);
/// <summary> /// <summary>
/// One-time, idempotent relocation of the pre-2026-06-07 layout into <c>config\</c>: /// One-time, idempotent consolidation of EVERY older layout into
/// * <c>&lt;exe&gt;\remsound.config.json</c> → <c>&lt;exe&gt;\config\global config.json</c> /// <c>&lt;exe&gt;\user settings and logs\</c>. Handles all the field permutations, each move guarded
/// * <c>&lt;exe&gt;\profiles\</c> → <c>&lt;exe&gt;\config\profiles\</c> /// by "source exists AND destination doesn't" so it's safe to run every launch and never clobbers
/// Run once at startup BEFORE anything reads config or profiles. Each move only happens when /// already-migrated data:
/// the old item exists and the new one doesn't, so it's safe to call every launch and it /// * global config: <c>&lt;exe&gt;\remsound.config.json</c> (oldest) OR
/// upgrades anyone coming from an older build without losing a profile or a setting. A custom /// <c>&lt;exe&gt;\config\global config.json</c> (the 2026-06-07 interim layout)
/// <see cref="ProfilesDirectory"/> is untouched — it isn't in the default location. /// * profiles: <c>&lt;exe&gt;\config\profiles\</c> (interim) OR <c>&lt;exe&gt;\profiles\</c> (oldest)
/// * logs: <c>&lt;exe&gt;\logs\</c>
/// → all under <c>...\user settings and logs\</c>. (Sounds are consolidated separately by
/// Program.ConsolidateSounds — the shipped default cues need seeding, not a plain move.) Runs
/// BEFORE anything reads config/profiles/logs. A custom <see cref="ProfilesDirectory"/> is
/// untouched. Directory moves fall back to copy-then-delete across a volume boundary.
/// </summary> /// </summary>
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> actually relocated this launch.
/// <see cref="MovedAnything"/> is true only on the one launch where an upgrade's old files
/// were found and moved — the caller uses it to show a one-time "your settings moved" notice.</summary>
public readonly record struct LayoutMigrationResult(bool MovedGlobalConfig, bool MovedProfiles)
{
public bool MovedAnything => MovedGlobalConfig || MovedProfiles;
}
public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded() public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded()
{ {
var movedGlobal = false; var moved = false;
var movedProfiles = false;
try try
{ {
Directory.CreateDirectory(ConfigDirectory); Directory.CreateDirectory(UserDataDirectory);
var oldGlobal = Path.Combine(AppContext.BaseDirectory, "remsound.config.json"); var root = AppContext.BaseDirectory;
if (File.Exists(oldGlobal) && !File.Exists(ConfigPath)) var interimConfigDir = Path.Combine(root, "config");
// Global config — interim location wins over the oldest loose file.
if (!File.Exists(ConfigPath))
{ {
File.Move(oldGlobal, ConfigPath); var interimGlobal = Path.Combine(interimConfigDir, "global config.json");
movedGlobal = true; var oldestGlobal = Path.Combine(root, "remsound.config.json");
if (File.Exists(interimGlobal)) { File.Move(interimGlobal, ConfigPath); moved = true; }
else if (File.Exists(oldestGlobal)) { File.Move(oldestGlobal, ConfigPath); moved = true; }
} }
var oldProfiles = Path.Combine(AppContext.BaseDirectory, "profiles");
var newProfiles = Path.Combine(ConfigDirectory, "profiles"); // Profiles — interim location wins over the oldest.
if (Directory.Exists(oldProfiles) && !Directory.Exists(newProfiles)) if (!Directory.Exists(ProfilesBaseDirectory))
{ {
Directory.Move(oldProfiles, newProfiles); var interimProfiles = Path.Combine(interimConfigDir, "profiles");
movedProfiles = true; var oldestProfiles = Path.Combine(root, "profiles");
if (Directory.Exists(interimProfiles)) { MoveDirectoryResilient(interimProfiles, ProfilesBaseDirectory); moved = true; }
else if (Directory.Exists(oldestProfiles)) { MoveDirectoryResilient(oldestProfiles, ProfilesBaseDirectory); moved = true; }
} }
// Logs (only ever lived loose in the root).
var oldLogs = Path.Combine(root, "logs");
if (Directory.Exists(oldLogs) && !Directory.Exists(LogsDirectory)) { MoveDirectoryResilient(oldLogs, LogsDirectory); moved = true; }
// Remove the now-empty 2026-06-07 interim config\ folder.
try
{
if (Directory.Exists(interimConfigDir) && Directory.GetFileSystemEntries(interimConfigDir).Length == 0)
Directory.Delete(interimConfigDir);
}
catch { /* leave it if it isn't empty / can't be removed */ }
} }
catch catch
{ {
// Best-effort: a failed move (permissions, file in use) just means the app falls // Best-effort: a failed move (permissions, file in use) just means the app falls
// back to defaults / an empty profiles list rather than crashing on launch. // back to defaults / an empty profiles list rather than crashing on launch.
} }
return new LayoutMigrationResult(movedGlobal, movedProfiles); return new LayoutMigrationResult(moved);
}
/// <summary>Move a directory, falling back to recursive copy-then-delete when a plain
/// <see cref="Directory.Move"/> can't cross a volume boundary (e.g. the user-data folder is a
/// junction onto another drive). Copy uses overwrite:false so an already-present destination
/// file is never clobbered.</summary>
private static void MoveDirectoryResilient(string source, string dest)
{
try { Directory.Move(source, dest); }
catch (IOException)
{
CopyDirectoryRecursive(source, dest);
try { Directory.Delete(source, recursive: true); } catch { /* copy succeeded; leaving the source is harmless */ }
}
}
private static void CopyDirectoryRecursive(string source, string dest)
{
Directory.CreateDirectory(dest);
foreach (var file in Directory.GetFiles(source))
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: false);
foreach (var dir in Directory.GetDirectories(source))
CopyDirectoryRecursive(dir, Path.Combine(dest, Path.GetFileName(dir)));
} }
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing /// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
@@ -282,9 +337,22 @@ public sealed class AppConfig
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary> /// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
public void Save() public void Save()
{ {
Directory.CreateDirectory(ConfigDirectory); Directory.CreateDirectory(UserDataDirectory);
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json); // Atomic replace — write a temp then move it over, so a torn write (crash / power-loss /
// the updater force-closing us mid-save) can't truncate the file and silently revert config
// to defaults.
var tmp = ConfigPath + ".tmp";
try
{
File.WriteAllText(tmp, json);
File.Move(tmp, ConfigPath, overwrite: true);
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ }
throw;
}
} }
/// <summary>Convenience: build the appropriate <see cref="ProfileStore"/> for the /// <summary>Convenience: build the appropriate <see cref="ProfileStore"/> for the
+23 -4
View File
@@ -24,9 +24,9 @@ public sealed class ProfileStore
public ProfileStore() public ProfileStore()
{ {
var machineFolder = SanitiseFsName(Environment.MachineName); var machineFolder = SanitiseFsName(Environment.MachineName);
// 2026-06-07: profiles live under config\profiles\<machine>\ (was <exe>\profiles\<machine>\). // 2026-06-10: profiles live under "user settings and logs"\profiles\<machine>\.
// AppConfig.MigrateLegacyLayoutIfNeeded moves any pre-existing profiles here at startup. // AppConfig.MigrateLegacyLayoutIfNeeded moves any pre-existing profiles here at startup.
baseDir = Path.Combine(AppContext.BaseDirectory, "config", "profiles", machineFolder); baseDir = Path.Combine(AppConfig.ProfilesBaseDirectory, machineFolder);
try { Directory.CreateDirectory(baseDir); } try { Directory.CreateDirectory(baseDir); }
catch { /* permissions; List/Save will surface this when actually used */ } catch { /* permissions; List/Save will surface this when actually used */ }
} }
@@ -128,7 +128,26 @@ public sealed class ProfileStore
Directory.CreateDirectory(baseDir); Directory.CreateDirectory(baseDir);
var path = PathFor(profile.Title); var path = PathFor(profile.Title);
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, json); WriteFileAtomic(path, json);
}
/// <summary>Write text crash-safely: write a sibling temp file, then atomically move it over the
/// target. A crash, power-loss, or the updater force-closing mid-write then leaves either the old
/// file or the complete new one — never a truncated file that the catch-all loaders would
/// silently read as a blank profile.</summary>
private static void WriteFileAtomic(string path, string contents)
{
var tmp = path + ".tmp";
try
{
File.WriteAllText(tmp, contents);
File.Move(tmp, path, overwrite: true);
}
catch
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ }
throw;
}
} }
/// <summary>Deletes the profile by title. Returns true if a file was removed, /// <summary>Deletes the profile by title. Returns true if a file was removed,
@@ -171,7 +190,7 @@ public sealed class ProfileStore
if (profile is null) return false; if (profile is null) return false;
profile.Title = newTitle; profile.Title = newTitle;
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(newPath, json); WriteFileAtomic(newPath, json);
if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase))
{ {
File.Delete(oldPath); File.Delete(oldPath);
+82 -13
View File
@@ -154,7 +154,19 @@ internal sealed class MultiOutputPlayout : IRenderBackend
using var enumerator = new MMDeviceEnumerator(); using var enumerator = new MMDeviceEnumerator();
foreach (var id in deviceIds) foreach (var id in deviceIds)
{ {
if (outputs.ContainsKey(id)) continue; if (outputs.TryGetValue(id, out var existing))
{
if (!existing.Faulted) continue; // already live — leave it running, no audio break
// Device faulted mid-stream (unplugged) yet still in the desired set. Tear the dead
// output down so the open below re-creates it. SAFETY NET only: normally an unplug
// also clears the card's tick, so the remove loop above drops it and replug re-opens
// it fresh from the remembered selection (App side) — this branch just covers a fault
// where the same id is still desired (e.g. a transient WASAPI invalidation with no
// device-state change). If the device is still gone the open fails and it stays absent.
onDiagnostic?.Invoke($"output \"{existing.Name}\" faulted — re-opening");
outputs.Remove(id);
DisposeOutput(existing);
}
MMDevice? device = null; MMDevice? device = null;
WasapiOut? wasapi = null; WasapiOut? wasapi = null;
try try
@@ -176,12 +188,26 @@ internal sealed class MultiOutputPlayout : IRenderBackend
// Output device buffer. Request 5 ms; shared-mode WASAPI clamps it up to the // Output device buffer. Request 5 ms; shared-mode WASAPI clamps it up to the
// device's minimum period (~10 ms on tested hardware, 2026-06-08) — but ~10 ms // device's minimum period (~10 ms on tested hardware, 2026-06-08) — but ~10 ms
// is still ~5 ms tighter than the old 15 ms, a free latency win. The per-device // is still ~5 ms tighter than the old 15 ms, a free latency win. The per-device
// drift corrector keeps this buffer fed from its held ~12 ms cushion, so the // drift corrector keeps this buffer fed from its held card-sized cushion (≈12 ms
// smaller endpoint reserve doesn't risk underruns even on a flaky onboard device. // on a typical card), so the smaller endpoint reserve doesn't risk underruns.
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 5); wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 5);
var entry = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name };
// Notice the device dying mid-stream (USB card unplugged → WASAPI invalidates the
// endpoint and raises PlaybackStopped WITH an exception). Just flag it — never take
// the gate or dispose from here: this can fire on the device thread during our own
// Stop()/DisposeOutput, so doing real work here could deadlock. The next
// SetOutputDevices (the hot-plug notifier fires one on unplug AND replug) sees the
// flag and tears the dead entry down so the device can be re-opened. A clean stop
// (no exception — we asked for it) is ignored: the entry is being removed anyway.
wasapi.PlaybackStopped += (_, stopArgs) =>
{
if (stopArgs.Exception is not { } stopEx) return;
entry.Faulted = true;
onDiagnostic?.Invoke($"output \"{name}\" lost the device: {stopEx.GetType().Name}: {stopEx.Message}");
};
wasapi.Init(drift); wasapi.Init(drift);
wasapi.Play(); wasapi.Play();
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name }; outputs[id] = entry;
onDiagnostic?.Invoke($"output added: \"{name}\""); onDiagnostic?.Invoke($"output added: \"{name}\"");
} }
catch (Exception ex) catch (Exception ex)
@@ -277,6 +303,11 @@ internal sealed class MultiOutputPlayout : IRenderBackend
public required BufferedWaveProvider Buffer { get; init; } public required BufferedWaveProvider Buffer { get; init; }
public required DriftResamplingProvider Drift { get; init; } public required DriftResamplingProvider Drift { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
// Set true (off-thread, from WasapiOut.PlaybackStopped) when this device dies mid-stream —
// typically a USB card unplugged, which invalidates the WASAPI endpoint. SetOutputDevices
// reads it to know the entry is dead and must be torn down + re-opened rather than skipped.
// Volatile: written on the WASAPI thread, read under the gate without a shared write lock.
public volatile bool Faulted;
} }
/// <summary> /// <summary>
@@ -304,12 +335,23 @@ internal sealed class MultiOutputPlayout : IRenderBackend
private const double DriftRatioSmoothingNew = 0.30; private const double DriftRatioSmoothingNew = 0.30;
private const double DriftRatioMin = 0.95; private const double DriftRatioMin = 0.95;
private const double DriftRatioMax = 1.05; private const double DriftRatioMax = 1.05;
// Feedback: steer the buffer toward a known, low cushion. Pure rate-matching holds the // Feedback: steer the buffer toward a cushion. Pure rate-matching holds the buffer wherever
// buffer wherever the start-up transient left it (~50 ms and climbing in the field) — // the start-up transient left it (~50 ms and climbing in the field) — SessionPlayout gets
// SessionPlayout gets away without this because it ARMS at target and has a click-trim // away without this because it ARMS at target and has a click-trim net; the device buffer
// net; the device buffer has neither, so it needs an explicit depth term. The correction // has neither, so it needs an explicit depth term. The correction is tiny (≤0.3 % rate,
// is tiny (≤0.3 % rate, spread over seconds): a sub-audible pitch nudge, never a click. // spread over seconds): a sub-audible pitch nudge, never a click.
private const int TargetDepthMs = 12; // operating depth we hold the buffer at //
// The cushion is sized to the CARD, not hardcoded. A device can't hold a buffer below one
// of its own WASAPI pulls — a 10 ms-period card sawtooths by 10 ms, an 18 ms Realtek by 18 —
// so the target is the measured pull size × a small margin: a 10 ms card lands at 12 ms (its
// long-proven value), an 18 ms card at ~22. Crucially this READS the card and HOLDS: it is
// NOT a load-reactive loop, so it never climbs on a CPU/network spike and drops when calm.
// The pull is the WINDOW AVERAGE (one coalesced double-pull can't move it), and a hysteresis
// band means only a genuine change in the card's pull size ever shifts the target.
private const double TargetGulpMultiple = 1.2; // cushion ≈ this × the card's pull size
private const int MinTargetDepthMs = 8; // floor for a tiny-pull device
private const int MaxTargetDepthMs = 50; // cap so a pathological pull can't run away
private const int TargetHysteresisMs = 2; // only move the target on a real ≥2 ms shift
private const double DepthCorrectionSec = 15.0; // correct a depth error over ~this long private const double DepthCorrectionSec = 15.0; // correct a depth error over ~this long
private const double MaxDepthBias = 0.003; // cap the depth nudge at 0.3 % rate private const double MaxDepthBias = 0.003; // cap the depth nudge at 0.3 % rate
@@ -332,6 +374,13 @@ internal sealed class MultiOutputPlayout : IRenderBackend
private double smoothedRatio = 1.0; private double smoothedRatio = 1.0;
private bool tracking; private bool tracking;
private bool firstWindowDone; private bool firstWindowDone;
// Adaptive-but-stable target. pullSumBytes/pullCount accumulate the card's pull sizes over a
// window; targetMs is set from their average (× the margin) and then HELD — the hysteresis
// band keeps it from flitting. Defaults to 12 ms until the first window measures the card.
private long pullSumBytes;
private long pullCount;
private int targetMs = 12;
private double lastGulpMs;
// Scratch — grown lazily, persists across calls so the hot path doesn't allocate. // Scratch — grown lazily, persists across calls so the hot path doesn't allocate.
private byte[] inputBytes = new byte[16384]; private byte[] inputBytes = new byte[16384];
@@ -367,6 +416,11 @@ internal sealed class MultiOutputPlayout : IRenderBackend
var outFrames = count / MixBytesPerFrame; var outFrames = count / MixBytesPerFrame;
if (outFrames <= 0) return 0; if (outFrames <= 0) return 0;
// Sample the card's pull size for the adaptive target (see UpdateRatioIfDue). Averaged,
// so an occasional coalesced double-pull can't distort it.
pullSumBytes += count;
pullCount++;
UpdateRatioIfDue(); UpdateRatioIfDue();
var inputFramesNeeded = resampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff); var inputFramesNeeded = resampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
@@ -435,6 +489,21 @@ internal sealed class MultiOutputPlayout : IRenderBackend
windowStartFed = fedNow; windowStartFed = fedNow;
windowStartDrained = deviceDrainedBytes; windowStartDrained = deviceDrainedBytes;
// Size the target to THIS card from its average pull, then hold it. Done on every path
// (including the discarded warm-up window) so the next window's average starts clean.
var avgPullBytes = pullCount > 0 ? pullSumBytes / pullCount : 0;
pullSumBytes = 0;
pullCount = 0;
if (avgPullBytes > 0)
{
lastGulpMs = avgPullBytes / (double)MixBytesPerFrame * 1000.0 / MixSampleRate;
var candidateMs = (int)Math.Round(
Math.Clamp(lastGulpMs * TargetGulpMultiple, MinTargetDepthMs, MaxTargetDepthMs));
// Hysteresis: only move on a genuine ≥2 ms change in the card's pull, so tiny
// averaging wobble never nudges the latency — it settles once and stays put.
if (Math.Abs(candidateMs - targetMs) >= TargetHysteresisMs) targetMs = candidateMs;
}
// Discard the FIRST completed window. WASAPI primes its endpoint buffer at start-up, // Discard the FIRST completed window. WASAPI primes its endpoint buffer at start-up,
// which inflates the device-drain count for that window and reads as a large bogus // which inflates the device-drain count for that window and reads as a large bogus
// ppm (1199 ppm observed) that shoves the buffer off target. Start measuring from // ppm (1199 ppm observed) that shoves the buffer off target. Start measuring from
@@ -456,12 +525,12 @@ internal sealed class MultiOutputPlayout : IRenderBackend
} }
if (!tracking) return; // nothing valid measured yet — don't touch the rate. if (!tracking) return; // nothing valid measured yet — don't touch the rate.
// Feedback: nudge the buffer toward TargetDepthMs. depthError > 0 = too deep → bias // Feedback: nudge the buffer toward the adaptive target (targetMs). depthError > 0 = too deep → bias
// the rate UP so the resampler pulls more per output and drains the buffer faster; // the rate UP so the resampler pulls more per output and drains the buffer faster;
// < 0 = too shallow → bias down. Clamped + spread over DepthCorrectionSec so it's a // < 0 = too shallow → bias down. Clamped + spread over DepthCorrectionSec so it's a
// gentle, inaudible pitch trim, not a per-sample discontinuity. // gentle, inaudible pitch trim, not a per-sample discontinuity.
var depthFrames = buffer.BufferedBytes / MixBytesPerFrame; var depthFrames = buffer.BufferedBytes / MixBytesPerFrame;
var targetFrames = TargetDepthMs * MixSampleRate / 1000; var targetFrames = targetMs * MixSampleRate / 1000;
var depthError = depthFrames - targetFrames; var depthError = depthFrames - targetFrames;
var depthCorrection = Math.Clamp( var depthCorrection = Math.Clamp(
depthError / (DepthCorrectionSec * MixSampleRate), depthError / (DepthCorrectionSec * MixSampleRate),
@@ -475,7 +544,7 @@ internal sealed class MultiOutputPlayout : IRenderBackend
var corrPpm = depthCorrection * 1_000_000.0; var corrPpm = depthCorrection * 1_000_000.0;
onDiagnostic?.Invoke( onDiagnostic?.Invoke(
$"\"{name}\": clock={smoothedRatio:F6} ({clockPpm:+0;-0}ppm) depthMs={depthMs} " + $"\"{name}\": clock={smoothedRatio:F6} ({clockPpm:+0;-0}ppm) depthMs={depthMs} " +
$"target={TargetDepthMs} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}"); $"target={targetMs} gulpMs={lastGulpMs:F0} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}");
} }
} }
} }
-28
View File
@@ -330,32 +330,4 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
// copy + mix loop, or is it encode + sendto". // copy + mix loop, or is it encode + sendto".
if (diag) Interlocked.Add(ref cumulativeCaptureTicks, Stopwatch.GetTimestamp() - workStart); if (diag) Interlocked.Add(ref cumulativeCaptureTicks, Stopwatch.GetTimestamp() - workStart);
} }
/// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
/// can't find any. Exposed for the App's driver picker UI.</summary>
public static IReadOnlyList<string> EnumerateDriverNames()
{
try { return AsioOut.GetDriverNames().ToList(); }
catch { return []; }
}
/// <summary>
/// Briefly opens the named ASIO driver to query its channel counts, then disposes. Single
/// driver instance held for ~50 ms while the COM object reads its channel info — does not
/// claim the device for streaming. Returns (in,out) = (-1,-1) on any failure (driver not
/// installed, busy with another app, etc.). Used by the App to populate channel-pair lists
/// in ASIO mode without holding the driver open between user actions.
/// </summary>
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
{
try
{
using var asio = new AsioOut(driverName);
return (asio.DriverInputChannelCount, asio.DriverOutputChannelCount);
}
catch
{
return (-1, -1);
}
}
} }
-10
View File
@@ -91,16 +91,6 @@ public static class AsioDeviceProbe
return new AsioDriverProbeResult(-1, -1, [], []); return new AsioDriverProbeResult(-1, -1, [], []);
} }
} }
/// <summary>
/// Backwards-compatibility shim around <see cref="ProbeDriverInfo"/> for callers that only
/// need channel counts.
/// </summary>
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
{
var info = ProbeDriverInfo(driverName);
return (info.InputChannelCount, info.OutputChannelCount);
}
} }
public sealed record AsioDriverProbeResult( public sealed record AsioDriverProbeResult(
@@ -58,6 +58,7 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
private readonly object gate = new(); private readonly object gate = new();
private WasapiCapture? capture; private WasapiCapture? capture;
private MMDevice? captureDevice; // the device backing capture/keepAlive; WE own it and must dispose it (NAudio's WasapiCapture never does)
private SilentRenderKeepAlive? keepAlive; private SilentRenderKeepAlive? keepAlive;
private CaptureSourceSpec? activeSpec; private CaptureSourceSpec? activeSpec;
private string? captureFormatDescription; private string? captureFormatDescription;
@@ -140,6 +141,7 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
{ {
using var enumerator = new MMDeviceEnumerator(); using var enumerator = new MMDeviceEnumerator();
var device = enumerator.GetDevice(spec.DeviceId); var device = enumerator.GetDevice(spec.DeviceId);
captureDevice = device; // hold it for disposal in StopInternal — see field comment
capture = spec.Kind == CaptureKind.Loopback capture = spec.Kind == CaptureKind.Loopback
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs) ? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
@@ -241,6 +243,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
try { keepAlive.Dispose(); } catch { /* ignore */ } try { keepAlive.Dispose(); } catch { /* ignore */ }
keepAlive = null; keepAlive = null;
} }
// Dispose the device AFTER capture + keepAlive (both hold its COM state). NAudio's
// WasapiCapture keeps no reference to the MMDevice and never disposes it, so without this the
// device's COM/handle state leaks on every start/stop/switch — the WASAPI handle-leak fingerprint.
if (captureDevice is not null)
{
try { captureDevice.Dispose(); } catch { /* ignore */ }
captureDevice = null;
}
resampler = null; resampler = null;
activeSpec = null; activeSpec = null;
} }