From e526014e6adc8c6d652046663b9ddb087ddcf45c Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:06:07 +0100 Subject: [PATCH] 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 " .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 --- MANUAL.md | 36 +++--- readme.html | 36 +++--- run-tests.ps1 | 13 +- src/RemSound.App/CueSounds.cs | 93 ++++++++++++++ src/RemSound.App/KeyClickService.cs | 173 ++++++++++++++++++++++++++ src/RemSound.App/MainForm.cs | 8 +- src/RemSound.App/PreferencesDialog.cs | 147 ++++++++++++++++++++-- src/RemSound.App/Program.cs | 12 +- src/RemSound.App/RemSound.App.csproj | 66 ++-------- src/RemSound.App/SelfTest.cs | 8 +- src/RemSound.Core/AppConfig.cs | 14 +++ 11 files changed, 502 insertions(+), 104 deletions(-) create mode 100644 src/RemSound.App/CueSounds.cs create mode 100644 src/RemSound.App/KeyClickService.cs diff --git a/MANUAL.md b/MANUAL.md index 3bde400..37bd5f3 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -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. diff --git a/readme.html b/readme.html index e6702c4..15f0624 100644 --- a/readme.html +++ b/readme.html @@ -919,7 +919,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift

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:

@@ -931,13 +931,14 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift +
CuePlays when
Profile switched soundYou 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 soundThe Quick profile switch popup opens.
Update soundAn 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 soundRemSound 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.

@@ -954,6 +955,22 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift

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

@@ -962,18 +979,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift

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:

- - +

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.

diff --git a/run-tests.ps1 b/run-tests.ps1 index ca18f0f..77fe57b 100644 --- a/run-tests.ps1 +++ b/run-tests.ps1 @@ -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\)" } diff --git a/src/RemSound.App/CueSounds.cs b/src/RemSound.App/CueSounds.cs new file mode 100644 index 0000000..288b56c --- /dev/null +++ b/src/RemSound.App/CueSounds.cs @@ -0,0 +1,93 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// +/// Default cue-sound resolution. The cue WAVs ship as numbered variants in sounds\ - +/// "connect 1.wav", "connect 2.wav", ... - and the user picks which one is the default for each +/// cue in Preferences (stored machine-wide in ). 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 "<base> <n>.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. +/// +internal static class CueSounds +{ + /// The variant filenames available for a cue, sorted by their trailing number. The + /// base name comes from (the historical single name, e.g. + /// "connect.wav" -> base "connect"), matched against "connect.wav" and "connect <n>.wav" + /// in sounds\. Returns filenames only (no path); empty when none are present. + public static IReadOnlyList Variants(string defaultFileName) + { + var baseName = Path.GetFileNameWithoutExtension(defaultFileName); + var dir = AppConfig.SoundsDirectory; + if (string.IsNullOrEmpty(baseName) || !Directory.Exists(dir)) return Array.Empty(); + + 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(); } + return matches.OrderBy(m => m.Order).Select(m => m.Name).ToList(); + } + + /// The "Sound N" label for a variant filename, for the Preferences listbox. + /// "connect 2.wav" -> "Sound 2"; an unnumbered "connect.wav" -> "Sound 1". + 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"; + } + + /// 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. + 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]; + } + + /// Full path to the chosen default WAV for a cue, or null when none resolves. + 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); + } +} diff --git a/src/RemSound.App/KeyClickService.cs b/src/RemSound.App/KeyClickService.cs new file mode 100644 index 0000000..1fad0b1 --- /dev/null +++ b/src/RemSound.App/KeyClickService.cs @@ -0,0 +1,173 @@ +using NAudio.Wave; +using NAudio.Wave.SampleProviders; +using RemSound.Core; + +namespace RemSound.App; + +/// +/// 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. +/// +internal static class KeyClickService +{ + private const int WM_CHAR = 0x0102; + + private static KeyClickPlayer? player; + private static MessageFilter? filter; + + /// Live on/off, read by the message filter on every keystroke. Set from Preferences. + public static bool Enabled { get; set; } + + /// 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. + 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 + } + } + + /// 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. + 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(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(); + } + } + + /// One-shot reader over a ; the mixer drops it when it ends. + 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 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 */ } + } + } +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 5f0c88e..3a8f25a 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -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; } } diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index d4ff887..0842608 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -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 " .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 currentVariants = Array.Empty(); + // 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, + }; + /// Describes one cue row in the list. is the listbox /// text; is the well-known key from ; /// is the bundled WAV in sounds\. 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 } } + /// 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. + private void RefreshDefaultSoundList() + { + suppressDefaultSoundPreview = true; + try + { + defaultSoundList.Items.Clear(); + currentVariants = Array.Empty(); + 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; } + } + + /// 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. + 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 */ } + } + /// Resolves the WAV file currently configured for a cue: the user's custom /// override if set and on disk, otherwise the bundled default in sounds\. /// 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; } /// Preview a cue's currently-configured WAV through the system default audio diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index 7f31229..026ebdf 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -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 */ } diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index 03905ba..4735be2 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -57,61 +57,17 @@ - - - - sounds\connect.wav - PreserveNewest - - - sounds\disconnect.wav - PreserveNewest - - - sounds\record start.wav - PreserveNewest - - - sounds\record stop.wav - PreserveNewest - - - - sounds\save.wav - PreserveNewest - - - sounds\profile.wav - PreserveNewest - - - - sounds\update.wav - PreserveNewest - - - - sounds\profile menu open.wav - PreserveNewest - - - - sounds\start up.wav + + + sounds\%(Filename)%(Extension) PreserveNewest