v4.0: full audio-cue system, cause-aware auto-tune, four-tab Preferences, install-side default sounds

Audio cues
- Cues for send/receive on-off, minimise/restore, checkbox tick/untick, and tab switch
- Soft keyboard clicks while typing, with a distinct passkey sound on password fields
- Per-cue "Choose sound" variant picker; "(none)" silences a cue; front-most missing-sound warning
- Send/receive cues take priority over the generic checkbox sound; programmatic ticks stay silent

Preferences
- Redesigned into four tabs (General, Audio cues, Startup behaviour, Update settings)
- Startup behaviour moved in from the Options menu
- NVDA now announces the dialog on open (focus a real named control, not the quiet tab control)

Auto-tune
- Cause-aware: tells device render-callback stalls (more buffer can't fix) apart from genuine
  network/buffer starvation, so it no longer pins latency high on chunky onboard cards
- Lowering the target eases the buffer down (glide) instead of trimming it, so no clicks while tuning

Sounds layout
- Shipped defaults moved out of the per-user folder into an install-side "default sounds" folder,
  so updates can refresh them; user customs are Browse-picked file paths and are left untouched
- Startup migration removes both legacy sound folders; verified from oldest (v1.0-v3.3) and v3.4 layouts

Quiet automated launches
- New --silent launch flag mutes all cue sounds and suppresses the startup dialogs (migration notice,
  update check, Realtek/mic/missing-sound warnings) so test launches never disturb the user
- run-tests / build-release / SelfTest repointed to the new "default sounds" layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-13 11:57:19 +01:00
co-authored by Claude Opus 4.8
parent 0b7ad49021
commit a408d2b56e
61 changed files with 548 additions and 139 deletions
+10 -7
View File
@@ -114,12 +114,14 @@ Action| How
### Where your files are stored
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:
Everything RemSound keeps for you on this computer — your settings, your profiles, and your logs — lives together in one folder inside RemSound called **user settings and logs**. Each profile is one small file, stored at:
<RemSound folder>\user settings and logs\profiles\<your computer name>\<profile name>
(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.
(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.) RemSound updates never touch that folder — so anything of your own in there stays safe when you update.
The built-in cue sounds are kept separately, in a **default sounds** folder alongside the program. Those are part of RemSound itself, so an update can refresh them — if a future version ships an improved default sound, you'll get it. Your own choices are never affected: a sound you pick for a cue with the **Browse** button is remembered as a link to your own file (wherever you keep it), and that's left exactly as you set it.
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.
@@ -866,7 +868,7 @@ Toggle| What it does
## 18. Audio cue sounds
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Sixteen kinds of event have a cue:
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Seventeen kinds of event have a cue:
Cue| Plays when
---|---
@@ -884,6 +886,7 @@ Cue| Plays when
**Minimise (hide) sound**| RemSound's window minimises to the notification area (hides).
**Restore (show) sound**| RemSound's window is brought back from the notification area (shows).
**Checkbox ticked / unticked sound**| You tick or untick _any_ checkbox anywhere in RemSound — a click for ticked, a different one for unticked. This gives instant feedback on which way a box just went, which is especially handy in the busy inputs and outputs lists. There's a separate sound for ticking and for unticking.
**Switch tabs sound**| You move between tabs anywhere in RemSound — the row of tabs in the main window, or the tabs in a dialog like Preferences. It plays each time you land on a different tab (with Ctrl+Tab, or the arrow keys when the row of tab names has focus).
All of these cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)
@@ -933,13 +936,13 @@ Custom sound choices are **saved with the active profile** , the same way the ti
### 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 RemSound's own `sounds` folder — inside **user settings and logs** — and RemSound treats 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 `default sounds` folder — alongside the program — and RemSound treats that as “use default” and clears the override automatically.)
### Where the cue sounds live
RemSound keeps the cue WAV files in a `sounds` folder inside **user settings and logs** — the same folder your settings and profiles live in. Each cue ships with a small set of numbered sound files there. They're named after the cue with a number on the end — for example `connect 1.wav` and `connect 2.wav` for the connect cue, `record start 1.wav` and `record start 2.wav` for the recording-start cue, and so on. The **Choose default sound** list described above simply picks between the numbered files a cue has. If you add more numbered files of your own following the same pattern, they show up in the list automatically — there's no fixed limit.
RemSound keeps the built-in cue WAV files in a `default sounds` folder alongside the program. Each cue ships with a small set of numbered sound files there. They're named after the cue with a number on the end — for example `connect 1.wav` and `connect 2.wav` for the connect cue, `record start 1.wav` and `record start 2.wav` for the recording-start cue, and so on. The **Choose sound** list described above simply picks between the numbered files a cue has.
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.
Because these are RemSound's own built-in sounds, an update can refresh them — if a future version ships an improved default, you'll get it. To use a sound of your _own_ for a cue, don't drop a file into `default sounds` (an update would overwrite it); instead use the **Browse** button, which links the cue to your file wherever you keep it. That link is remembered and never touched by an update, so your chosen sound always stays put.
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.
@@ -1035,7 +1038,7 @@ Format| What you get| When to pick it
### 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 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.
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 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 built-in defaults are the `record start` and `record stop` sounds in the **default sounds** folder alongside the program.
### Where recordings go
+23 -25
View File
@@ -1,51 +1,49 @@
# RemSound v3.9
# RemSound v4.0
A reliability release built from real reports: a long-standing cause of one-way silence is fixed, long sessions no longer drift slower, RemSound stops "sending" when nobody is connected, there's a start-up sound, and you can now drive RemSound from the command line.
A big step up for audible feedback and for getting the latency right by itself. RemSound now has a sound for nearly everything you do, can click as you type, has a clearer four-tab Preferences window, and tunes latency more intelligently and more quietly.
## One-way silence is fixed
## A sound for nearly everything
If you sent plain (non-ASIO) audio to someone who had an **ASIO device selected** at their end, your sound — your microphone included — could arrive at their machine and then never be played. They heard nothing, even though everything looked connected and their logs showed audio coming in.
On top of the connect, disconnect and recording cues, RemSound can now play a short sound when you:
This was a routing fault inside the receiver: a plain stream arriving at a receiver running in two-lane (ASIO) mode was being decoded into a buffer that nothing ever played out, so it silently piled up and was dropped. A plain stream now always reaches the speakers, whatever mix of ASIO and standard audio the two of you happen to be using. This is the fix behind "my mic works for me but they can't hear it."
* turn **sending** or **receiving** on or off (a separate sound for each),
* **minimise** to or **return** from the notification area,
* **tick or untick** any box, and
* **switch between tabs**.
## Long sessions stay as tight as they started
Every one is optional. Under **Options → Preferences → Audio cues** you can silence any cue, pick which built-in sound it uses, or **Browse** for your own WAV file. (The receive-audio on/off sounds were a request on the issue tracker — they're here now.)
On the standard (non-ASIO) path the incoming audio buffer used to creep deeper after a network hiccup and never settle back, so a connection that started tight could feel laggy hours later. It now eases itself back to your chosen latency, gently and silently, so a long session stays as tight as it began.
## Hear yourself type
## RemSound no longer "sends into the void"
RemSound can **click softly as you type** into any box, so you get an audible sense of your keystrokes — with a distinct sound in **password fields** so you always know which kind of box you're in. One tick under Audio cues turns it on or off.
If you left a profile open with nobody connected, the status line could still report that it was sending data — and a large running total — which was confusing and looked wrong. RemSound now only sends audio once a peer is genuinely reachable. With no one connected it sits quietly, and the status line reflects that. (Reported on the issue tracker.)
## Preferences, reorganised
## A start-up sound
The Preferences window is now four clear tabs — **General**, **Audio cues**, **Startup behaviour** and **Update settings** — so everything is easier to find. The startup options (start with Windows, start minimised, start with a chosen profile) have moved here from the Options menu.
RemSound now plays a short sound as it starts, so you know it's running even when it opens straight to the notification area. You can turn it off, or choose your own sound, under **Options → Preferences**.
## Smarter, quieter latency tuning
## Run RemSound from the command line
The automatic latency tuning used to treat every tiny audio glitch as "the buffer is too small" and keep adding delay — even when the real cause was the receiving computer's own sound card stumbling, which more buffer can't fix. It now tells the two apart, so it stops piling on delay it can't help. And when it does lower the latency, it eases the buffer down smoothly instead of trimming it, so you no longer hear little clicks while it tunes.
As well as its normal window, RemSound now takes **command-line options** — handy for quick checks, for getting a support report to send, and for starting RemSound a particular way from a shortcut or a script:
## Default sounds that updates can refresh
* `--devices` lists every microphone, output and ASIO driver, with formats and ids.
* `--selftest` runs the whole audio path on the machine on its own and reports PASS or FAIL.
* `--diagnostics` writes one report file (version, system, settings, profiles, devices, mic-privacy check, recent log) to send for help.
* `--profile`, `--connect` and `--minimized` start RemSound straight into a profile, connected to an address, or down in the tray.
Run `RemSound.exe --help` for the full list, or see the new **Command-line options** section in the manual (press F1).
The built-in default sounds now travel with the program itself, so an update can refresh them — if a better default sound ships in a future update, you'll actually get it. Your own chosen sounds are kept exactly as you set them.
## Compatibility
**v3.9 talks to v3.3 through v3.8 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
**v4.0 talks to v3.3 through v3.9 with no trouble** — the over-the-network format is unchanged, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
## Install
1. Download `RemSound-v3.9.zip` from this release.
1. Download `RemSound-v4.0.zip` from this release.
2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs, recordings, or sounds.
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 settings, profiles, logs or recordings.
4. Run `RemSound.exe`.
## Upgrading
**From v3.6, v3.7 or v3.8:** Help → Check for updates installs v3.9 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
**From v3.6 or newer:** Help → Check for updates installs v4.0 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was. The first launch tidies your settings into their current home if they aren't there already; nothing is lost.
**From v1.9v3.5:** Check for updates works, but it uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above.
**From v1.9v3.5:** Check for updates works, but uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above.
**v1.8 and earlier:** the auto-updater in those versions can't install updates at all — install by hand using the steps above.
**v1.8 and earlier:** the auto-updater in those versions can't install updates — install by hand using the steps above.
+3 -3
View File
@@ -37,10 +37,10 @@ $staging = Join-Path ([System.IO.Path]::GetTempPath()) ("remsound-release-" + [g
# 0a. SoundForge drops a .sfk peak file next to every .wav it opens. They're byproducts that must
# never ship (the build only bundles *.wav, so they wouldn't anyway) - clear them from the
# source sounds\ folder so they don't accumulate and clutter the working tree.
$sfk = @(Get-ChildItem -LiteralPath (Join-Path $repo 'sounds') -Filter '*.sfk' -ErrorAction SilentlyContinue)
# source 'default sounds\' folder so they don't accumulate and clutter the working tree.
$sfk = @(Get-ChildItem -LiteralPath (Join-Path $repo 'default sounds') -Filter '*.sfk' -ErrorAction SilentlyContinue)
if ($sfk.Count -gt 0) {
Write-Host "Removing $($sfk.Count) SoundForge .sfk byproduct(s) from sounds\..." -ForegroundColor Cyan
Write-Host "Removing $($sfk.Count) SoundForge .sfk byproduct(s) from 'default sounds\'..." -ForegroundColor Cyan
$sfk | Remove-Item -Force
}
Binary file not shown.
Binary file not shown.
+10 -7
View File
@@ -140,11 +140,13 @@ ul, ol { padding-left: 1.4em; }
<h3>Where your files are stored</h3>
<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>
<p>Everything RemSound keeps for you on this computer &mdash; your settings, your profiles, and your logs &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;\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 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>(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.) RemSound updates never touch that folder &mdash; so anything of your own in there stays safe when you update.</p>
<p>The built-in cue sounds are kept separately, in a <strong>default sounds</strong> folder alongside the program. Those are part of RemSound itself, so an update can refresh them &mdash; if a future version ships an improved default sound, you'll get it. Your own choices are never affected: a sound you pick for a cue with the <strong>Browse</strong> button is remembered as a link to your own file (wherever you keep it), and that's left exactly as you set it.</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>
@@ -919,7 +921,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<h2 id="audio-cues">18. Audio cue sounds</h2>
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Sixteen kinds of event have a cue:</p>
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Seventeen kinds of event have a cue:</p>
<table>
<tr><th>Cue</th><th>Plays when</th></tr>
@@ -937,6 +939,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<tr><td><strong>Minimise (hide) sound</strong></td><td>RemSound's window minimises to the notification area (hides).</td></tr>
<tr><td><strong>Restore (show) sound</strong></td><td>RemSound's window is brought back from the notification area (shows).</td></tr>
<tr><td><strong>Checkbox ticked / unticked sound</strong></td><td>You tick or untick <em>any</em> checkbox anywhere in RemSound &mdash; a click for ticked, a different one for unticked. This gives instant feedback on which way a box just went, which is especially handy in the busy inputs and outputs lists. There's a separate sound for ticking and for unticking.</td></tr>
<tr><td><strong>Switch tabs sound</strong></td><td>You move between tabs anywhere in RemSound &mdash; the row of tabs in the main window, or the tabs in a dialog like Preferences. It plays each time you land on a different tab (with Ctrl+Tab, or the arrow keys when the row of tab names has focus).</td></tr>
</table>
<p>All of these cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)</p>
@@ -987,13 +990,13 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<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 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>
<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>default sounds</code> folder &mdash; alongside the program &mdash; and RemSound treats that as &ldquo;use default&rdquo; and clears the override automatically.)</p>
<h3>Where the cue sounds live</h3>
<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. Each cue ships with a small set of numbered sound files there. They're named after the cue with a number on the end &mdash; for example <code>connect 1.wav</code> and <code>connect 2.wav</code> for the connect cue, <code>record start 1.wav</code> and <code>record start 2.wav</code> for the recording-start cue, and so on. The <strong>Choose default sound</strong> list described above simply picks between the numbered files a cue has. If you add more numbered files of your own following the same pattern, they show up in the list automatically &mdash; there's no fixed limit.</p>
<p>RemSound keeps the built-in cue WAV files in a <code>default sounds</code> folder alongside the program. Each cue ships with a small set of numbered sound files there. They're named after the cue with a number on the end &mdash; for example <code>connect 1.wav</code> and <code>connect 2.wav</code> for the connect cue, <code>record start 1.wav</code> and <code>record start 2.wav</code> for the recording-start cue, and so on. The <strong>Choose sound</strong> list described above simply picks between the numbered files a cue has.</p>
<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>Because these are RemSound's own built-in sounds, an update can refresh them &mdash; if a future version ships an improved default, you'll get it. To use a sound of your <em>own</em> for a cue, don't drop a file into <code>default sounds</code> (an update would overwrite it); instead use the <strong>Browse</strong> button, which links the cue to your file wherever you keep it. That link is remembered and never touched by an update, so your chosen sound always stays put.</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>
@@ -1093,7 +1096,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<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 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>
<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 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 built-in defaults are the <code>record start</code> and <code>record stop</code> sounds in the <strong>default sounds</strong> folder alongside the program.</p>
<h3>Where recordings go</h3>
+18 -10
View File
@@ -41,20 +41,23 @@ if (-not (Test-Path -LiteralPath $exe)) {
exit 1
}
# ---- 2. PACKAGE CONTENTS (must run BEFORE any CLI call: every RemSound launch consolidates the
# bundled sounds\ into 'user settings and logs\sounds\', emptying sounds\) ----
# ---- 2. PACKAGE CONTENTS ----
# The shipped DEFAULT cues live install-side in 'default sounds\' next to the exe (the app reads
# them from there and updates always overwrite them; they're no longer copied into the per-user
# folder). So unlike before, a CLI launch does NOT empty this folder - the checks here are robust
# whatever order they run in.
Write-Host "`nPackage contents:" -ForegroundColor Cyan
$soundsPath = Join-Path $publishDir 'sounds'
$soundsPath = Join-Path $publishDir 'default sounds'
$wavCount = @(Get-ChildItem -LiteralPath $soundsPath -Filter *.wav -ErrorAction SilentlyContinue).Count
if ($wavCount -ge 3) { Pass "cue sounds bundled ($wavCount .wav)" } else { Fail "cue sounds missing (found $wavCount) - this is the bug that shipped v3.9 with no sounds" }
# Cues ship as numbered variants ("connect 1.wav", ...); each required cue needs at least one.
foreach ($base in @('connect', 'disconnect', 'start up', 'send on', 'send off', 'recieve on', 'recieve off', 'minimise', 'maximise', 'check', 'uncheck')) {
foreach ($base in @('connect', 'disconnect', 'start up', 'send on', 'send off', 'recieve on', 'recieve off', 'minimise', 'maximise', 'check', 'uncheck', 'tab switch')) {
$variants = @(Get-ChildItem -LiteralPath $soundsPath -Filter "$base*.wav" -ErrorAction SilentlyContinue)
if ($variants.Count -gt 0) { Pass "'$base' cue has $($variants.Count) sound variant(s)" } else { Fail "no sound variant for the '$base' cue" }
}
# Keyboard-click + password sounds.
foreach ($extra in @('key 1.wav', 'passkey.wav')) {
if (Test-Path -LiteralPath (Join-Path $soundsPath $extra)) { Pass "'$extra' present" } else { Fail "'$extra' missing from the published sounds\ folder" }
if (Test-Path -LiteralPath (Join-Path $soundsPath $extra)) { Pass "'$extra' present" } else { Fail "'$extra' missing from the published 'default sounds' folder" }
}
if (Test-Path -LiteralPath (Join-Path $publishDir 'readme.html')) { Pass "readme.html (F1 manual) bundled" } else { Fail "readme.html missing" }
if (Test-Path -LiteralPath (Join-Path $publishDir 'runtimes\win-x64\native\opus.dll')) { Pass "native opus.dll bundled" } else { Fail "native opus.dll missing (runtimes\win-x64\native\)" }
@@ -139,15 +142,20 @@ else {
$testCfg = Join-Path ([System.IO.Path]::GetTempPath()) ("rs-cfg-" + [guid]::NewGuid().ToString('N'))
$proc = $null
try {
$proc = Start-Process -FilePath $exe -ArgumentList @('--config-dir', $testCfg, '--connect', '127.0.0.1', '--minimized') -PassThru
# --silent so this throwaway launch makes no cue sounds (startup / connect) and pops no
# dialogs at whoever's at the screen while the gate runs. The shipped default sounds live
# install-side ('default sounds\' next to the exe) so this cold-start finds them with no
# consolidation step and no missing-sound warning - nothing to restore here any more.
$proc = Start-Process -FilePath $exe -ArgumentList @('--config-dir', $testCfg, '--connect', '127.0.0.1', '--minimized', '--silent') -PassThru
Start-Sleep -Seconds 6
if (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue) { Pass "GUI cold-started and stayed up (minimized to tray)" }
else { Fail "GUI exited or crashed during cold start" }
# Startup consolidates the cue sounds into UserDataDirectory; with the override that's $testCfg,
# so a populated $testCfg proves the process honoured --config-dir and left the real settings alone.
if (Test-Path (Join-Path $testCfg 'sounds')) { Pass "ran against the isolated --config-dir folder (real settings untouched)" }
else { Fail "--config-dir folder was not populated - config isolation may not be working" }
# The process creates its user-data folder at the --config-dir override (MigrateLegacyLayout),
# so the override folder existing afterwards proves it honoured --config-dir and left the real
# settings alone. (Sounds are NOT here - they're install-side now.)
if (Test-Path -LiteralPath $testCfg) { Pass "ran against the isolated --config-dir folder (real settings untouched)" }
else { Fail "--config-dir folder was not created - config isolation may not be working" }
Invoke-RsCli @('--close') | Out-Null
Start-Sleep -Seconds 2
+37
View File
@@ -20,6 +20,43 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v4.0
RemSound now has a sound for nearly everything you do.
On top of the connect, disconnect and recording cues, it
can play a short sound when you turn sending or receiving
on or off, when it minimises to or returns from the tray,
when you tick or untick any box, and when you move between
tabs. Every one is optional: under Options Preferences
Audio cues you can silence any of them, pick which built-in
sound it uses, or choose your own WAV file.
It can also click softly as you type into any box, so you
hear your keystrokes, with a distinct sound in password
fields so you always know which kind of box you're in. That
too is a single tick you can turn off.
The Preferences window is now organised into four clear tabs
General, Audio cues, Startup behaviour and Update settings
so everything is easier to find. The startup options
(start with Windows, start minimised, start with a chosen
profile) have moved here from the Options menu.
The automatic latency tuning is cleverer. It used to treat
every tiny audio glitch as "the buffer is too small" and
keep adding delay even when the real cause was the
receiving computer's own sound card stumbling, which more
buffer can't fix. It now tells the two apart, so it stops
piling on delay it can't help. And when it does lower the
latency, it eases the buffer down smoothly instead of
trimming it, so you no longer hear little clicks while it
tunes.
Finally, the built-in default sounds now travel with the
program itself, so an update can refresh them if a better
default sound ships in a future update, you'll actually get
it. Your own chosen sounds are kept exactly as you set them.
RemSound v3.9
Listening for a long time no longer slowly builds up
+8
View File
@@ -17,6 +17,13 @@ internal static class CheckSoundService
private static CuePlayer? checkSound;
private static CuePlayer? uncheckSound;
/// <summary>When true, <see cref="Play"/> is a no-op. MainForm sets this around bulk programmatic
/// control updates (profile load, "uncheck all", device-list refresh). The per-call Focused gate
/// already silences MOST programmatic ticks, but it leaks when the box we tick in code happens to
/// be the focused control on launch — which is exactly what made loading a profile blast a
/// checkbox click. This flag closes that gap: only genuine user toggles ever click.</summary>
public static bool Suppressed { get; set; }
/// <summary>(Re)load the tick/untick sounds from the current cue configuration. Call at startup
/// and whenever cue settings change.</summary>
public static void Reload()
@@ -28,6 +35,7 @@ internal static class CheckSoundService
public static void Play(bool isChecked)
{
if (Suppressed) return;
var cfg = AppConfig.Load();
if (isChecked) { if (cfg.EnableCheckboxOnCue) checkSound?.Play(); }
else { if (cfg.EnableCheckboxOffCue) uncheckSound?.Play(); }
+2
View File
@@ -172,6 +172,8 @@ internal static class CommandLine
Console.WriteLine(" logs and sounds, instead of the usual location. Lets a test");
Console.WriteLine(" exercise RemSound without touching your real settings.");
Console.WriteLine(" Works with any command (e.g. --selftest --config-dir ...).");
Console.WriteLine(" --silent Play no cue sounds and show no missing-sound pop-ups for");
Console.WriteLine(" this run - for automated / unattended launches.");
Console.WriteLine();
Console.WriteLine("Examples:");
Console.WriteLine(" RemSound.exe --devices");
+10
View File
@@ -20,12 +20,22 @@ namespace RemSound.App;
/// </summary>
internal sealed class CuePlayer : IDisposable
{
/// <summary>The "this is a silent / automated launch" flag, set once at startup by the
/// <c>--silent</c> flag. Primarily an app-wide mute for every cue sound (startup, connect,
/// checkbox, tab-switch, send/receive, previews — everything that plays through a CuePlayer).
/// It ALSO doubles as the signal to suppress the unattended startup pop-ups that would otherwise
/// ding at and bother whoever's at the screen during a throwaway test launch — the missing-sound
/// warning and the Realtek-ASIO-detected warning both check it. So a <c>--silent</c> launch is
/// completely quiet: no cue audio, no warning dings, no dialogs. Off in normal use.</summary>
public static bool GloballyMuted { get; set; }
private readonly string filePath;
public CuePlayer(string filePath) => this.filePath = filePath;
public void Play()
{
if (GloballyMuted) return;
var path = filePath;
Task.Run(() =>
{
+11
View File
@@ -74,6 +74,17 @@ internal sealed class QuietTabControl : TabControl
protected override AccessibleObject CreateAccessibilityInstance()
=> new QuietAcc(this);
protected override void OnSelectedIndexChanged(EventArgs e)
{
base.OnSelectedIndexChanged(e);
// Audible tab-switch cue, app-wide (every QuietTabControl gets it for free). Gated on
// ContainsFocus so a genuine user switch - arrow keys with the strip focused, or Ctrl+Tab
// with focus on a control inside the active page - clicks, while the programmatic
// SelectedIndex set done while a window is being built or its last tab restored (nothing
// here focused yet) stays silent.
if (ContainsFocus) TabSwitchSoundService.Play();
}
private sealed class QuietAcc : ControlAccessibleObject
{
public QuietAcc(Control owner) : base(owner) { }
+78 -21
View File
@@ -271,6 +271,15 @@ public sealed class MainForm : Form
private bool suppressConnectedCheck;
private bool suppressDiscoveredCheck;
private bool suppressRememberedCheck;
/// <summary>True while ANY checkable list is being (un)checked programmatically — a device-list
/// refresh (<see cref="suppressDeviceCheckChange"/>) or a rebuild of one of the three peer lists
/// (each fires ItemCheck for every pre-checked row it adds). The tick/untick CUE must stay silent
/// for all of these, not just device-list changes: on startup the connected-peers list is focused
/// (see the Shown handler's FocusListControl call), so a saved-peer reconnect rebuilding that list
/// would otherwise click a checkbox sound at launch. Only a genuine user toggle — no flag set —
/// should click.</summary>
private bool SuppressingCheckSounds =>
suppressDeviceCheckChange || suppressConnectedCheck || suppressDiscoveredCheck || suppressRememberedCheck;
private string lastConnectedListSignature = string.Empty;
private string lastDiscoveredListSignature = string.Empty;
private string lastRememberedListSignature = string.Empty;
@@ -460,6 +469,7 @@ public sealed class MainForm : Form
// are gone too.
private long prevDiagConceal;
private long prevDiagShortRead;
private long prevDiagDeviceGulp;
private long prevDiagTrimFires;
// Wire-level packet-sequence tracking deltas. Detects packet reordering, loss, or
// duplication on the UDP path between sender and receiver. On a healthy LAN all three
@@ -1282,7 +1292,9 @@ public sealed class MainForm : Form
// launch RemSound, find an update, and stay running for less than the timer
// interval would otherwise miss the release entirely. Default on. The
// background-poll path handles both silent install and the user-prompt flow.
if (startupCfg.CheckForUpdatesOnStartup)
// Skipped on a --silent (automated/throwaway) launch: a test instance must never pop an
// "update available" prompt or, worse, silently download/install + restart mid-test.
if (startupCfg.CheckForUpdatesOnStartup && !CuePlayer.GloballyMuted)
{
// Defer a few seconds so the network stack, audio engine, and any device
// hot-swap has settled before we touch GitHub. The visible cue (silent-
@@ -4013,7 +4025,8 @@ public sealed class MainForm : Form
// Log the detector's verdict every startup so a silent-mic session is no longer ambiguous —
// we can see whether RemSound thought Windows was blocking the mic, not just whether it warned.
logFile.Event($"mic-privacy: windows-blocks-desktop-mic={blocked} wasapiMicTicked={anyWasapiMicChecked}");
if (blocked && anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
// A --silent (automated/throwaway) launch logs the verdict but never pops the warning dialog.
if (blocked && anyWasapiMicChecked && !CuePlayer.GloballyMuted) WarnMicrophoneBlockedByWindowsPrivacy();
}
/// <summary>
@@ -4024,6 +4037,10 @@ public sealed class MainForm : Form
/// </summary>
private void MaybeWarnAboutRealtekAsio()
{
// A --silent (automated/throwaway) launch must not pop this warning - its TaskDialog plays
// the Windows warning ding even when nobody can see the dialog (it's on a minimized test
// instance that's then auto-closed). The decision belongs to a real user at a real launch.
if (CuePlayer.GloballyMuted) return;
if (realtekAsioDriverNames.Count == 0) return;
var cfg = AppConfig.Load();
var changed = false;
@@ -5383,6 +5400,11 @@ public sealed class MainForm : Form
var shortReadNow = receiver.ShortReadFires;
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
// Device-gulp short-reads this second — the inaudible, on-target partial reads the
// cause-aware auto-tune ignores. High devGulpΔ alongside a near-zero concealΔ is the
// onboard-Realtek chunky-render-callback fingerprint we built the split to catch.
var deviceGulpNow = receiver.DeviceGulpUnderruns;
var deviceGulpDeltaDiag = deviceGulpNow - prevDiagDeviceGulp; prevDiagDeviceGulp = deviceGulpNow;
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
// Live state — current LP-filtered drift error. Negative = buffer running below
// target on average; positive = above.
@@ -5510,7 +5532,7 @@ public sealed class MainForm : Form
$"privMB={selfMeter.PrivateBytesMb:0.0} gcHeapMB={selfMeter.GcHeapMb:0.0} gcFragMB={selfMeter.GcFragmentedMb:0.0} gcCommitMB={selfMeter.GcCommittedMb:0.0} handles={selfMeter.HandleCount} threads={selfMeter.ThreadCount} " +
$"captureMs={captureMs:0.0} sendMs={sendMs:0.0} recvMs={recvMs:0.0} renderMs={renderMs:0.0} " +
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} devGulpΔ={deviceGulpDeltaDiag} " +
$"filtErr={filteredErrorFrames:0.0}f " +
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
@@ -5736,6 +5758,11 @@ public sealed class MainForm : Form
if (pendingProfile is null) return;
var p = pendingProfile;
applyingProfile = true;
// Silence the generic checkbox tick/untick for the whole bulk apply. The per-box Focused
// gate isn't enough on launch: the box we tick in code is often the focused control, so it
// would click. This + the applyingProfile guard on the send/receive cue keep a profile load
// down to just the startup and (real) connect cues.
CheckSoundService.Suppressed = true;
try
{
// Volume first — affects what's audible during the rest of this method.
@@ -5778,6 +5805,7 @@ public sealed class MainForm : Form
// the original profile forever.
pendingProfile = null;
applyingProfile = false;
CheckSoundService.Suppressed = false;
}
}
@@ -6182,10 +6210,10 @@ public sealed class MainForm : Form
HandleCapabilityChange();
MarkProfileDirty();
// Audible feedback for the toggle, whether the user clicked the checkbox or pressed the
// mute hotkey (the hotkey flips .Checked, which routes through here too). Suppressed during
// profile load by the suppressStreamingPasswordGate guard above, so loading a profile that
// has send/receive on doesn't blast the cues.
PlayStreamToggleCue(box);
// mute hotkey (the hotkey flips .Checked, which routes through here too). Skipped while a
// profile is being applied programmatically — loading a profile that has send/receive on
// shouldn't blast the cues; only a genuine user toggle should.
if (!applyingProfile) PlayStreamToggleCue(box);
}
/// <summary>Play the send/receive turned-on / turned-off cue for a streaming checkbox toggle.
@@ -6519,6 +6547,9 @@ public sealed class MainForm : Form
// Played on every checkbox tick/untick across the whole app (CheckSoundService).
public const string CheckboxOn = "checkbox-on";
public const string CheckboxOff = "checkbox-off";
// Played whenever the user switches tabs anywhere in the app (TabSwitchSoundService,
// fired from QuietTabControl). The shipped WAVs are "tab switch 1.wav" etc.
public const string TabSwitch = "tab-switch";
}
/// <summary>Load one cue sound. Resolution order:
@@ -6596,8 +6627,10 @@ public sealed class MainForm : Form
TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound);
TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound);
TryLoadCueSound(CueId.Show, "maximise.wav", out showSound);
// The app-wide checkbox tick/untick sounds live in their own service; keep them in step.
// The app-wide checkbox tick/untick and tab-switch sounds live in their own services; keep
// them in step.
CheckSoundService.Reload();
TabSwitchSoundService.Reload();
// After a reload (e.g. the user changed a cue in Preferences), warn about any cue that's
// switched on but whose sound file is missing. Skipped during construction (no window yet);
// OnShown does the first-launch pass.
@@ -6653,6 +6686,8 @@ public sealed class MainForm : Form
{
if (!reportedMissingCues.Add(name)) return;
logFile.Event($"cue sound '{name}': enabled but file missing — cue turned off, informing the user");
// A --silent (automated) launch turns the cue off quietly and never pops a dialog at the user.
if (CuePlayer.GloballyMuted) return;
BeginInvoke(() =>
{
try { RestoreFromTray(); } catch { /* surfacing is best-effort */ }
@@ -7131,13 +7166,13 @@ public sealed class MainForm : Form
if (continuousTuneEnabled && receiver.HasSessionsForRoute(RenderRoute.WasapiLane))
{
TickRoute(RenderRoute.WasapiLane, maxLatencyBox, "WASAPI",
ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking,
ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking,
lastUserSliderMoveUtc, intervalSec, frameMs.Value);
}
if (settings.LoadContinuousAutoTuneAsioEnabled() && receiver.HasSessionsForRoute(RenderRoute.AsioLane))
{
TickRoute(RenderRoute.AsioLane, maxLatencyAsioBox, "ASIO",
ref lastObservedUnderrunCountAsio, ref suppressUserAsioSliderMoveTracking,
ref lastObservedUnderrunCountAsio, ref lastObservedDeviceGulpCountAsio, ref suppressUserAsioSliderMoveTracking,
lastUserAsioSliderMoveUtc, intervalSec, frameMs.Value);
}
}
@@ -7146,7 +7181,7 @@ public sealed class MainForm : Form
if (continuousTuneEnabled)
{
TickRoute(RenderRoute.Mixed, maxLatencyBox, "",
ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking,
ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking,
lastUserSliderMoveUtc, intervalSec, frameMs.Value);
}
}
@@ -7159,6 +7194,12 @@ public sealed class MainForm : Form
// a heap-allocated state object on the hot path.
private long lastObservedUnderrunCountAsio;
private bool suppressUserAsioSliderMoveTracking;
// Per-route "device-gulp underruns at last tick" — the inaudible, more-buffer-won't-fix
// partial short-reads the cause-aware skip gate deliberately ignores. Tracked only so the
// auto-tune log can show how many were ignored; shared between Mixed and the WASAPI lane the
// same way lastObservedUnderrunCount is.
private long lastObservedDeviceGulpCount;
private long lastObservedDeviceGulpCountAsio;
/// <summary>
/// Per-route auto-tune tick body. Same algorithm as the pre-2026-05-11 single-route
@@ -7175,6 +7216,7 @@ public sealed class MainForm : Form
NumericUpDown slider,
string routeLabel,
ref long lastObservedUnderruns,
ref long lastObservedDeviceGulps,
ref bool suppressFlag,
DateTime lastUserMoveUtc,
int intervalSec,
@@ -7195,20 +7237,31 @@ public sealed class MainForm : Form
// Defer to user's manual change — wait at least one tick interval before overriding.
if (DateTime.UtcNow - lastUserMoveUtc < TimeSpan.FromSeconds(intervalSec)) return;
// Per-route underrun delta. The receiver tracks underruns per session, so summing
// only over sessions tagged with this route gives a route-local distress signal.
var currentUnderruns = route == RenderRoute.Mixed ? receiver.Underruns : receiver.UnderrunsFor(route);
// Per-route underrun delta — but the CAUSE-AWARE kind (2026-06-13). We gate on
// tune-blocking underruns only: the full-empty / producer-starved short-reads that
// genuinely mean "the buffer is too thin". A steady trickle of inaudible device-gulp
// partials — a chunky onboard-Realtek render callback asking for an oversized block on an
// otherwise on-target ring — is deliberately NOT counted here, so it can no longer pin the
// target high forever by making every tick skip. The recommendation below still folds in
// the render-callback gap, so even when we're free to lower we can never lower below what
// the device structurally needs; it just settles to that floor instead of overshooting up.
var currentUnderruns = route == RenderRoute.Mixed ? receiver.TuneBlockingUnderruns : receiver.TuneBlockingUnderrunsFor(route);
var underrunDelta = currentUnderruns - lastObservedUnderruns;
lastObservedUnderruns = currentUnderruns;
// Device-gulp delta is tracked for the diagnostic trail only — it never gates.
var currentDeviceGulps = route == RenderRoute.Mixed ? receiver.DeviceGulpUnderruns : receiver.DeviceGulpUnderrunsFor(route);
var deviceGulpDelta = currentDeviceGulps - lastObservedDeviceGulps;
lastObservedDeviceGulps = currentDeviceGulps;
if (underrunDelta > 0)
{
// Route label slots into the message body when present, omitted entirely in classic
// modes so the legacy "continuous auto-tune: skipping (N new underruns...)" wording
// is preserved bit-for-bit. The trailing-space + colon ordering is what gave the
// pre-fix line its weird "continuous auto-tune : skipping" formatting when the
// label was empty.
// label was empty. devGulp shows how many inaudible device-gulp partials were ignored
// this tick — a high devGulp with a small underrunDelta is the Realtek fingerprint.
var prefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick)");
logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick, devGulp={deviceGulpDelta} ignored)");
return;
}
@@ -7271,7 +7324,7 @@ public sealed class MainForm : Form
suppressFlag = false;
}
var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms devGulp={deviceGulpDelta}");
}
// UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button.
@@ -7281,12 +7334,16 @@ public sealed class MainForm : Form
private void WireCheckedListAccessibility(CheckedListBox list, Label statusLabel, string itemKind)
{
// Tick/untick sound for the inputs/outputs lists. Gated on the list being focused so a real
// user click/spacebar clicks, but the bulk programmatic (un)checking done on profile load or
// by "uncheck all" (focus is on the button, not the list) stays silent.
// Tick/untick sound for the inputs/outputs AND peer lists. Gated on the list being focused so
// a real user click/spacebar clicks, but EVERY programmatic (un)check stays silent — via the
// list-focus gate, CheckSoundService.Suppressed during profile apply, and SuppressingCheckSounds
// which covers both the device-list mutations and the three peer-list rebuilds (each rebuild
// fires ItemCheck for its pre-checked rows). Without the peer-list half, a saved-peer reconnect
// rebuilding the focused connected-peers list at startup would click a checkbox sound. Only a
// genuine user toggle (no suppression flag set) should click.
list.ItemCheck += (_, e) =>
{
if (list.Focused) CheckSoundService.Play(e.NewValue == CheckState.Checked);
if (list.Focused && !SuppressingCheckSounds) CheckSoundService.Play(e.NewValue == CheckState.Checked);
};
list.SelectedIndexChanged += (_, _) =>
{
+61 -2
View File
@@ -190,6 +190,11 @@ internal sealed class PreferencesDialog : Form
c => c.EnableCheckboxOnCue, (c, v) => c.EnableCheckboxOnCue = v),
MachineRow("Checkbox unticked sound", MainForm.CueId.CheckboxOff, "uncheck.wav",
c => c.EnableCheckboxOffCue, (c, v) => c.EnableCheckboxOffCue = v),
// Played whenever the user switches tabs anywhere in the app (TabSwitchSoundService).
// Display name is Ed's "switch tabs"; the shipped files are "tab switch 1.wav" etc, so
// the base filename here is "tab switch.wav" for variant discovery to match.
MachineRow("Switch tabs sound", MainForm.CueId.TabSwitch, "tab switch.wav",
c => c.EnableTabSwitchCue, (c, v) => c.EnableTabSwitchCue = v),
];
}
@@ -330,6 +335,10 @@ internal sealed class PreferencesDialog : Form
DialogResult = DialogResult.OK,
};
// The four-tab strip. Held as a field (not a constructor local) so OnShown can land focus
// on it when the dialog opens — see the OnShown override for why that's needed for NVDA.
private readonly QuietTabControl tabs = new() { Dock = DockStyle.Fill, TabIndex = 0, TabStop = true };
/// <summary>True if the user toggled Mute cues or Accept remote during this dialog
/// session. The owner uses this to know whether to MarkProfileDirty after the dialog
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
@@ -355,6 +364,10 @@ internal sealed class PreferencesDialog : Form
cueRows = BuildCueRows(settings);
Text = "Preferences";
// Explicitly a dialog so the spoken title is clean and screen readers treat it as a dialog
// (ShowDialog already exposes UIA IsDialog on .NET 7+; this is harmless reinforcement).
AccessibleRole = AccessibleRole.Dialog;
AccessibleName = "Preferences";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
@@ -638,8 +651,8 @@ internal sealed class PreferencesDialog : Form
startupListPanel.Controls.Add(startupProfileList);
// Four tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
// strip switch tabs; the active page's controls are the next tab stops.
var tabs = new QuietTabControl { Dock = DockStyle.Fill, TabIndex = 0, TabStop = true };
// strip switch tabs; the active page's controls are the next tab stops. The control itself
// is a field (declared above) so OnShown can focus it when the dialog opens.
tabs.TabPages.Add(MakeTab("General",
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, loggingBox, writeLogsNowButton));
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
@@ -695,6 +708,52 @@ internal sealed class PreferencesDialog : Form
};
}
/// <summary>When the dialog opens, land focus on the first real, NAMED leaf control inside the
/// active tab page so NVDA announces the dialog and that control. Never the tab strip: the tab
/// control is a <see cref="QuietTabControl"/> whose own accessible object is deliberately
/// role-less and nameless (so NVDA reads the tab item, not a redundant "tab control"), and
/// focusing THAT on open gave NVDA nothing to announce — which is exactly why Preferences opened
/// silent until you moved. Focusing a named leaf (the first General-tab control) gives NVDA
/// something to speak, and because ShowDialog exposes the form as a dialog (UIA IsDialog, .NET 7+)
/// it then reads the whole dialog.
///
/// Three load-bearing details, confirmed against the dotnet/winforms + NVDA issue trackers and
/// matching the pattern Andre's Sensor Readout uses (it focuses a real list/textbox in Shown):
/// * Deferred via BeginInvoke so it runs after the dialog's accessibility tree is live.
/// * ActiveControl=null FIRST, so leaf.Focus() is a genuine focus CHANGE and actually raises the
/// focus event (without the transition WinForms can treat focus as unchanged and stay silent).
/// * NotifyFocus re-fires the MSAA focus event as belt-and-braces.
/// Ctrl+Tab still switches tabs from inside the page.</summary>
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
BeginInvoke(new Action(() =>
{
if (IsDisposed) return;
if (tabs.TabCount > 0) tabs.SelectedIndex = 0;
var leaf = FirstTabStopLeaf(tabs.SelectedTab) ?? (Control)tabs;
ActiveControl = null;
leaf.Focus();
if (leaf.IsHandleCreated) WinEventNotifier.NotifyFocus(leaf);
}));
}
/// <summary>The first visible, enabled, tab-stop control inside <paramref name="container"/>,
/// searched depth-first in child order (which matches the order controls were added to each tab).
/// Returns a real leaf the dialog can focus on open so NVDA has a named control to announce —
/// never a layout panel or the role-less tab strip.</summary>
private static Control? FirstTabStopLeaf(Control? container)
{
if (container is null) return null;
foreach (Control c in container.Controls)
{
if (c is { CanSelect: true, TabStop: true, Visible: true, Enabled: true })
return c;
if (FirstTabStopLeaf(c) is { } nested) return nested;
}
return null;
}
/// <summary>Wire up the Startup behaviour tab (moved here from StartupBehaviourDialog): load the
/// current state, populate the profile list, and persist each change immediately to AppConfig /
/// the Windows auto-start registry entry, exactly as the old dialog did.</summary>
+43 -31
View File
@@ -35,6 +35,16 @@ internal static class Program
AppConfig.SetUserDataDirectoryOverride(configDir);
}
// --silent: make this launch play no cue sounds at all (startup, connect, checkbox,
// tab-switch, ...) and skip the front-most "missing sound file" warning. The automated test
// harness passes it so its throwaway launches stay completely quiet instead of chiming a
// startup cue (or popping a dialog) onto whoever happens to be at the screen. Set up front,
// before consolidation or the startup cue can fire.
if (Array.Exists(args, a => string.Equals(a, "--silent", StringComparison.OrdinalIgnoreCase)))
{
CuePlayer.GloballyMuted = true;
}
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
@@ -48,8 +58,11 @@ internal static class Program
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
// Move the cue sounds into that folder too — seeded from the shipped defaults (see method).
ConsolidateSounds();
// Cue sounds no longer live in a sounds\ folder at all (the shipped defaults are install-side
// in AppConfig.SoundsDirectory = "default sounds\" now, so an update always refreshes them).
// Delete BOTH defunct old sounds folders an upgrader might still have, whatever version they
// came from. Best-effort + idempotent.
RemoveLegacySoundFolders();
// 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
@@ -137,14 +150,18 @@ internal static class Program
KeyClickService.Initialize(AppConfig.Load().EnableKeyboardClicks);
Application.ApplicationExit += (_, _) => KeyClickService.Shutdown();
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService). Loaded here; reloaded
// by MainForm.ReloadAllCueSounds whenever cue settings change in Preferences.
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService) and the tab-switch
// cue (TabSwitchSoundService). Loaded here; reloaded by MainForm.ReloadAllCueSounds whenever
// cue settings change in Preferences.
CheckSoundService.Reload();
TabSwitchSoundService.Reload();
// One-time "your settings moved" notice — only the launch that actually relocated files
// shows it (idempotent migration ⇒ MovedAnything is false on every later launch). Shown
// here, after the guard and before the profile picker, so the user reads it once up front.
if (layoutMigration.MovedAnything)
// Skipped on a --silent (automated/throwaway) launch: its TaskDialog dings at and pops over
// whoever's at the screen during a test, and a throwaway instance needn't announce a move.
if (layoutMigration.MovedAnything && !CuePlayer.GloballyMuted)
{
ShowLayoutMigrationNotice();
}
@@ -405,39 +422,34 @@ internal static class Program
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()
/// <summary>Delete the two defunct old cue-sounds folders an upgrader might still have on disk,
/// whichever version they came from. Sounds now live install-side in
/// <see cref="AppConfig.SoundsDirectory"/> (<c>&lt;exe&gt;\default sounds\</c>), which updates
/// always refresh; both old locations are dead and only cause confusion / stale reads if left:
/// * <c>&lt;exe&gt;\sounds\</c> — the install-side folder cue WAVs lived in from ~v3.1 to v3.4.
/// A user jumping STRAIGHT from that era to this version never ran the v3.5 consolidation that
/// used to move-and-delete it, so it can still be sitting there.
/// * <c>...\user settings and logs\sounds\</c> — the per-user folder cues lived in from v3.5 to
/// v3.9.1, with a never-overwrite seed that meant a changed default could never reach an
/// existing user (the whole reason for the 2026-06-13 move).
/// Best-effort + idempotent — a no-op once they're gone. The user's REAL custom sounds were never
/// in either folder (they're explicit Browse-picked file paths elsewhere), so nothing is lost.</summary>
private static void RemoveLegacySoundFolders()
{
try
foreach (var legacy in new[]
{
Path.Combine(AppContext.BaseDirectory, "sounds"), // ~v3.1v3.4 install-side
AppConfig.LegacyUserSoundsDirectory, // v3.5v3.9.1 per-user
})
{
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 */ }
try { if (Directory.Exists(legacy)) Directory.Delete(legacy, recursive: true); }
catch { /* leave it if locked / unreadable — it's just unused clutter now */ }
}
catch { /* never let cue consolidation disturb startup */ }
}
/// <summary>Play the startup cue once if the machine-wide setting is on. Resolves the WAV the
/// same way the in-app cues do — a user-set custom path (machine-wide, in <see cref="AppConfig"/>)
/// if it exists on disk, otherwise the bundled <c>sounds\start up.wav</c>. Read straight from
/// if it exists on disk, otherwise the bundled <c>default sounds\start up.wav</c>. Read straight from
/// AppConfig because no profile (and therefore no settings store) is loaded yet at this point
/// in startup. Best-effort: a cue must never stop RemSound from starting.</summary>
private static void PlayStartupCueIfEnabled()
+21 -20
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>3.9</Version>
<Version>4.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -57,17 +57,18 @@
</ItemGroup>
<ItemGroup>
<!-- All cue sounds live in a sounds\ subfolder under the build/publish output (moved out of the
install-root flat layout 2026-05-28). Every WAV in the source sounds\ folder ships, via a
wildcard, so the numbered cue variants ("connect 1.wav", "connect 2.wav", ...), the
keyboard-click sounds ("key 1.wav".."key 4.wav") and the password "passkey.wav" are all
carried without per-file edits, and adding more sounds later needs no csproj change.
Filenames containing a space are preserved verbatim (via %(Filename)%(Extension)) so the
load-by-filename path in CueSounds / KeyClickService finds them exactly as written. The
EnsureCueSoundsPublished target below is the belt-and-braces that guarantees these reach a
PUBLISH output even when MSBuild's incremental Content-copy marker would skip them. -->
<Content Include="..\..\sounds\*.wav">
<Link>sounds\%(Filename)%(Extension)</Link>
<!-- Shipped DEFAULT cue sounds live in a "default sounds\" subfolder under the build/publish
output (2026-06-13: moved out of the per-user folder, which never overwrote them, so a
changed default sound could never reach an existing user). They are part of the install:
the auto-updater (and a dev republish) always overwrites this folder, so a tweaked default
always lands. The user's OWN custom sounds are NOT here - they're explicit file paths set
via the Preferences Browse picker, which the updater never touches. Every WAV in the source
"default sounds\" folder ships via a wildcard (numbered variants, key clicks, passkey), so
adding sounds later needs no csproj change. Spaces in filenames are preserved verbatim. The
EnsureCueSoundsPublished target below guarantees they reach a PUBLISH output even when
MSBuild's incremental Content-copy marker would skip them. -->
<Content Include="..\..\default sounds\*.wav">
<Link>default sounds\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
@@ -80,20 +81,20 @@
</Content>
</ItemGroup>
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The sounds\ Content items above use
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The Content items above use
CopyToOutputDirectory=PreserveNewest, but MSBuild's incremental "copy already done" marker
(obj\...\.csproj.CopyComplete) can skip that copy when publishing into a fresh output folder
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all
(startup sound + connect/disconnect/record/etc.). This explicit post-publish copy is marker-
independent: it always copies every WAV from the source sounds\ folder into the published
sounds\ folder. Build-release.ps1 zips the publish output, so this is what makes the release
reliably contain the sounds. (The self-test "Bundled resources present" step verifies it.) -->
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all.
This explicit post-publish copy is marker-independent: it always copies every WAV from the
source "default sounds\" folder into the published "default sounds\" folder. Build-release.ps1
zips the publish output, so this is what makes the release reliably contain the sounds. (The
self-test "Bundled resources present" step verifies it.) -->
<Target Name="EnsureCueSoundsPublished" AfterTargets="Publish">
<ItemGroup>
<_CueWavs Include="..\..\sounds\*.wav" />
<_CueWavs Include="..\..\default sounds\*.wav" />
</ItemGroup>
<Copy SourceFiles="@(_CueWavs)"
DestinationFolder="$(PublishDir)sounds"
DestinationFolder="$(PublishDir)default sounds"
SkipUnchangedFiles="false" />
</Target>
</Project>
+5 -6
View File
@@ -295,12 +295,11 @@ internal static class SelfTest
var root = AppContext.BaseDirectory;
Check(File.Exists(Path.Combine(root, "readme.html")), "readme.html (the F1 manual) must ship next to the exe");
// Cues are consolidated from the shipped sounds\ folder into the runtime sounds folder at
// startup (Program.ConsolidateSounds), so by the time the self-test runs they live here.
// An empty runtime folder means the shipped build had no sounds to seed from - exactly the
// bug that shipped the v3.9 zip with no cue sounds.
// The shipped DEFAULT cues live install-side in "default sounds\" next to the exe
// (AppConfig.SoundsDirectory). An empty/absent folder means the shipped build had no sounds -
// exactly the bug that shipped the v3.9 zip with no cue sounds.
var soundsDir = AppConfig.SoundsDirectory;
Check(Directory.Exists(soundsDir), "the runtime sounds folder must exist (cues are consolidated at startup)");
Check(Directory.Exists(soundsDir), "the shipped 'default sounds' folder must exist next to the exe");
// Cues ship as numbered variants ("connect 1.wav", ...); each required cue must have at
// least one variant present.
foreach (var cue in new[]
@@ -311,7 +310,7 @@ internal static class SelfTest
})
{
Check(CueSounds.Variants(cue).Count > 0,
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped sounds\\ folder empty?)");
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped 'default sounds' folder empty?)");
}
// Keyboard-click typing sounds + the password passkey sound.
Check(File.Exists(Path.Combine(soundsDir, "key 1.wav")), "keyboard-click sound 'key 1.wav' must be present");
+47
View File
@@ -0,0 +1,47 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Plays a short cue whenever the user switches between tabs anywhere in RemSound - the main
/// window's tab strip and every tabbed dialog (Preferences, Recording settings, ...). Fired from
/// <see cref="QuietTabControl"/> on a selection change, gated on the tab control actually containing
/// focus, so a genuine user switch (arrow keys / Ctrl+Tab) clicks but the programmatic selection
/// done while building or restoring a window stays silent.
///
/// Machine-wide cue (<see cref="AppConfig.EnableTabSwitchCue"/>), shipped as numbered variants
/// ("tab switch 1.wav", ...) and configured in Preferences exactly like the other cues. Default on.
/// </summary>
internal static class TabSwitchSoundService
{
private static CuePlayer? switchSound;
/// <summary>When true, <see cref="Play"/> is a no-op. Reserved for bulk programmatic tab changes
/// the focus gate doesn't already cover; mirrors <see cref="CheckSoundService.Suppressed"/>.</summary>
public static bool Suppressed { get; set; }
/// <summary>(Re)load the cue from the current cue configuration. Call at startup and whenever cue
/// settings change, alongside <see cref="CheckSoundService.Reload"/>.</summary>
public static void Reload()
{
switchSound = LoadCue(MainForm.CueId.TabSwitch, "tab switch.wav", AppConfig.Load());
}
public static void Play()
{
if (Suppressed) return;
if (AppConfig.Load().EnableTabSwitchCue) switchSound?.Play();
}
private static CuePlayer? LoadCue(string cueId, string defaultFile, AppConfig cfg)
{
try
{
string? path = cfg.MachineCueCustomPaths.TryGetValue(cueId, out var custom) && File.Exists(custom)
? custom
: CueSounds.ResolveDefaultPath(cueId, defaultFile, cfg);
return path is not null && File.Exists(path) ? new CuePlayer(path) : null;
}
catch { return null; }
}
}
+23 -6
View File
@@ -109,6 +109,9 @@ public sealed class AppConfig
/// <summary>Tick / untick sounds played on every checkbox toggle anywhere in the app.</summary>
public bool EnableCheckboxOnCue { get; set; } = true;
public bool EnableCheckboxOffCue { get; set; } = true;
/// <summary>Sound played whenever the user switches between tabs anywhere in the app (the main
/// window's tab strip and every tabbed dialog). Machine-wide, default on.</summary>
public bool EnableTabSwitchCue { get; set; } = true;
/// <summary>Custom WAV overrides for the machine-wide cues above, keyed by cue id. The
/// equivalent of <see cref="Profile.CustomCuePaths"/> but machine-wide, since these cues don't
@@ -281,8 +284,21 @@ public sealed class AppConfig
/// <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>Where the shipped DEFAULT cue WAVs live: a <c>default sounds\</c> folder next to the
/// exe. This is part of the INSTALL, not user state — the auto-updater (and a dev republish)
/// always overwrites it, so a changed default sound reaches every user, including existing ones.
/// Deliberately NOT under <see cref="UserDataDirectory"/> and NOT redirected by <c>--config-dir</c>:
/// these are shipped defaults, not per-user data. The user's OWN custom sounds are never stored
/// here — they're explicit file paths (the Preferences "Browse" picker) that live in the user's
/// own location, which the updater never touches. 2026-06-13: moved here out of the per-user
/// <c>sounds\</c> folder, whose never-overwrite seeding meant a tweaked default could never land
/// for anyone who already had the old one.</summary>
public static string SoundsDirectory => Path.Combine(AppContext.BaseDirectory, "default sounds");
/// <summary>The OLD per-user sounds folder (<c>...\user settings and logs\sounds\</c>), now
/// defunct after sounds moved to the install-side <see cref="SoundsDirectory"/>. Kept only so the
/// startup migration can delete the orphan. Do NOT read cues from here.</summary>
public static string LegacyUserSoundsDirectory => Path.Combine(UserDataDirectory, "sounds");
/// <summary>The base profiles folder (ProfileStore appends the per-machine subfolder).</summary>
public static string ProfilesBaseDirectory => Path.Combine(UserDataDirectory, "profiles");
@@ -303,10 +319,11 @@ public sealed class AppConfig
/// <c>&lt;exe&gt;\config\global config.json</c> (the 2026-06-07 interim layout)
/// * 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.
/// → all under <c>...\user settings and logs\</c>. (Sounds are NOT part of this folder any more —
/// the shipped defaults live install-side in <see cref="SoundsDirectory"/>; Program deletes the
/// two orphaned old sounds folders on startup.) 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>
public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded()
{
+13
View File
@@ -491,6 +491,19 @@ public sealed class AudioReceiver : IDisposable
public long Underruns => playoutEngine.AggregateUnderruns;
public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped);
/// <summary>Cause-split of <see cref="Underruns"/> for the continuous auto-tune (2026-06-13).
/// <c>TuneBlockingUnderruns</c> are the short-reads that mean "the buffer is genuinely too thin"
/// (full-empty reads + reads taken while the ring was draining below target) — the auto-tune
/// gates its lowering on THESE rather than the legacy total, so a steady trickle of inaudible
/// device-gulp partials (a chunky output render callback asking for an oversized block on an
/// otherwise on-target ring) no longer pins the target high forever. <c>DeviceGulpUnderruns</c>
/// is that excluded device-structural remainder, surfaced for the diag log so the split can be
/// validated. Both are per-route for BothIndependent, mirroring <see cref="UnderrunsFor"/>.</summary>
public long TuneBlockingUnderruns => playoutEngine.AggregateTuneBlockingUnderruns;
public long TuneBlockingUnderrunsFor(RenderRoute route) => playoutEngine.AggregateTuneBlockingUnderrunsFor(route);
public long DeviceGulpUnderruns => playoutEngine.AggregateDeviceGulpUnderruns;
public long DeviceGulpUnderrunsFor(RenderRoute route) => playoutEngine.AggregateDeviceGulpUnderrunsFor(route);
/// <summary>Per-cause split of the legacy `Drops` rollup. Useful in the diag log to tell
/// "we deliberately trimmed the buffer to track the latency target" (TrimDropBytes) from
/// "we got malformed packets" (PacketsRejectedMalformed) from "ringbuffer overflowed and
+47
View File
@@ -366,6 +366,53 @@ internal sealed class PlayoutEngine : IWaveProvider
return total;
}
/// <summary>Cause-split underrun aggregators for the continuous auto-tune (2026-06-13).
/// <see cref="AggregateTuneBlockingUnderrunsFor"/> sums the short-reads the tuner should treat
/// as "need more buffer" (full-empty reads + reads taken while the ring was draining below
/// target). <see cref="AggregateDeviceGulpUnderrunsFor"/> sums the inaudible on-target partial
/// short-reads caused by a chunky output render callback, which more buffer can't fix and which
/// must NOT block the tuner from lowering. Together they partition <see cref="AggregateUnderrunsFor"/>.
/// Per-route, mirroring the underrun aggregator above.</summary>
public long AggregateTuneBlockingUnderrunsFor(RenderRoute route)
{
long total = 0;
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) total += s.TuneBlockingUnderrunsTotal;
}
return total;
}
public long AggregateTuneBlockingUnderruns
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.TuneBlockingUnderrunsTotal;
return total;
}
}
public long AggregateDeviceGulpUnderrunsFor(RenderRoute route)
{
long total = 0;
foreach (var s in sessionsSnapshot)
{
if (s.Route == route) total += s.DeviceGulpUnderrunsTotal;
}
return total;
}
public long AggregateDeviceGulpUnderruns
{
get
{
long total = 0;
foreach (var s in sessionsSnapshot) total += s.DeviceGulpUnderrunsTotal;
return total;
}
}
/// <summary>True if at least one session is currently tagged for this route. Used by
/// the auto-tune to skip ticking a lane that has nobody to tune — without this gate
/// the ASIO auto-tune (for example) would react to the shared network-gap signal
+78 -1
View File
@@ -56,6 +56,19 @@ internal sealed class SessionPlayout : IDisposable
// independent of the byte amount).
private long trimFireCount;
// Reference latency the click-trim threshold is measured against, distinct from the live
// target. It snaps UP to the target instantly (raising never trims) but eases DOWN slowly when
// the target is lowered - e.g. the continuous auto-tune walking the latency down. Without this
// glide, each downward target step drops the trim threshold under the buffer's natural sawtooth
// and the trim shaves a chunk off the buffer on the spot: an audible click on every step (the
// "crackly while it tunes" Ed reported, worse at a 3-second tune interval, and on ASIO too since
// its tighter smoothness=1 trim is even more eager). Easing the ceiling down slower than the
// depth corrector drains the buffer lets the buffer slide down UNDER the threshold instead of
// being trimmed. Genuine bloat above the gliding ceiling still trims. In steady state it equals
// the target, so normal operation - including ASIO Tight mode's burst snap-back - is unchanged.
private double trimGlideTargetMs;
private long prevTrimGlideTicks;
// === Underrun concealment state ===
// When the playout ring buffer comes up short on a render-side read, AudioRingBuffer
// silence-fills the missing portion with hard zero. The transient from the last real
@@ -249,6 +262,33 @@ internal sealed class SessionPlayout : IDisposable
private long partialReadFiresTotal;
public long ConcealmentFiresTotal => Interlocked.Read(ref concealmentFiresTotal);
public long PartialReadFiresTotal => Interlocked.Read(ref partialReadFiresTotal);
// Cause-split of ring short-reads, for the continuous auto-tune (2026-06-13). The legacy
// UnderrunCount can't tell apart two very different events that both come up short on a read:
// * PRODUCER starvation — the ring is draining below target because decoded audio isn't
// arriving fast enough (late/lost packets, or a stalled receive/decode thread). MORE
// buffer genuinely helps; this is the "raise the target / don't lower" case.
// * DEVICE gulp — the output render callback came late/chunky and asked for an oversized
// block in one go (classic onboard-Realtek WASAPI ~21ms period stretching toward ~42ms),
// so a perfectly healthy on-target ring still can't fill that one gulp. These are partial
// short-reads that are INAUDIBLE (zero-padded sub-frame tail since the 2026-05-14 fix), and
// more buffer never cures the gulp pattern — it just adds permanent latency. This is the
// case that used to pin the auto-tune high forever (it skipped lowering on ANY underrun).
// We tell them apart by where the buffer is sitting at the instant of the short-read: a
// chronically-below-target buffer (filteredErrorFrames well negative) is producer starvation;
// an on-target buffer that momentarily came up short is a device gulp. The split is computed
// live on the render thread, independent of file logging, so it drives the live tuner even
// with logs off. The cumulative totals are also surfaced for the diag log so the classification
// can be validated against real captures.
private long tuneBlockingUnderrunsTotal;
private long deviceGulpUnderrunsTotal;
/// <summary>Short-reads the auto-tune should treat as "need more buffer" — full-empty reads
/// (audible) plus any short-read taken while the ring was chronically below target (producer
/// starvation). These block the auto-tune from lowering, exactly as the legacy total used to.</summary>
public long TuneBlockingUnderrunsTotal => Interlocked.Read(ref tuneBlockingUnderrunsTotal);
/// <summary>Inaudible partial short-reads taken while the ring was on target — the device-gulp
/// case more buffer can't fix. These must NOT block the auto-tune from lowering.</summary>
public long DeviceGulpUnderrunsTotal => Interlocked.Read(ref deviceGulpUnderrunsTotal);
/// <summary>Live state — the LP-filtered drift error in stereo frames. Positive = buffer
/// running above target on average (sender clock faster); negative = buffer below
/// target. Magnitude shows how off-target the buffer's average position is right now.</summary>
@@ -378,6 +418,8 @@ internal sealed class SessionPlayout : IDisposable
lastConcealSampleR = 0f;
filteredErrorFrames = 0;
prevDriftSampleTicks = 0;
trimGlideTargetMs = 0;
prevTrimGlideTicks = 0;
// Phase-4 drift resampler state. Reset counters and window state. Reset() on the
// resampler clears its internal filter delay line so a fresh session doesn't
// inherit phase from a prior one. SetRates back to 1:1 — we'll re-measure drift
@@ -520,8 +562,28 @@ internal sealed class SessionPlayout : IDisposable
};
if (knobExtraMs >= 0)
{
// Ease the trim's reference latency: snap UP to the target instantly (raising never
// trims), glide DOWN at ~1.5 ms/sec - slower than the depth corrector can drain the
// buffer - so a lowered target (the auto-tune walking latency down) lets the buffer
// slide down under the threshold rather than being shaved into a click on each step.
const double TrimGlideDownMsPerSec = 1.5;
var nowTrimTicks = Stopwatch.GetTimestamp();
if (trimGlideTargetMs <= 0 || targetLatencyMs >= trimGlideTargetMs)
{
trimGlideTargetMs = targetLatencyMs;
}
else if (prevTrimGlideTicks != 0)
{
var dtSec = (nowTrimTicks - prevTrimGlideTicks) / (double)Stopwatch.Frequency;
trimGlideTargetMs = Math.Max(targetLatencyMs, trimGlideTargetMs - TrimGlideDownMsPerSec * dtSec);
}
prevTrimGlideTicks = nowTrimTicks;
var trimMarginMs = floorMarginMs + knobExtraMs;
var trimThresholdBytes = MillisecondsToBytes(targetLatencyMs + trimMarginMs);
// Threshold uses the gliding ceiling so a freshly-lowered target doesn't trip the trim.
// The drop destination still uses the REAL target, so if genuine bloat does push the
// buffer above the gliding ceiling, it's cut back to where the latency actually wants it.
var trimThresholdBytes = MillisecondsToBytes((int)Math.Round(trimGlideTargetMs) + trimMarginMs);
if (playout.BufferedBytes > trimThresholdBytes)
{
var keepBytes = MillisecondsToBytes(Math.Max(targetLatencyMs + dropToCushionMs, 1));
@@ -701,6 +763,21 @@ internal sealed class SessionPlayout : IDisposable
// than zero; the zero-padded tail just produces silence at the resampler output
// for that fraction.
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
// Cause-split for the continuous auto-tune (see tuneBlockingUnderrunsTotal declaration).
// Any short-read is classified the instant it happens by where the buffer is sitting:
// a ring chronically below target (filteredErrorFrames well negative) is producer/network
// starvation that more buffer fixes; an on-target ring that came up short is an inaudible
// device gulp that more buffer can't fix. ~3ms of deficit is the boundary — past the
// on-target jitter the depth-corrector leaves behind, far short of a real producer stall.
if (framesGot < inputFramesNeeded)
{
const int TuneStarveMs = 3;
var producerBehind = filteredErrorFrames <= -(TuneStarveMs * MixSampleRate / 1000.0);
if (framesGot == 0 || producerBehind)
Interlocked.Increment(ref tuneBlockingUnderrunsTotal);
else
Interlocked.Increment(ref deviceGulpUnderrunsTotal);
}
if (framesGot == 0)
{
Interlocked.Increment(ref concealmentFiresTotal);