Audio cues: per-cue default-sound picker, keyboard-click typing feedback, passkey on password fields

Cue sounds now ship as numbered variants ("connect 1.wav", "connect 2.wav", ...); the
count is never hard-coded so more can be added with no code change.

- CueSounds.cs: discovers a cue's "<base> <n>.wav" variants (case-insensitive) and
  resolves the active default: per-profile custom WAV > machine-wide chosen variant >
  first variant > silent. Wired into MainForm.TryLoadCueSound, the startup cue in
  Program.cs, and PreferencesDialog.ResolveCueFilePath.
- AppConfig: DefaultCueSounds (machine-wide cueId -> chosen filename) and
  EnableKeyboardClicks (on by default).
- Preferences: a "Choose default sound" listbox under the cue checklist - it lists the
  selected cue's variants, arrowing it previews each sound and makes it that cue's
  default. Plus a "Play keyboard clicks when typing into any edit field" checkbox.
- KeyClickService.cs: an app-wide WM_CHAR message filter + a low-latency NAudio mixer.
  Typing into any edit field plays a random key click (key 1..N.wav); password fields
  also play passkey.wav at the same instant. On/off live from the Preferences toggle.
  Inert if the sounds are missing or the device won't open; never consumes the keystroke.
- csproj: ship every sounds\*.wav via a wildcard (variants, key clicks, passkey, future
  additions) instead of stale per-file canonical names.
- Tests: resource checks (self-test + run-tests.ps1) now verify each cue has >=1 variant
  and that key 1.wav / passkey.wav are present. Accessibility audit still green with the
  new Preferences controls (Alt+D, Alt+K - no mnemonic clashes).
