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
+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 —