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
+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; }
}
}