- Manual: variant picker, keyboard clicks, and the new sound-file naming documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-13 00:06:07 +01:00
co-authored by Claude Opus 4.8
parent ad66fe5364
commit e526014e6a
11 changed files with 502 additions and 104 deletions
+21 -15
View File
@@ -866,7 +866,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**. Eight events 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**. Nine events have a cue:
Cue| Plays when
---|---
@@ -878,12 +878,13 @@ Cue| Plays when
**Profile switched sound**| You switch to a different profile — from the Recent profiles menu, the Quick profile switch popup, or File → Open profile. It plays the moment you pick the new profile. It deliberately does _not_ play on a fresh start into your first profile, so it isn't layered on top of the connect sound at launch.
**Profile menu open sound**| The Quick profile switch popup opens.
**Update sound**| An update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.
**Startup sound**| RemSound has finished starting up. It plays once at launch, even when RemSound opens straight to the notification area, so you know it's running.
All eight 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.)
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.)
### Turning each cue on or off
Open **File → Preferences** (or Ctrl+P). The **Audio cue sounds (Alt+N)** list shows all eight cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.
Open **File → Preferences** (or Ctrl+P). The **Audio cue sounds (Alt+N)** list shows every cue with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.
Use the up and down arrow keys to move between cues; press **Space** to toggle the highlighted cue's tick on or off.
@@ -900,6 +901,22 @@ Below the list are two buttons that act on whichever cue is currently highlighte
Both buttons' labels update as you arrow through the list, so you always know which cue you're about to act on.
### Choosing which built-in sound a cue uses
Each cue ships with more than one sound to choose from. Between the cue list and the buttons is a **Choose default sound (Alt+D)** list. It shows the built-in sounds available for whichever cue is highlighted in the list above. Arrow up and down it and RemSound plays each sound as you land on it, so you can listen through them and stop on the one you like — the sound you leave selected becomes that cue's default.
This is separate from browsing for your own file: this list picks among the sounds that _ship with_ RemSound, while Browse replaces the sound with a WAV of your own. A file you've browsed for always takes priority; clear it (right-click Browse → Use default) and the cue goes back to the built-in sound you chose here.
Your choice here is remembered **for the whole installation** , not per profile, so your preferred set of built-in sounds follows you across every profile.
### Keyboard clicks while typing
Just below the cue controls is a tickbox, **Play keyboard clicks when typing into any edit field (Alt+K)** , which is on by default. With it ticked, typing into any text box anywhere in RemSound plays a soft click on each key, so you get an audible sense of your typing. It only sounds while your cursor is actually in an edit field — move out of the field and it stops.
**Password boxes are treated specially:** they play the same key click _and_ a second, distinct sound at the same instant, so you can tell by ear when you're typing into a masked password field rather than an ordinary one. That second sound only happens in password fields.
Untick the box to switch all of this off. The setting applies to the whole installation.
Custom sound choices are **saved with the active profile** , the same way the tick states are. Different profiles can have completely different cue palettes — a “studio” profile might use one set of sounds, a “broadcast” profile another. The custom files themselves stay where you picked them on your disk; the profile just remembers their paths.
### Going back to the default sound
@@ -908,18 +925,7 @@ To revert a cue to its default sound, **right-click** the _Browse for [cue name]
### 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. It puts the defaults there for you; if you don't pick a custom file for a cue, RemSound plays the matching one from this folder:
* `sounds\connect.wav` — connect cue
* `sounds\disconnect.wav` — disconnect cue
* `sounds\record start.wav` — recording start cue
* `sounds\record stop.wav` — recording stop cue
* `sounds\save.wav` — profile saved cue
* `sounds\profile.wav` — profile switched cue
* `sounds\profile menu open.wav` — profile menu open cue
* `sounds\update.wav` — update cue
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.
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.
+21 -15
View File
@@ -919,7 +919,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>. Eight events 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>. Nine events have a cue:</p>
<table>
<tr><th>Cue</th><th>Plays when</th></tr>
@@ -931,13 +931,14 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<tr><td><strong>Profile switched sound</strong></td><td>You switch to a different profile &mdash; from the Recent profiles menu, the Quick profile switch popup, or File &rarr; Open profile. It plays the moment you pick the new profile. It deliberately does <em>not</em> play on a fresh start into your first profile, so it isn't layered on top of the connect sound at launch.</td></tr>
<tr><td><strong>Profile menu open sound</strong></td><td>The Quick profile switch popup opens.</td></tr>
<tr><td><strong>Update sound</strong></td><td>An update is about to install &mdash; it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.</td></tr>
<tr><td><strong>Startup sound</strong></td><td>RemSound has finished starting up. It plays once at launch, even when RemSound opens straight to the notification area, so you know it's running.</td></tr>
</table>
<p>All eight 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>
<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>
<h3>Turning each cue on or off</h3>
<p>Open <strong>File &rarr; Preferences</strong> (or Ctrl+P). The <strong>Audio cue sounds (Alt+N)</strong> list shows all eight cues with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.</p>
<p>Open <strong>File &rarr; Preferences</strong> (or Ctrl+P). The <strong>Audio cue sounds (Alt+N)</strong> list shows every cue with a tickbox next to each. Tick to play the cue when the corresponding event happens; untick to silence it.</p>
<p>Use the up and down arrow keys to move between cues; press <strong>Space</strong> to toggle the highlighted cue's tick on or off.</p>
@@ -954,6 +955,22 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<p>Both buttons' labels update as you arrow through the list, so you always know which cue you're about to act on.</p>
<h3>Choosing which built-in sound a cue uses</h3>
<p>Each cue ships with more than one sound to choose from. Between the cue list and the buttons is a <strong>Choose default sound (Alt+D)</strong> list. It shows the built-in sounds available for whichever cue is highlighted in the list above. Arrow up and down it and RemSound plays each sound as you land on it, so you can listen through them and stop on the one you like &mdash; the sound you leave selected becomes that cue's default.</p>
<p>This is separate from browsing for your own file: this list picks among the sounds that <em>ship with</em> RemSound, while Browse replaces the sound with a WAV of your own. A file you've browsed for always takes priority; clear it (right-click Browse &rarr; Use default) and the cue goes back to the built-in sound you chose here.</p>
<p>Your choice here is remembered <strong>for the whole installation</strong>, not per profile, so your preferred set of built-in sounds follows you across every profile.</p>
<h3>Keyboard clicks while typing</h3>
<p>Just below the cue controls is a tickbox, <strong>Play keyboard clicks when typing into any edit field (Alt+K)</strong>, which is on by default. With it ticked, typing into any text box anywhere in RemSound plays a soft click on each key, so you get an audible sense of your typing. It only sounds while your cursor is actually in an edit field &mdash; move out of the field and it stops.</p>
<p><strong>Password boxes are treated specially:</strong> they play the same key click <em>and</em> a second, distinct sound at the same instant, so you can tell by ear when you're typing into a masked password field rather than an ordinary one. That second sound only happens in password fields.</p>
<p>Untick the box to switch all of this off. The setting applies to the whole installation.</p>
<p>Custom sound choices are <strong>saved with the active profile</strong>, the same way the tick states are. Different profiles can have completely different cue palettes &mdash; a &ldquo;studio&rdquo; profile might use one set of sounds, a &ldquo;broadcast&rdquo; profile another. The custom files themselves stay where you picked them on your disk; the profile just remembers their paths.</p>
<h3>Going back to the default sound</h3>
@@ -962,18 +979,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<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. It puts the defaults there for you; if you don't pick a custom file for a cue, RemSound plays the matching one from this folder:</p>
<ul>
<li><code>sounds\connect.wav</code> &mdash; connect cue</li>
<li><code>sounds\disconnect.wav</code> &mdash; disconnect cue</li>
<li><code>sounds\record start.wav</code> &mdash; recording start cue</li>
<li><code>sounds\record stop.wav</code> &mdash; recording stop cue</li>
<li><code>sounds\save.wav</code> &mdash; profile saved cue</li>
<li><code>sounds\profile.wav</code> &mdash; profile switched cue</li>
<li><code>sounds\profile menu open.wav</code> &mdash; profile menu open cue</li>
<li><code>sounds\update.wav</code> &mdash; update cue</li>
</ul>
<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>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>
+10 -3
View File
@@ -44,10 +44,17 @@ if (-not (Test-Path -LiteralPath $exe)) {
# ---- 2. PACKAGE CONTENTS (must run BEFORE any CLI call: every RemSound launch consolidates the
# bundled sounds\ into 'user settings and logs\sounds\', emptying sounds\) ----
Write-Host "`nPackage contents:" -ForegroundColor Cyan
$wavCount = @(Get-ChildItem -LiteralPath (Join-Path $publishDir 'sounds') -Filter *.wav -ErrorAction SilentlyContinue).Count
$soundsPath = Join-Path $publishDir '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" }
foreach ($cue in @('connect.wav', 'disconnect.wav', 'start up.wav')) {
if (Test-Path -LiteralPath (Join-Path $publishDir "sounds\$cue")) { Pass "cue '$cue' present" } else { Fail "cue '$cue' missing from the published sounds\ folder" }
# Cues ship as numbered variants ("connect 1.wav", ...); each required cue needs at least one.
foreach ($base in @('connect', 'disconnect', 'start up')) {
$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 $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\)" }
+93
View File
@@ -0,0 +1,93 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Default cue-sound resolution. The cue WAVs ship as numbered variants in <c>sounds\</c> -
/// "connect 1.wav", "connect 2.wav", ... - and the user picks which one is the default for each
/// cue in Preferences (stored machine-wide in <see cref="AppConfig.DefaultCueSounds"/>). This
/// helper discovers the variants for a cue and resolves which one is the active default.
///
/// The full resolution order used wherever a cue is loaded (MainForm, the startup cue, the
/// Preferences preview) is:
/// 1. the user's per-profile custom WAV (handled by the caller) - highest priority;
/// 2. the machine-wide chosen default variant, if it still exists on disk;
/// 3. the first available variant (the lowest-numbered - the "1"s) - the shipped default;
/// 4. nothing - the cue is silent.
///
/// The count of variants is never assumed: whatever "&lt;base&gt; &lt;n&gt;.wav" files are present
/// are offered, so adding more sounds later needs no code change. Matching is case-insensitive so
/// a stray capital (e.g. "Profile menu open 1.wav") still resolves.
/// </summary>
internal static class CueSounds
{
/// <summary>The variant filenames available for a cue, sorted by their trailing number. The
/// base name comes from <paramref name="defaultFileName"/> (the historical single name, e.g.
/// "connect.wav" -> base "connect"), matched against "connect.wav" and "connect &lt;n&gt;.wav"
/// in <c>sounds\</c>. Returns filenames only (no path); empty when none are present.</summary>
public static IReadOnlyList<string> Variants(string defaultFileName)
{
var baseName = Path.GetFileNameWithoutExtension(defaultFileName);
var dir = AppConfig.SoundsDirectory;
if (string.IsNullOrEmpty(baseName) || !Directory.Exists(dir)) return Array.Empty<string>();
var matches = new List<(int Order, string Name)>();
try
{
foreach (var full in Directory.EnumerateFiles(dir, "*.wav"))
{
var name = Path.GetFileName(full);
var stem = Path.GetFileNameWithoutExtension(name);
if (stem.Equals(baseName, StringComparison.OrdinalIgnoreCase))
{
matches.Add((0, name)); // the bare, unnumbered name sorts first
}
else if (stem.Length > baseName.Length + 1
&& stem.StartsWith(baseName + " ", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(stem[(baseName.Length + 1)..], out var n))
{
matches.Add((n, name));
}
}
}
catch { return Array.Empty<string>(); }
return matches.OrderBy(m => m.Order).Select(m => m.Name).ToList();
}
/// <summary>The "Sound N" label for a variant filename, for the Preferences listbox.
/// "connect 2.wav" -> "Sound 2"; an unnumbered "connect.wav" -> "Sound 1".</summary>
public static string VariantLabel(string defaultFileName, string variantFileName)
{
var baseName = Path.GetFileNameWithoutExtension(defaultFileName);
var stem = Path.GetFileNameWithoutExtension(variantFileName);
if (stem.Length > baseName.Length + 1
&& stem.StartsWith(baseName + " ", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(stem[(baseName.Length + 1)..], out var n))
{
return $"Sound {n}";
}
return "Sound 1";
}
/// <summary>The chosen default variant filename for a cue: the machine-wide pick if it still
/// exists among the variants, otherwise the first variant (the "1"). Null when the cue has no
/// variants on disk at all.</summary>
public static string? ResolveDefaultFileName(string cueId, string defaultFileName, AppConfig cfg)
{
var variants = Variants(defaultFileName);
if (variants.Count == 0) return null;
if (cfg.DefaultCueSounds.TryGetValue(cueId, out var chosen))
{
var match = variants.FirstOrDefault(v => v.Equals(chosen, StringComparison.OrdinalIgnoreCase));
if (match is not null) return match;
}
return variants[0];
}
/// <summary>Full path to the chosen default WAV for a cue, or null when none resolves.</summary>
public static string? ResolveDefaultPath(string cueId, string defaultFileName, AppConfig cfg)
{
var name = ResolveDefaultFileName(cueId, defaultFileName, cfg);
return name is null ? null : Path.Combine(AppConfig.SoundsDirectory, name);
}
}
+173
View File
@@ -0,0 +1,173 @@
using NAudio.Wave;
using NAudio.Wave.SampleProviders;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Audible typing feedback. When enabled, every character typed into any edit field anywhere in
/// RemSound plays one of several soft key-click sounds (chosen at random), and password fields
/// additionally play a distinct "passkey" sound at the same instant - so a screen-reader user
/// knows they're typing, and knows when they're in a masked password field.
///
/// Built for speed and overlap: the click WAVs are decoded into memory once and rendered through a
/// single persistent mixer + output, so a click fires the instant a key is pressed and fast typing
/// simply layers clicks rather than cutting them off. The hook is a single application-wide message
/// filter watching for WM_CHAR; it never consumes the keystroke, so typing is unaffected. If the
/// click sounds are missing or the output device won't open, the whole thing stays silently inert.
/// </summary>
internal static class KeyClickService
{
private const int WM_CHAR = 0x0102;
private static KeyClickPlayer? player;
private static MessageFilter? filter;
/// <summary>Live on/off, read by the message filter on every keystroke. Set from Preferences.</summary>
public static bool Enabled { get; set; }
/// <summary>Preload the click sounds, open the output, and install the app-wide key hook. Call
/// once on the GUI thread after the message loop's owner exists. Best-effort - any failure
/// leaves the service inert (no clicks), never throwing into startup.</summary>
public static void Initialize(bool enabled)
{
Enabled = enabled;
try
{
player = new KeyClickPlayer(AppConfig.SoundsDirectory);
filter = new MessageFilter();
System.Windows.Forms.Application.AddMessageFilter(filter);
}
catch
{
player = null;
filter = null;
}
}
public static void Shutdown()
{
try { if (filter is not null) System.Windows.Forms.Application.RemoveMessageFilter(filter); } catch { /* ignore */ }
try { player?.Dispose(); } catch { /* ignore */ }
filter = null;
player = null;
}
private static void OnChar(IntPtr hwnd)
{
if (!Enabled || player is null) return;
// The WM_CHAR target window is the focused control. Click only when it's an edit field.
if (System.Windows.Forms.Control.FromHandle(hwnd) is not System.Windows.Forms.TextBoxBase edit) return;
var isPassword = edit is System.Windows.Forms.TextBox tb && (tb.UseSystemPasswordChar || tb.PasswordChar != '\0');
player.PlayClick(isPassword);
}
private sealed class MessageFilter : System.Windows.Forms.IMessageFilter
{
public bool PreFilterMessage(ref System.Windows.Forms.Message m)
{
if (m.Msg == WM_CHAR) OnChar(m.HWnd);
return false; // never consume - the character must still reach the edit field
}
}
/// <summary>A short sound decoded into memory once, at the mixer's format, ready to play
/// instantly and as many times over as fast typing needs.</summary>
private sealed class CachedSound
{
public float[] AudioData { get; }
public WaveFormat WaveFormat { get; }
public CachedSound(string path, WaveFormat target)
{
using var reader = new AudioFileReader(path);
ISampleProvider source = reader;
if (source.WaveFormat.SampleRate != target.SampleRate)
source = new WdlResamplingSampleProvider(source, target.SampleRate);
if (source.WaveFormat.Channels == 1 && target.Channels == 2)
source = new MonoToStereoSampleProvider(source);
else if (source.WaveFormat.Channels == 2 && target.Channels == 1)
source = new StereoToMonoSampleProvider(source);
WaveFormat = source.WaveFormat;
var data = new List<float>(target.SampleRate); // ~1s headroom; clicks are far shorter
var buffer = new float[target.SampleRate * target.Channels];
int read;
while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read; i++) data.Add(buffer[i]);
}
AudioData = data.ToArray();
}
}
/// <summary>One-shot reader over a <see cref="CachedSound"/>; the mixer drops it when it ends.</summary>
private sealed class CachedSoundSampleProvider : ISampleProvider
{
private readonly CachedSound sound;
private long position;
public CachedSoundSampleProvider(CachedSound sound) => this.sound = sound;
public WaveFormat WaveFormat => sound.WaveFormat;
public int Read(float[] buffer, int offset, int count)
{
var available = sound.AudioData.Length - position;
var n = (int)Math.Min(available, count);
if (n > 0) Array.Copy(sound.AudioData, position, buffer, offset, n);
position += n;
return n;
}
}
private sealed class KeyClickPlayer : IDisposable
{
private readonly WaveOutEvent output;
private readonly MixingSampleProvider mixer;
private readonly List<CachedSound> keyClips = new();
private readonly CachedSound? passkeyClip;
private readonly bool ready;
public KeyClickPlayer(string soundsDir)
{
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
mixer = new MixingSampleProvider(format) { ReadFully = true };
// A modest buffer keeps the click snappy without risking dropouts under load.
output = new WaveOutEvent { DesiredLatency = 80, NumberOfBuffers = 3 };
try
{
for (var i = 1; i <= 8; i++) // discover "key 1.wav" upward; ships with 4 but don't hard-code
{
var p = Path.Combine(soundsDir, $"key {i}.wav");
if (File.Exists(p)) keyClips.Add(new CachedSound(p, format));
}
var passkeyPath = Path.Combine(soundsDir, "passkey.wav");
if (File.Exists(passkeyPath)) passkeyClip = new CachedSound(passkeyPath, format);
if (keyClips.Count > 0)
{
output.Init(mixer);
output.Play();
ready = true;
}
}
catch { ready = false; }
}
public void PlayClick(bool isPassword)
{
if (!ready || keyClips.Count == 0) return;
try
{
mixer.AddMixerInput(new CachedSoundSampleProvider(keyClips[Random.Shared.Next(keyClips.Count)]));
if (isPassword && passkeyClip is not null)
mixer.AddMixerInput(new CachedSoundSampleProvider(passkeyClip));
}
catch { /* a key click must never disturb anything */ }
}
public void Dispose()
{
try { output.Stop(); } catch { /* ignore */ }
try { output.Dispose(); } catch { /* ignore */ }
}
}
}
+5 -3
View File
@@ -6486,14 +6486,16 @@ public sealed class MainForm : Form
}
else
{
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
if (File.Exists(defaultPath))
// The cue ships as numbered variants ("connect 1.wav", "connect 2.wav", ...);
// resolve the machine-wide chosen default (or the first variant) for this cue.
var defaultPath = CueSounds.ResolveDefaultPath(cueId, defaultFileName, AppConfig.Load());
if (defaultPath is not null && File.Exists(defaultPath))
{
path = defaultPath;
}
else
{
logFile.Event($"cue sound '{cueId}': default missing ({defaultPath}) and no custom override set — cue will be silent");
logFile.Event($"cue sound '{cueId}': no default variant found in sounds\\ and no custom override set — cue will be silent");
return;
}
}
+137 -10
View File
@@ -77,6 +77,42 @@ internal sealed class PreferencesDialog : Form
Padding = new Padding(6, 2, 6, 2),
};
// "Choose default sound" — a second listbox under the cue checklist. The cue WAVs ship as
// numbered variants ("connect 1.wav", "connect 2.wav", ...); this lists the variants for the
// cue currently selected in cueList. Arrowing it previews each variant AND makes it the chosen
// default for that cue (machine-wide, AppConfig.DefaultCueSounds). The count isn't hard-coded —
// whatever "<base> <n>.wav" files exist are offered, so adding more sounds later needs no code.
private readonly Label defaultSoundLabel = new()
{
Text = "Choose default soun&d (Alt+D):",
AccessibleName = "Choose default sound",
AutoSize = true,
Padding = new Padding(0, 6, 0, 4),
};
private readonly ListBox defaultSoundList = new()
{
IntegralHeight = false,
Height = 76,
Width = 360,
AccessibleName = "Choose default sound",
};
// The variant filenames currently shown in defaultSoundList, index-aligned with its items, so a
// selected index maps back to the WAV to persist + preview.
private IReadOnlyList<string> currentVariants = Array.Empty<string>();
// Set while we repopulate / programmatically select the list, so it doesn't fire a preview.
private bool suppressDefaultSoundPreview;
// Keyboard-click typing feedback toggle (machine-wide, on by default). Ed asked for it right
// after the cue Browse button. Drives KeyClickService.Enabled live.
private readonly AccessibleCheckBox keyboardClicksBox = new()
{
Text = "Play keyboard clicks when typing into any edit field (Alt+&K)",
AccessibleName = "Play keyboard clicks when typing into any edit field",
AutoSize = true,
};
/// <summary>Describes one cue row in the list. <see cref="DisplayName"/> is the listbox
/// text; <see cref="CueId"/> is the well-known key from <see cref="MainForm.CueId"/>;
/// <see cref="DefaultFileName"/> is the bundled WAV in <c>sounds\</c>. The Load/Save
@@ -329,7 +365,7 @@ internal sealed class PreferencesDialog : Form
// Selection changes update the two action buttons' labels so they always tell the
// user which cue they're about to act on. Refreshed eagerly at construction time
// for the initial selection too.
cueList.SelectedIndexChanged += (_, _) => RefreshCueActionButtons();
cueList.SelectedIndexChanged += (_, _) => { RefreshCueActionButtons(); RefreshDefaultSoundList(); };
RefreshCueActionButtons();
playSelectedCueButton.Click += (_, _) =>
@@ -380,6 +416,22 @@ internal sealed class PreferencesDialog : Form
cueListLabel.Click += (_, _) => cueList.Focus();
// "Choose default sound" listbox — populated for the selected cue, arrowing it previews +
// chooses the default variant. Initial fill for the cue selected at construction.
defaultSoundLabel.Click += (_, _) => defaultSoundList.Focus();
defaultSoundList.SelectedIndexChanged += (_, _) => OnDefaultSoundChosen();
RefreshDefaultSoundList();
// Keyboard-click typing feedback toggle (machine-wide; drives KeyClickService live).
keyboardClicksBox.Checked = AppConfig.Load().EnableKeyboardClicks;
keyboardClicksBox.CheckedChanged += (_, _) =>
{
var c = AppConfig.Load();
c.EnableKeyboardClicks = keyboardClicksBox.Checked;
TrySaveConfig(c);
KeyClickService.Enabled = keyboardClicksBox.Checked;
};
acceptRemoteVolumeBox.Checked = settings.LoadAcceptRemoteVolumeCommands();
acceptRemoteVolumeBox.CheckedChanged += (_, _) =>
{
@@ -528,12 +580,10 @@ internal sealed class PreferencesDialog : Form
Dock = DockStyle.Fill,
AutoSize = true,
ColumnCount = 1,
RowCount = 3,
RowCount = 6,
};
cueGroup.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
for (var i = 0; i < 6; i++) cueGroup.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var cueActions = new FlowLayoutPanel
{
AutoSize = true,
@@ -541,12 +591,20 @@ internal sealed class PreferencesDialog : Form
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Padding = new Padding(0, 4, 0, 0),
TabIndex = 4,
};
cueActions.Controls.Add(playSelectedCueButton);
cueActions.Controls.Add(browseSelectedCueButton);
// Tab order within the cue group: cue checklist -> default-sound list -> Play/Browse ->
// keyboard-clicks checkbox. Labels are skipped (not tab stops).
defaultSoundList.TabIndex = 2;
keyboardClicksBox.TabIndex = 5;
cueGroup.Controls.Add(cueListLabel, 0, 0);
cueGroup.Controls.Add(cueList, 0, 1);
cueGroup.Controls.Add(cueActions, 0, 2);
cueGroup.Controls.Add(defaultSoundLabel, 0, 2);
cueGroup.Controls.Add(defaultSoundList, 0, 3);
cueGroup.Controls.Add(cueActions, 0, 4);
cueGroup.Controls.Add(keyboardClicksBox, 0, 5);
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(cueGroup, 0, 1);
@@ -627,6 +685,75 @@ internal sealed class PreferencesDialog : Form
}
}
/// <summary>Repopulate the "Choose default sound" listbox for the currently-selected cue with
/// its numbered variants, and select whichever variant is the active default. Disabled (with a
/// note) when the cue has no built-in sounds on disk.</summary>
private void RefreshDefaultSoundList()
{
suppressDefaultSoundPreview = true;
try
{
defaultSoundList.Items.Clear();
currentVariants = Array.Empty<string>();
var idx = cueList.SelectedIndex;
if (idx < 0 || idx >= cueRows.Length)
{
defaultSoundList.Enabled = false;
return;
}
var cue = cueRows[idx];
var variants = CueSounds.Variants(cue.DefaultFileName);
if (variants.Count == 0)
{
defaultSoundLabel.Text = "Choose default soun&d (Alt+D): (no built-in sounds)";
defaultSoundList.Enabled = false;
return;
}
defaultSoundLabel.Text = "Choose default soun&d (Alt+D):";
defaultSoundList.Enabled = true;
currentVariants = variants;
foreach (var v in variants) defaultSoundList.Items.Add(CueSounds.VariantLabel(cue.DefaultFileName, v));
var chosen = CueSounds.ResolveDefaultFileName(cue.CueId, cue.DefaultFileName, AppConfig.Load());
var sel = 0;
if (chosen is not null)
{
for (var i = 0; i < variants.Count; i++)
{
if (variants[i].Equals(chosen, StringComparison.OrdinalIgnoreCase)) { sel = i; break; }
}
}
defaultSoundList.SelectedIndex = sel;
}
finally { suppressDefaultSoundPreview = false; }
}
/// <summary>The user arrowed onto / picked a default-sound variant: persist it machine-wide for
/// the selected cue and preview it. The running app re-reads the choice when this dialog closes
/// (MainForm.ReloadAllCueSounds), so the cue plays the new default from then on.</summary>
private void OnDefaultSoundChosen()
{
// Only a genuine user arrow/click should persist + preview; a programmatic re-fill must not.
if (suppressDefaultSoundPreview) return;
var idx = cueList.SelectedIndex;
var vi = defaultSoundList.SelectedIndex;
if (idx < 0 || idx >= cueRows.Length || vi < 0 || vi >= currentVariants.Count) return;
var cue = cueRows[idx];
var chosenFile = currentVariants[vi];
var cfg = AppConfig.Load();
cfg.DefaultCueSounds[cue.CueId] = chosenFile;
TrySaveConfig(cfg);
RefreshCueActionButtons(); // the Play button now previews this same default
try
{
var path = Path.Combine(AppConfig.SoundsDirectory, chosenFile);
if (File.Exists(path)) new CuePlayer(path).Play();
}
catch { /* a preview must never disturb the dialog */ }
}
/// <summary>Resolves the WAV file currently configured for a cue: the user's custom
/// override if set and on disk, otherwise the bundled default in <c>sounds\</c>.
/// Returns null when neither resolves to an existing file (typical for save.wav /
@@ -642,10 +769,10 @@ internal sealed class PreferencesDialog : Form
{
return customPath;
}
// Otherwise the bundled default WAV in sounds\ — the filename the descriptor carries
// (preserves spaces like "record start.wav" / "start up.wav" verbatim).
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, cue.DefaultFileName);
return File.Exists(defaultPath) ? defaultPath : null;
// Otherwise the chosen default variant in sounds\ ("connect 1.wav" / "connect 2.wav" / ...),
// resolved the same way MainForm.TryLoadCueSound resolves it.
var defaultPath = CueSounds.ResolveDefaultPath(cue.CueId, cue.DefaultFileName, AppConfig.Load());
return defaultPath is not null && File.Exists(defaultPath) ? defaultPath : null;
}
/// <summary>Preview a cue's currently-configured WAV through the system default audio
+10 -2
View File
@@ -130,6 +130,13 @@ internal static class Program
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
HelpLauncher.Install();
// Audible typing feedback: a soft click on each keystroke in any edit field, plus a distinct
// passkey sound on password fields. Machine-wide toggle (on by default). Installed app-wide
// here - after the single-instance guard, before the profile picker - so it works on the
// picker and every dialog. Best-effort: inert if the click sounds can't load.
KeyClickService.Initialize(AppConfig.Load().EnableKeyboardClicks);
Application.ApplicationExit += (_, _) => KeyClickService.Shutdown();
// 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.
@@ -436,10 +443,11 @@ internal static class Program
var cfg = AppConfig.Load();
if (!cfg.EnableStartupCue) return;
var custom = cfg.StartupCueCustomPath;
// Custom override wins; otherwise the chosen default variant ("start up 1.wav" etc).
var path = !string.IsNullOrWhiteSpace(custom) && File.Exists(custom)
? custom
: Path.Combine(AppConfig.SoundsDirectory, "start up.wav");
if (!File.Exists(path)) return;
: CueSounds.ResolveDefaultPath(MainForm.CueId.Startup, "start up.wav", cfg);
if (path is null || !File.Exists(path)) return;
new CuePlayer(path).Play();
}
catch { /* a startup cue must never disturb startup */ }
+11 -55
View File
@@ -57,61 +57,17 @@
</ItemGroup>
<ItemGroup>
<!-- All cue sounds live in a sounds\ subfolder under the publish output (moved out of
the install-root flat layout 2026-05-28). Default cue WAVs ship inside that folder;
user-supplied custom cue paths set in Preferences override the defaults at runtime.
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
<!-- Exists-guarded like the newer cues below: a missing source WAV (e.g. while the project owner
is swapping in new cue sounds) must not break the dev build. The release gate (run-tests.ps1
and the self-test "Bundled resources present" step) enforces that the required cues are
actually present before a release ships, so guarding here loses no safety. -->
<Content Include="..\..\sounds\connect.wav" Condition="Exists('..\..\sounds\connect.wav')">
<Link>sounds\connect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\sounds\disconnect.wav" Condition="Exists('..\..\sounds\disconnect.wav')">
<Link>sounds\disconnect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\sounds\record start.wav" Condition="Exists('..\..\sounds\record start.wav')">
<Link>sounds\record start.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\sounds\record stop.wav" Condition="Exists('..\..\sounds\record stop.wav')">
<Link>sounds\record stop.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- New cues introduced 2026-05-28: save.wav fires after a successful Save / Save As;
profile.wav fires immediately after a profile finishes loading. Both default WAVs
to be supplied by the project owner; absent files are gracefully ignored at load
time (the cue just doesn't play) so the build is fine without them. -->
<Content Include="..\..\sounds\save.wav" Condition="Exists('..\..\sounds\save.wav')">
<Link>sounds\save.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\sounds\profile.wav" Condition="Exists('..\..\sounds\profile.wav')">
<Link>sounds\profile.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- update.wav (2026-05-31): plays just before an update starts installing, so a silent
background update still gives an audible heads-up. Per-profile mute + custom-sound
override available in Preferences, same as the other cues. -->
<Content Include="..\..\sounds\update.wav" Condition="Exists('..\..\sounds\update.wav')">
<Link>sounds\update.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- profile menu open.wav (v3.4): plays as the Quick profile switch popup opens. Project-owner
supplied; absent file is ignored at load time. Per-profile mute + custom override in Prefs. -->
<Content Include="..\..\sounds\profile menu open.wav" Condition="Exists('..\..\sounds\profile menu open.wav')">
<Link>sounds\profile menu open.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- start up.wav (v3.9): plays once when RemSound starts, right after this copy wins the
single-instance takeover and before the profile loads. Machine-wide enable + custom
override (AppConfig), since it fires before any profile is chosen. -->
<Content Include="..\..\sounds\start up.wav" Condition="Exists('..\..\sounds\start up.wav')">
<Link>sounds\start up.wav</Link>
<!-- 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>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
+7 -1
View File
@@ -301,10 +301,16 @@ internal static class SelfTest
// 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)");
// Cues ship as numbered variants ("connect 1.wav", ...); each required cue must have at
// least one variant present.
foreach (var cue in new[] { "connect.wav", "disconnect.wav", "start up.wav" })
{
Check(File.Exists(Path.Combine(soundsDir, cue)), $"cue sound '{cue}' must be present (was the shipped sounds\\ folder empty?)");
Check(CueSounds.Variants(cue).Count > 0,
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped 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");
Check(File.Exists(Path.Combine(soundsDir, "passkey.wav")), "password 'passkey.wav' must be present");
// Native Opus (Concentus.Native) keeps the encoder off the allocation-heavy managed fallback.
var nativeOpus = Path.Combine(root, "runtimes", "win-x64", "native", "opus.dll");
+14
View File
@@ -82,6 +82,20 @@ public sealed class AppConfig
/// custom-cue paths) is loaded, so it can't live on <see cref="Profile"/>.</summary>
public string? StartupCueCustomPath { get; set; }
/// <summary>The chosen default-sound FILENAME for each cue (e.g. "connect 2.wav"), keyed by
/// the cue id (<c>MainForm.CueId</c>). The cue WAVs ship as numbered variants ("connect 1.wav",
/// "connect 2.wav", ...); this records which one the user picked in Preferences. Machine-wide,
/// so a user's preferred sound palette follows them across every profile. A cue absent from the
/// dictionary uses the first available variant (the "1"s) by default. A per-profile custom WAV
/// (<see cref="Profile.CustomCuePaths"/>) still overrides this choice.</summary>
public Dictionary<string, string> DefaultCueSounds { get; set; } = new();
/// <summary>If true (the default), typing into any edit field anywhere in RemSound plays a soft
/// keyboard-click sound (one of several, picked at random), so a screen-reader user gets audible
/// typing feedback. Password fields additionally play a distinct key sound at the same time.
/// Machine-wide; the user unticks "Play keyboard clicks" in Preferences to silence it.</summary>
public bool EnableKeyboardClicks { get; set; } = true;
/// <summary>If true, RemSound writes a tab-separated diagnostic log to
/// <c>&lt;exe&gt;\logs\</c>. Lives here (not in <see cref="Profile"/>) because logging
/// is a debugging affordance for the installation, not a user-facing audio preference —