v4.0: full audio-cue system, cause-aware auto-tune, four-tab Preferences, install-side default sounds
Audio cues - Cues for send/receive on-off, minimise/restore, checkbox tick/untick, and tab switch - Soft keyboard clicks while typing, with a distinct passkey sound on password fields - Per-cue "Choose sound" variant picker; "(none)" silences a cue; front-most missing-sound warning - Send/receive cues take priority over the generic checkbox sound; programmatic ticks stay silent Preferences - Redesigned into four tabs (General, Audio cues, Startup behaviour, Update settings) - Startup behaviour moved in from the Options menu - NVDA now announces the dialog on open (focus a real named control, not the quiet tab control) Auto-tune - Cause-aware: tells device render-callback stalls (more buffer can't fix) apart from genuine network/buffer starvation, so it no longer pins latency high on chunky onboard cards - Lowering the target eases the buffer down (glide) instead of trimming it, so no clicks while tuning Sounds layout - Shipped defaults moved out of the per-user folder into an install-side "default sounds" folder, so updates can refresh them; user customs are Browse-picked file paths and are left untouched - Startup migration removes both legacy sound folders; verified from oldest (v1.0-v3.3) and v3.4 layouts Quiet automated launches - New --silent launch flag mutes all cue sounds and suppresses the startup dialogs (migration notice, update check, Realtek/mic/missing-sound warnings) so test launches never disturb the user - run-tests / build-release / SelfTest repointed to the new "default sounds" layout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0b7ad49021
commit
a408d2b56e
@@ -20,6 +20,43 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v4.0
|
||||
|
||||
RemSound now has a sound for nearly everything you do.
|
||||
On top of the connect, disconnect and recording cues, it
|
||||
can play a short sound when you turn sending or receiving
|
||||
on or off, when it minimises to or returns from the tray,
|
||||
when you tick or untick any box, and when you move between
|
||||
tabs. Every one is optional: under Options → Preferences →
|
||||
Audio cues you can silence any of them, pick which built-in
|
||||
sound it uses, or choose your own WAV file.
|
||||
|
||||
It can also click softly as you type into any box, so you
|
||||
hear your keystrokes, with a distinct sound in password
|
||||
fields so you always know which kind of box you're in. That
|
||||
too is a single tick you can turn off.
|
||||
|
||||
The Preferences window is now organised into four clear tabs
|
||||
— General, Audio cues, Startup behaviour and Update settings
|
||||
— so everything is easier to find. The startup options
|
||||
(start with Windows, start minimised, start with a chosen
|
||||
profile) have moved here from the Options menu.
|
||||
|
||||
The automatic latency tuning is cleverer. It used to treat
|
||||
every tiny audio glitch as "the buffer is too small" and
|
||||
keep adding delay — even when the real cause was the
|
||||
receiving computer's own sound card stumbling, which more
|
||||
buffer can't fix. It now tells the two apart, so it stops
|
||||
piling on delay it can't help. And when it does lower the
|
||||
latency, it eases the buffer down smoothly instead of
|
||||
trimming it, so you no longer hear little clicks while it
|
||||
tunes.
|
||||
|
||||
Finally, the built-in default sounds now travel with the
|
||||
program itself, so an update can refresh them — if a better
|
||||
default sound ships in a future update, you'll actually get
|
||||
it. Your own chosen sounds are kept exactly as you set them.
|
||||
|
||||
RemSound v3.9
|
||||
|
||||
Listening for a long time no longer slowly builds up
|
||||
|
||||
@@ -17,6 +17,13 @@ internal static class CheckSoundService
|
||||
private static CuePlayer? checkSound;
|
||||
private static CuePlayer? uncheckSound;
|
||||
|
||||
/// <summary>When true, <see cref="Play"/> is a no-op. MainForm sets this around bulk programmatic
|
||||
/// control updates (profile load, "uncheck all", device-list refresh). The per-call Focused gate
|
||||
/// already silences MOST programmatic ticks, but it leaks when the box we tick in code happens to
|
||||
/// be the focused control on launch — which is exactly what made loading a profile blast a
|
||||
/// checkbox click. This flag closes that gap: only genuine user toggles ever click.</summary>
|
||||
public static bool Suppressed { get; set; }
|
||||
|
||||
/// <summary>(Re)load the tick/untick sounds from the current cue configuration. Call at startup
|
||||
/// and whenever cue settings change.</summary>
|
||||
public static void Reload()
|
||||
@@ -28,6 +35,7 @@ internal static class CheckSoundService
|
||||
|
||||
public static void Play(bool isChecked)
|
||||
{
|
||||
if (Suppressed) return;
|
||||
var cfg = AppConfig.Load();
|
||||
if (isChecked) { if (cfg.EnableCheckboxOnCue) checkSound?.Play(); }
|
||||
else { if (cfg.EnableCheckboxOffCue) uncheckSound?.Play(); }
|
||||
|
||||
@@ -172,6 +172,8 @@ internal static class CommandLine
|
||||
Console.WriteLine(" logs and sounds, instead of the usual location. Lets a test");
|
||||
Console.WriteLine(" exercise RemSound without touching your real settings.");
|
||||
Console.WriteLine(" Works with any command (e.g. --selftest --config-dir ...).");
|
||||
Console.WriteLine(" --silent Play no cue sounds and show no missing-sound pop-ups for");
|
||||
Console.WriteLine(" this run - for automated / unattended launches.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Examples:");
|
||||
Console.WriteLine(" RemSound.exe --devices");
|
||||
|
||||
@@ -20,12 +20,22 @@ namespace RemSound.App;
|
||||
/// </summary>
|
||||
internal sealed class CuePlayer : IDisposable
|
||||
{
|
||||
/// <summary>The "this is a silent / automated launch" flag, set once at startup by the
|
||||
/// <c>--silent</c> flag. Primarily an app-wide mute for every cue sound (startup, connect,
|
||||
/// checkbox, tab-switch, send/receive, previews — everything that plays through a CuePlayer).
|
||||
/// It ALSO doubles as the signal to suppress the unattended startup pop-ups that would otherwise
|
||||
/// ding at and bother whoever's at the screen during a throwaway test launch — the missing-sound
|
||||
/// warning and the Realtek-ASIO-detected warning both check it. So a <c>--silent</c> launch is
|
||||
/// completely quiet: no cue audio, no warning dings, no dialogs. Off in normal use.</summary>
|
||||
public static bool GloballyMuted { get; set; }
|
||||
|
||||
private readonly string filePath;
|
||||
|
||||
public CuePlayer(string filePath) => this.filePath = filePath;
|
||||
|
||||
public void Play()
|
||||
{
|
||||
if (GloballyMuted) return;
|
||||
var path = filePath;
|
||||
Task.Run(() =>
|
||||
{
|
||||
|
||||
@@ -74,6 +74,17 @@ internal sealed class QuietTabControl : TabControl
|
||||
protected override AccessibleObject CreateAccessibilityInstance()
|
||||
=> new QuietAcc(this);
|
||||
|
||||
protected override void OnSelectedIndexChanged(EventArgs e)
|
||||
{
|
||||
base.OnSelectedIndexChanged(e);
|
||||
// Audible tab-switch cue, app-wide (every QuietTabControl gets it for free). Gated on
|
||||
// ContainsFocus so a genuine user switch - arrow keys with the strip focused, or Ctrl+Tab
|
||||
// with focus on a control inside the active page - clicks, while the programmatic
|
||||
// SelectedIndex set done while a window is being built or its last tab restored (nothing
|
||||
// here focused yet) stays silent.
|
||||
if (ContainsFocus) TabSwitchSoundService.Play();
|
||||
}
|
||||
|
||||
private sealed class QuietAcc : ControlAccessibleObject
|
||||
{
|
||||
public QuietAcc(Control owner) : base(owner) { }
|
||||
|
||||
@@ -271,6 +271,15 @@ public sealed class MainForm : Form
|
||||
private bool suppressConnectedCheck;
|
||||
private bool suppressDiscoveredCheck;
|
||||
private bool suppressRememberedCheck;
|
||||
/// <summary>True while ANY checkable list is being (un)checked programmatically — a device-list
|
||||
/// refresh (<see cref="suppressDeviceCheckChange"/>) or a rebuild of one of the three peer lists
|
||||
/// (each fires ItemCheck for every pre-checked row it adds). The tick/untick CUE must stay silent
|
||||
/// for all of these, not just device-list changes: on startup the connected-peers list is focused
|
||||
/// (see the Shown handler's FocusListControl call), so a saved-peer reconnect rebuilding that list
|
||||
/// would otherwise click a checkbox sound at launch. Only a genuine user toggle — no flag set —
|
||||
/// should click.</summary>
|
||||
private bool SuppressingCheckSounds =>
|
||||
suppressDeviceCheckChange || suppressConnectedCheck || suppressDiscoveredCheck || suppressRememberedCheck;
|
||||
private string lastConnectedListSignature = string.Empty;
|
||||
private string lastDiscoveredListSignature = string.Empty;
|
||||
private string lastRememberedListSignature = string.Empty;
|
||||
@@ -460,6 +469,7 @@ public sealed class MainForm : Form
|
||||
// are gone too.
|
||||
private long prevDiagConceal;
|
||||
private long prevDiagShortRead;
|
||||
private long prevDiagDeviceGulp;
|
||||
private long prevDiagTrimFires;
|
||||
// Wire-level packet-sequence tracking deltas. Detects packet reordering, loss, or
|
||||
// duplication on the UDP path between sender and receiver. On a healthy LAN all three
|
||||
@@ -1282,7 +1292,9 @@ public sealed class MainForm : Form
|
||||
// launch RemSound, find an update, and stay running for less than the timer
|
||||
// interval would otherwise miss the release entirely. Default on. The
|
||||
// background-poll path handles both silent install and the user-prompt flow.
|
||||
if (startupCfg.CheckForUpdatesOnStartup)
|
||||
// Skipped on a --silent (automated/throwaway) launch: a test instance must never pop an
|
||||
// "update available" prompt or, worse, silently download/install + restart mid-test.
|
||||
if (startupCfg.CheckForUpdatesOnStartup && !CuePlayer.GloballyMuted)
|
||||
{
|
||||
// Defer a few seconds so the network stack, audio engine, and any device
|
||||
// hot-swap has settled before we touch GitHub. The visible cue (silent-
|
||||
@@ -4013,7 +4025,8 @@ public sealed class MainForm : Form
|
||||
// Log the detector's verdict every startup so a silent-mic session is no longer ambiguous —
|
||||
// we can see whether RemSound thought Windows was blocking the mic, not just whether it warned.
|
||||
logFile.Event($"mic-privacy: windows-blocks-desktop-mic={blocked} wasapiMicTicked={anyWasapiMicChecked}");
|
||||
if (blocked && anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
// A --silent (automated/throwaway) launch logs the verdict but never pops the warning dialog.
|
||||
if (blocked && anyWasapiMicChecked && !CuePlayer.GloballyMuted) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -4024,6 +4037,10 @@ public sealed class MainForm : Form
|
||||
/// </summary>
|
||||
private void MaybeWarnAboutRealtekAsio()
|
||||
{
|
||||
// A --silent (automated/throwaway) launch must not pop this warning - its TaskDialog plays
|
||||
// the Windows warning ding even when nobody can see the dialog (it's on a minimized test
|
||||
// instance that's then auto-closed). The decision belongs to a real user at a real launch.
|
||||
if (CuePlayer.GloballyMuted) return;
|
||||
if (realtekAsioDriverNames.Count == 0) return;
|
||||
var cfg = AppConfig.Load();
|
||||
var changed = false;
|
||||
@@ -5383,6 +5400,11 @@ public sealed class MainForm : Form
|
||||
var shortReadNow = receiver.ShortReadFires;
|
||||
var concealDelta = concealNow - prevDiagConceal; prevDiagConceal = concealNow;
|
||||
var shortReadDelta = shortReadNow - prevDiagShortRead; prevDiagShortRead = shortReadNow;
|
||||
// Device-gulp short-reads this second — the inaudible, on-target partial reads the
|
||||
// cause-aware auto-tune ignores. High devGulpΔ alongside a near-zero concealΔ is the
|
||||
// onboard-Realtek chunky-render-callback fingerprint we built the split to catch.
|
||||
var deviceGulpNow = receiver.DeviceGulpUnderruns;
|
||||
var deviceGulpDeltaDiag = deviceGulpNow - prevDiagDeviceGulp; prevDiagDeviceGulp = deviceGulpNow;
|
||||
var trimDelta = trimFires - prevDiagTrimFires; prevDiagTrimFires = trimFires;
|
||||
// Live state — current LP-filtered drift error. Negative = buffer running below
|
||||
// target on average; positive = above.
|
||||
@@ -5510,7 +5532,7 @@ public sealed class MainForm : Form
|
||||
$"privMB={selfMeter.PrivateBytesMb:0.0} gcHeapMB={selfMeter.GcHeapMb:0.0} gcFragMB={selfMeter.GcFragmentedMb:0.0} gcCommitMB={selfMeter.GcCommittedMb:0.0} handles={selfMeter.HandleCount} threads={selfMeter.ThreadCount} " +
|
||||
$"captureMs={captureMs:0.0} sendMs={sendMs:0.0} recvMs={recvMs:0.0} renderMs={renderMs:0.0} " +
|
||||
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} devGulpΔ={deviceGulpDeltaDiag} " +
|
||||
$"filtErr={filteredErrorFrames:0.0}f " +
|
||||
$"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
@@ -5736,6 +5758,11 @@ public sealed class MainForm : Form
|
||||
if (pendingProfile is null) return;
|
||||
var p = pendingProfile;
|
||||
applyingProfile = true;
|
||||
// Silence the generic checkbox tick/untick for the whole bulk apply. The per-box Focused
|
||||
// gate isn't enough on launch: the box we tick in code is often the focused control, so it
|
||||
// would click. This + the applyingProfile guard on the send/receive cue keep a profile load
|
||||
// down to just the startup and (real) connect cues.
|
||||
CheckSoundService.Suppressed = true;
|
||||
try
|
||||
{
|
||||
// Volume first — affects what's audible during the rest of this method.
|
||||
@@ -5778,6 +5805,7 @@ public sealed class MainForm : Form
|
||||
// the original profile forever.
|
||||
pendingProfile = null;
|
||||
applyingProfile = false;
|
||||
CheckSoundService.Suppressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6182,10 +6210,10 @@ public sealed class MainForm : Form
|
||||
HandleCapabilityChange();
|
||||
MarkProfileDirty();
|
||||
// Audible feedback for the toggle, whether the user clicked the checkbox or pressed the
|
||||
// mute hotkey (the hotkey flips .Checked, which routes through here too). Suppressed during
|
||||
// profile load by the suppressStreamingPasswordGate guard above, so loading a profile that
|
||||
// has send/receive on doesn't blast the cues.
|
||||
PlayStreamToggleCue(box);
|
||||
// mute hotkey (the hotkey flips .Checked, which routes through here too). Skipped while a
|
||||
// profile is being applied programmatically — loading a profile that has send/receive on
|
||||
// shouldn't blast the cues; only a genuine user toggle should.
|
||||
if (!applyingProfile) PlayStreamToggleCue(box);
|
||||
}
|
||||
|
||||
/// <summary>Play the send/receive turned-on / turned-off cue for a streaming checkbox toggle.
|
||||
@@ -6519,6 +6547,9 @@ public sealed class MainForm : Form
|
||||
// Played on every checkbox tick/untick across the whole app (CheckSoundService).
|
||||
public const string CheckboxOn = "checkbox-on";
|
||||
public const string CheckboxOff = "checkbox-off";
|
||||
// Played whenever the user switches tabs anywhere in the app (TabSwitchSoundService,
|
||||
// fired from QuietTabControl). The shipped WAVs are "tab switch 1.wav" etc.
|
||||
public const string TabSwitch = "tab-switch";
|
||||
}
|
||||
|
||||
/// <summary>Load one cue sound. Resolution order:
|
||||
@@ -6596,8 +6627,10 @@ public sealed class MainForm : Form
|
||||
TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound);
|
||||
TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound);
|
||||
TryLoadCueSound(CueId.Show, "maximise.wav", out showSound);
|
||||
// The app-wide checkbox tick/untick sounds live in their own service; keep them in step.
|
||||
// The app-wide checkbox tick/untick and tab-switch sounds live in their own services; keep
|
||||
// them in step.
|
||||
CheckSoundService.Reload();
|
||||
TabSwitchSoundService.Reload();
|
||||
// After a reload (e.g. the user changed a cue in Preferences), warn about any cue that's
|
||||
// switched on but whose sound file is missing. Skipped during construction (no window yet);
|
||||
// OnShown does the first-launch pass.
|
||||
@@ -6653,6 +6686,8 @@ public sealed class MainForm : Form
|
||||
{
|
||||
if (!reportedMissingCues.Add(name)) return;
|
||||
logFile.Event($"cue sound '{name}': enabled but file missing — cue turned off, informing the user");
|
||||
// A --silent (automated) launch turns the cue off quietly and never pops a dialog at the user.
|
||||
if (CuePlayer.GloballyMuted) return;
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
try { RestoreFromTray(); } catch { /* surfacing is best-effort */ }
|
||||
@@ -7131,13 +7166,13 @@ public sealed class MainForm : Form
|
||||
if (continuousTuneEnabled && receiver.HasSessionsForRoute(RenderRoute.WasapiLane))
|
||||
{
|
||||
TickRoute(RenderRoute.WasapiLane, maxLatencyBox, "WASAPI",
|
||||
ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking,
|
||||
ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking,
|
||||
lastUserSliderMoveUtc, intervalSec, frameMs.Value);
|
||||
}
|
||||
if (settings.LoadContinuousAutoTuneAsioEnabled() && receiver.HasSessionsForRoute(RenderRoute.AsioLane))
|
||||
{
|
||||
TickRoute(RenderRoute.AsioLane, maxLatencyAsioBox, "ASIO",
|
||||
ref lastObservedUnderrunCountAsio, ref suppressUserAsioSliderMoveTracking,
|
||||
ref lastObservedUnderrunCountAsio, ref lastObservedDeviceGulpCountAsio, ref suppressUserAsioSliderMoveTracking,
|
||||
lastUserAsioSliderMoveUtc, intervalSec, frameMs.Value);
|
||||
}
|
||||
}
|
||||
@@ -7146,7 +7181,7 @@ public sealed class MainForm : Form
|
||||
if (continuousTuneEnabled)
|
||||
{
|
||||
TickRoute(RenderRoute.Mixed, maxLatencyBox, "",
|
||||
ref lastObservedUnderrunCount, ref suppressUserSliderMoveTracking,
|
||||
ref lastObservedUnderrunCount, ref lastObservedDeviceGulpCount, ref suppressUserSliderMoveTracking,
|
||||
lastUserSliderMoveUtc, intervalSec, frameMs.Value);
|
||||
}
|
||||
}
|
||||
@@ -7159,6 +7194,12 @@ public sealed class MainForm : Form
|
||||
// a heap-allocated state object on the hot path.
|
||||
private long lastObservedUnderrunCountAsio;
|
||||
private bool suppressUserAsioSliderMoveTracking;
|
||||
// Per-route "device-gulp underruns at last tick" — the inaudible, more-buffer-won't-fix
|
||||
// partial short-reads the cause-aware skip gate deliberately ignores. Tracked only so the
|
||||
// auto-tune log can show how many were ignored; shared between Mixed and the WASAPI lane the
|
||||
// same way lastObservedUnderrunCount is.
|
||||
private long lastObservedDeviceGulpCount;
|
||||
private long lastObservedDeviceGulpCountAsio;
|
||||
|
||||
/// <summary>
|
||||
/// Per-route auto-tune tick body. Same algorithm as the pre-2026-05-11 single-route
|
||||
@@ -7175,6 +7216,7 @@ public sealed class MainForm : Form
|
||||
NumericUpDown slider,
|
||||
string routeLabel,
|
||||
ref long lastObservedUnderruns,
|
||||
ref long lastObservedDeviceGulps,
|
||||
ref bool suppressFlag,
|
||||
DateTime lastUserMoveUtc,
|
||||
int intervalSec,
|
||||
@@ -7195,20 +7237,31 @@ public sealed class MainForm : Form
|
||||
// Defer to user's manual change — wait at least one tick interval before overriding.
|
||||
if (DateTime.UtcNow - lastUserMoveUtc < TimeSpan.FromSeconds(intervalSec)) return;
|
||||
|
||||
// Per-route underrun delta. The receiver tracks underruns per session, so summing
|
||||
// only over sessions tagged with this route gives a route-local distress signal.
|
||||
var currentUnderruns = route == RenderRoute.Mixed ? receiver.Underruns : receiver.UnderrunsFor(route);
|
||||
// Per-route underrun delta — but the CAUSE-AWARE kind (2026-06-13). We gate on
|
||||
// tune-blocking underruns only: the full-empty / producer-starved short-reads that
|
||||
// genuinely mean "the buffer is too thin". A steady trickle of inaudible device-gulp
|
||||
// partials — a chunky onboard-Realtek render callback asking for an oversized block on an
|
||||
// otherwise on-target ring — is deliberately NOT counted here, so it can no longer pin the
|
||||
// target high forever by making every tick skip. The recommendation below still folds in
|
||||
// the render-callback gap, so even when we're free to lower we can never lower below what
|
||||
// the device structurally needs; it just settles to that floor instead of overshooting up.
|
||||
var currentUnderruns = route == RenderRoute.Mixed ? receiver.TuneBlockingUnderruns : receiver.TuneBlockingUnderrunsFor(route);
|
||||
var underrunDelta = currentUnderruns - lastObservedUnderruns;
|
||||
lastObservedUnderruns = currentUnderruns;
|
||||
// Device-gulp delta is tracked for the diagnostic trail only — it never gates.
|
||||
var currentDeviceGulps = route == RenderRoute.Mixed ? receiver.DeviceGulpUnderruns : receiver.DeviceGulpUnderrunsFor(route);
|
||||
var deviceGulpDelta = currentDeviceGulps - lastObservedDeviceGulps;
|
||||
lastObservedDeviceGulps = currentDeviceGulps;
|
||||
if (underrunDelta > 0)
|
||||
{
|
||||
// Route label slots into the message body when present, omitted entirely in classic
|
||||
// modes so the legacy "continuous auto-tune: skipping (N new underruns...)" wording
|
||||
// is preserved bit-for-bit. The trailing-space + colon ordering is what gave the
|
||||
// pre-fix line its weird "continuous auto-tune : skipping" formatting when the
|
||||
// label was empty.
|
||||
// label was empty. devGulp shows how many inaudible device-gulp partials were ignored
|
||||
// this tick — a high devGulp with a small underrunDelta is the Realtek fingerprint.
|
||||
var prefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
|
||||
logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick)");
|
||||
logFile.Event($"{prefix}: skipping ({underrunDelta} new underruns since last tick, devGulp={deviceGulpDelta} ignored)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7271,7 +7324,7 @@ public sealed class MainForm : Form
|
||||
suppressFlag = false;
|
||||
}
|
||||
var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
|
||||
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
|
||||
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms devGulp={deviceGulpDelta}");
|
||||
}
|
||||
|
||||
// UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button.
|
||||
@@ -7281,12 +7334,16 @@ public sealed class MainForm : Form
|
||||
|
||||
private void WireCheckedListAccessibility(CheckedListBox list, Label statusLabel, string itemKind)
|
||||
{
|
||||
// Tick/untick sound for the inputs/outputs lists. Gated on the list being focused so a real
|
||||
// user click/spacebar clicks, but the bulk programmatic (un)checking done on profile load or
|
||||
// by "uncheck all" (focus is on the button, not the list) stays silent.
|
||||
// Tick/untick sound for the inputs/outputs AND peer lists. Gated on the list being focused so
|
||||
// a real user click/spacebar clicks, but EVERY programmatic (un)check stays silent — via the
|
||||
// list-focus gate, CheckSoundService.Suppressed during profile apply, and SuppressingCheckSounds
|
||||
// which covers both the device-list mutations and the three peer-list rebuilds (each rebuild
|
||||
// fires ItemCheck for its pre-checked rows). Without the peer-list half, a saved-peer reconnect
|
||||
// rebuilding the focused connected-peers list at startup would click a checkbox sound. Only a
|
||||
// genuine user toggle (no suppression flag set) should click.
|
||||
list.ItemCheck += (_, e) =>
|
||||
{
|
||||
if (list.Focused) CheckSoundService.Play(e.NewValue == CheckState.Checked);
|
||||
if (list.Focused && !SuppressingCheckSounds) CheckSoundService.Play(e.NewValue == CheckState.Checked);
|
||||
};
|
||||
list.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
|
||||
@@ -190,6 +190,11 @@ internal sealed class PreferencesDialog : Form
|
||||
c => c.EnableCheckboxOnCue, (c, v) => c.EnableCheckboxOnCue = v),
|
||||
MachineRow("Checkbox unticked sound", MainForm.CueId.CheckboxOff, "uncheck.wav",
|
||||
c => c.EnableCheckboxOffCue, (c, v) => c.EnableCheckboxOffCue = v),
|
||||
// Played whenever the user switches tabs anywhere in the app (TabSwitchSoundService).
|
||||
// Display name is Ed's "switch tabs"; the shipped files are "tab switch 1.wav" etc, so
|
||||
// the base filename here is "tab switch.wav" for variant discovery to match.
|
||||
MachineRow("Switch tabs sound", MainForm.CueId.TabSwitch, "tab switch.wav",
|
||||
c => c.EnableTabSwitchCue, (c, v) => c.EnableTabSwitchCue = v),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -330,6 +335,10 @@ internal sealed class PreferencesDialog : Form
|
||||
DialogResult = DialogResult.OK,
|
||||
};
|
||||
|
||||
// The four-tab strip. Held as a field (not a constructor local) so OnShown can land focus
|
||||
// on it when the dialog opens — see the OnShown override for why that's needed for NVDA.
|
||||
private readonly QuietTabControl tabs = new() { Dock = DockStyle.Fill, TabIndex = 0, TabStop = true };
|
||||
|
||||
/// <summary>True if the user toggled Mute cues or Accept remote during this dialog
|
||||
/// session. The owner uses this to know whether to MarkProfileDirty after the dialog
|
||||
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
|
||||
@@ -355,6 +364,10 @@ internal sealed class PreferencesDialog : Form
|
||||
cueRows = BuildCueRows(settings);
|
||||
|
||||
Text = "Preferences";
|
||||
// Explicitly a dialog so the spoken title is clean and screen readers treat it as a dialog
|
||||
// (ShowDialog already exposes UIA IsDialog on .NET 7+; this is harmless reinforcement).
|
||||
AccessibleRole = AccessibleRole.Dialog;
|
||||
AccessibleName = "Preferences";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
@@ -638,8 +651,8 @@ internal sealed class PreferencesDialog : Form
|
||||
startupListPanel.Controls.Add(startupProfileList);
|
||||
|
||||
// Four tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
|
||||
// strip switch tabs; the active page's controls are the next tab stops.
|
||||
var tabs = new QuietTabControl { Dock = DockStyle.Fill, TabIndex = 0, TabStop = true };
|
||||
// strip switch tabs; the active page's controls are the next tab stops. The control itself
|
||||
// is a field (declared above) so OnShown can focus it when the dialog opens.
|
||||
tabs.TabPages.Add(MakeTab("General",
|
||||
browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, loggingBox, writeLogsNowButton));
|
||||
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
|
||||
@@ -695,6 +708,52 @@ internal sealed class PreferencesDialog : Form
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>When the dialog opens, land focus on the first real, NAMED leaf control inside the
|
||||
/// active tab page so NVDA announces the dialog and that control. Never the tab strip: the tab
|
||||
/// control is a <see cref="QuietTabControl"/> whose own accessible object is deliberately
|
||||
/// role-less and nameless (so NVDA reads the tab item, not a redundant "tab control"), and
|
||||
/// focusing THAT on open gave NVDA nothing to announce — which is exactly why Preferences opened
|
||||
/// silent until you moved. Focusing a named leaf (the first General-tab control) gives NVDA
|
||||
/// something to speak, and because ShowDialog exposes the form as a dialog (UIA IsDialog, .NET 7+)
|
||||
/// it then reads the whole dialog.
|
||||
///
|
||||
/// Three load-bearing details, confirmed against the dotnet/winforms + NVDA issue trackers and
|
||||
/// matching the pattern Andre's Sensor Readout uses (it focuses a real list/textbox in Shown):
|
||||
/// * Deferred via BeginInvoke so it runs after the dialog's accessibility tree is live.
|
||||
/// * ActiveControl=null FIRST, so leaf.Focus() is a genuine focus CHANGE and actually raises the
|
||||
/// focus event (without the transition WinForms can treat focus as unchanged and stay silent).
|
||||
/// * NotifyFocus re-fires the MSAA focus event as belt-and-braces.
|
||||
/// Ctrl+Tab still switches tabs from inside the page.</summary>
|
||||
protected override void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
BeginInvoke(new Action(() =>
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (tabs.TabCount > 0) tabs.SelectedIndex = 0;
|
||||
var leaf = FirstTabStopLeaf(tabs.SelectedTab) ?? (Control)tabs;
|
||||
ActiveControl = null;
|
||||
leaf.Focus();
|
||||
if (leaf.IsHandleCreated) WinEventNotifier.NotifyFocus(leaf);
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>The first visible, enabled, tab-stop control inside <paramref name="container"/>,
|
||||
/// searched depth-first in child order (which matches the order controls were added to each tab).
|
||||
/// Returns a real leaf the dialog can focus on open so NVDA has a named control to announce —
|
||||
/// never a layout panel or the role-less tab strip.</summary>
|
||||
private static Control? FirstTabStopLeaf(Control? container)
|
||||
{
|
||||
if (container is null) return null;
|
||||
foreach (Control c in container.Controls)
|
||||
{
|
||||
if (c is { CanSelect: true, TabStop: true, Visible: true, Enabled: true })
|
||||
return c;
|
||||
if (FirstTabStopLeaf(c) is { } nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Wire up the Startup behaviour tab (moved here from StartupBehaviourDialog): load the
|
||||
/// current state, populate the profile list, and persist each change immediately to AppConfig /
|
||||
/// the Windows auto-start registry entry, exactly as the old dialog did.</summary>
|
||||
|
||||
+43
-31
@@ -35,6 +35,16 @@ internal static class Program
|
||||
AppConfig.SetUserDataDirectoryOverride(configDir);
|
||||
}
|
||||
|
||||
// --silent: make this launch play no cue sounds at all (startup, connect, checkbox,
|
||||
// tab-switch, ...) and skip the front-most "missing sound file" warning. The automated test
|
||||
// harness passes it so its throwaway launches stay completely quiet instead of chiming a
|
||||
// startup cue (or popping a dialog) onto whoever happens to be at the screen. Set up front,
|
||||
// before consolidation or the startup cue can fire.
|
||||
if (Array.Exists(args, a => string.Equals(a, "--silent", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
CuePlayer.GloballyMuted = true;
|
||||
}
|
||||
|
||||
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
|
||||
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
|
||||
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
|
||||
@@ -48,8 +58,11 @@ internal static class Program
|
||||
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
|
||||
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
|
||||
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
|
||||
// Move the cue sounds into that folder too — seeded from the shipped defaults (see method).
|
||||
ConsolidateSounds();
|
||||
// Cue sounds no longer live in a sounds\ folder at all (the shipped defaults are install-side
|
||||
// in AppConfig.SoundsDirectory = "default sounds\" now, so an update always refreshes them).
|
||||
// Delete BOTH defunct old sounds folders an upgrader might still have, whatever version they
|
||||
// came from. Best-effort + idempotent.
|
||||
RemoveLegacySoundFolders();
|
||||
|
||||
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
|
||||
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
|
||||
@@ -137,14 +150,18 @@ internal static class Program
|
||||
KeyClickService.Initialize(AppConfig.Load().EnableKeyboardClicks);
|
||||
Application.ApplicationExit += (_, _) => KeyClickService.Shutdown();
|
||||
|
||||
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService). Loaded here; reloaded
|
||||
// by MainForm.ReloadAllCueSounds whenever cue settings change in Preferences.
|
||||
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService) and the tab-switch
|
||||
// cue (TabSwitchSoundService). Loaded here; reloaded by MainForm.ReloadAllCueSounds whenever
|
||||
// cue settings change in Preferences.
|
||||
CheckSoundService.Reload();
|
||||
TabSwitchSoundService.Reload();
|
||||
|
||||
// One-time "your settings moved" notice — only the launch that actually relocated files
|
||||
// shows it (idempotent migration ⇒ MovedAnything is false on every later launch). Shown
|
||||
// here, after the guard and before the profile picker, so the user reads it once up front.
|
||||
if (layoutMigration.MovedAnything)
|
||||
// Skipped on a --silent (automated/throwaway) launch: its TaskDialog dings at and pops over
|
||||
// whoever's at the screen during a test, and a throwaway instance needn't announce a move.
|
||||
if (layoutMigration.MovedAnything && !CuePlayer.GloballyMuted)
|
||||
{
|
||||
ShowLayoutMigrationNotice();
|
||||
}
|
||||
@@ -405,39 +422,34 @@ internal static class Program
|
||||
catch { /* never let cleanup disturb startup */ }
|
||||
}
|
||||
|
||||
/// <summary>Consolidate the cue WAVs into the per-user sounds folder. The release ships the
|
||||
/// default cues in <c><exe>\sounds\</c>; this copies any cue MISSING from the per-user
|
||||
/// <c>...\user settings and logs\sounds\</c> across (so a fresh install, or a release that adds a
|
||||
/// new cue, gets seeded) WITHOUT overwriting one already there (so the user's own cue files
|
||||
/// survive), then removes the shipped folder to keep the install root tidy. The app reads cues
|
||||
/// only from the per-user folder, which the updater leaves untouched — so a user's custom cue
|
||||
/// WAVs are no longer clobbered by an update. Best-effort + idempotent. 2026-06-10.</summary>
|
||||
private static void ConsolidateSounds()
|
||||
/// <summary>Delete the two defunct old cue-sounds folders an upgrader might still have on disk,
|
||||
/// whichever version they came from. Sounds now live install-side in
|
||||
/// <see cref="AppConfig.SoundsDirectory"/> (<c><exe>\default sounds\</c>), which updates
|
||||
/// always refresh; both old locations are dead and only cause confusion / stale reads if left:
|
||||
/// * <c><exe>\sounds\</c> — the install-side folder cue WAVs lived in from ~v3.1 to v3.4.
|
||||
/// A user jumping STRAIGHT from that era to this version never ran the v3.5 consolidation that
|
||||
/// used to move-and-delete it, so it can still be sitting there.
|
||||
/// * <c>...\user settings and logs\sounds\</c> — the per-user folder cues lived in from v3.5 to
|
||||
/// v3.9.1, with a never-overwrite seed that meant a changed default could never reach an
|
||||
/// existing user (the whole reason for the 2026-06-13 move).
|
||||
/// Best-effort + idempotent — a no-op once they're gone. The user's REAL custom sounds were never
|
||||
/// in either folder (they're explicit Browse-picked file paths elsewhere), so nothing is lost.</summary>
|
||||
private static void RemoveLegacySoundFolders()
|
||||
{
|
||||
try
|
||||
foreach (var legacy in new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "sounds"), // ~v3.1–v3.4 install-side
|
||||
AppConfig.LegacyUserSoundsDirectory, // v3.5–v3.9.1 per-user
|
||||
})
|
||||
{
|
||||
var userSounds = AppConfig.SoundsDirectory;
|
||||
Directory.CreateDirectory(userSounds);
|
||||
var shippedSounds = Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||
if (!Directory.Exists(shippedSounds)) return;
|
||||
foreach (var src in Directory.GetFiles(shippedSounds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = Path.Combine(userSounds, Path.GetFileName(src));
|
||||
if (!File.Exists(dest)) File.Copy(src, dest);
|
||||
}
|
||||
catch { /* one unreadable cue mustn't stop the rest */ }
|
||||
}
|
||||
try { Directory.Delete(shippedSounds, recursive: true); }
|
||||
catch { /* leave it if locked — the app reads the per-user copy anyway */ }
|
||||
try { if (Directory.Exists(legacy)) Directory.Delete(legacy, recursive: true); }
|
||||
catch { /* leave it if locked / unreadable — it's just unused clutter now */ }
|
||||
}
|
||||
catch { /* never let cue consolidation disturb startup */ }
|
||||
}
|
||||
|
||||
/// <summary>Play the startup cue once if the machine-wide setting is on. Resolves the WAV the
|
||||
/// same way the in-app cues do — a user-set custom path (machine-wide, in <see cref="AppConfig"/>)
|
||||
/// if it exists on disk, otherwise the bundled <c>sounds\start up.wav</c>. Read straight from
|
||||
/// if it exists on disk, otherwise the bundled <c>default sounds\start up.wav</c>. Read straight from
|
||||
/// AppConfig because no profile (and therefore no settings store) is loaded yet at this point
|
||||
/// in startup. Best-effort: a cue must never stop RemSound from starting.</summary>
|
||||
private static void PlayStartupCueIfEnabled()
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>3.9</Version>
|
||||
<Version>4.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -57,17 +57,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- All cue sounds live in a sounds\ subfolder under the build/publish output (moved out of the
|
||||
install-root flat layout 2026-05-28). Every WAV in the source sounds\ folder ships, via a
|
||||
wildcard, so the numbered cue variants ("connect 1.wav", "connect 2.wav", ...), the
|
||||
keyboard-click sounds ("key 1.wav".."key 4.wav") and the password "passkey.wav" are all
|
||||
carried without per-file edits, and adding more sounds later needs no csproj change.
|
||||
Filenames containing a space are preserved verbatim (via %(Filename)%(Extension)) so the
|
||||
load-by-filename path in CueSounds / KeyClickService finds them exactly as written. The
|
||||
EnsureCueSoundsPublished target below is the belt-and-braces that guarantees these reach a
|
||||
PUBLISH output even when MSBuild's incremental Content-copy marker would skip them. -->
|
||||
<Content Include="..\..\sounds\*.wav">
|
||||
<Link>sounds\%(Filename)%(Extension)</Link>
|
||||
<!-- Shipped DEFAULT cue sounds live in a "default sounds\" subfolder under the build/publish
|
||||
output (2026-06-13: moved out of the per-user folder, which never overwrote them, so a
|
||||
changed default sound could never reach an existing user). They are part of the install:
|
||||
the auto-updater (and a dev republish) always overwrites this folder, so a tweaked default
|
||||
always lands. The user's OWN custom sounds are NOT here - they're explicit file paths set
|
||||
via the Preferences Browse picker, which the updater never touches. Every WAV in the source
|
||||
"default sounds\" folder ships via a wildcard (numbered variants, key clicks, passkey), so
|
||||
adding sounds later needs no csproj change. Spaces in filenames are preserved verbatim. The
|
||||
EnsureCueSoundsPublished target below guarantees they reach a PUBLISH output even when
|
||||
MSBuild's incremental Content-copy marker would skip them. -->
|
||||
<Content Include="..\..\default sounds\*.wav">
|
||||
<Link>default sounds\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
@@ -80,20 +81,20 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The sounds\ Content items above use
|
||||
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The Content items above use
|
||||
CopyToOutputDirectory=PreserveNewest, but MSBuild's incremental "copy already done" marker
|
||||
(obj\...\.csproj.CopyComplete) can skip that copy when publishing into a fresh output folder
|
||||
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all
|
||||
(startup sound + connect/disconnect/record/etc.). This explicit post-publish copy is marker-
|
||||
independent: it always copies every WAV from the source sounds\ folder into the published
|
||||
sounds\ folder. Build-release.ps1 zips the publish output, so this is what makes the release
|
||||
reliably contain the sounds. (The self-test "Bundled resources present" step verifies it.) -->
|
||||
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all.
|
||||
This explicit post-publish copy is marker-independent: it always copies every WAV from the
|
||||
source "default sounds\" folder into the published "default sounds\" folder. Build-release.ps1
|
||||
zips the publish output, so this is what makes the release reliably contain the sounds. (The
|
||||
self-test "Bundled resources present" step verifies it.) -->
|
||||
<Target Name="EnsureCueSoundsPublished" AfterTargets="Publish">
|
||||
<ItemGroup>
|
||||
<_CueWavs Include="..\..\sounds\*.wav" />
|
||||
<_CueWavs Include="..\..\default sounds\*.wav" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_CueWavs)"
|
||||
DestinationFolder="$(PublishDir)sounds"
|
||||
DestinationFolder="$(PublishDir)default sounds"
|
||||
SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -295,12 +295,11 @@ internal static class SelfTest
|
||||
var root = AppContext.BaseDirectory;
|
||||
Check(File.Exists(Path.Combine(root, "readme.html")), "readme.html (the F1 manual) must ship next to the exe");
|
||||
|
||||
// Cues are consolidated from the shipped sounds\ folder into the runtime sounds folder at
|
||||
// startup (Program.ConsolidateSounds), so by the time the self-test runs they live here.
|
||||
// An empty runtime folder means the shipped build had no sounds to seed from - exactly the
|
||||
// bug that shipped the v3.9 zip with no cue sounds.
|
||||
// The shipped DEFAULT cues live install-side in "default sounds\" next to the exe
|
||||
// (AppConfig.SoundsDirectory). An empty/absent folder means the shipped build had no sounds -
|
||||
// exactly the bug that shipped the v3.9 zip with no cue sounds.
|
||||
var soundsDir = AppConfig.SoundsDirectory;
|
||||
Check(Directory.Exists(soundsDir), "the runtime sounds folder must exist (cues are consolidated at startup)");
|
||||
Check(Directory.Exists(soundsDir), "the shipped 'default sounds' folder must exist next to the exe");
|
||||
// Cues ship as numbered variants ("connect 1.wav", ...); each required cue must have at
|
||||
// least one variant present.
|
||||
foreach (var cue in new[]
|
||||
@@ -311,7 +310,7 @@ internal static class SelfTest
|
||||
})
|
||||
{
|
||||
Check(CueSounds.Variants(cue).Count > 0,
|
||||
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped sounds\\ folder empty?)");
|
||||
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped 'default sounds' folder empty?)");
|
||||
}
|
||||
// Keyboard-click typing sounds + the password passkey sound.
|
||||
Check(File.Exists(Path.Combine(soundsDir, "key 1.wav")), "keyboard-click sound 'key 1.wav' must be present");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Plays a short cue whenever the user switches between tabs anywhere in RemSound - the main
|
||||
/// window's tab strip and every tabbed dialog (Preferences, Recording settings, ...). Fired from
|
||||
/// <see cref="QuietTabControl"/> on a selection change, gated on the tab control actually containing
|
||||
/// focus, so a genuine user switch (arrow keys / Ctrl+Tab) clicks but the programmatic selection
|
||||
/// done while building or restoring a window stays silent.
|
||||
///
|
||||
/// Machine-wide cue (<see cref="AppConfig.EnableTabSwitchCue"/>), shipped as numbered variants
|
||||
/// ("tab switch 1.wav", ...) and configured in Preferences exactly like the other cues. Default on.
|
||||
/// </summary>
|
||||
internal static class TabSwitchSoundService
|
||||
{
|
||||
private static CuePlayer? switchSound;
|
||||
|
||||
/// <summary>When true, <see cref="Play"/> is a no-op. Reserved for bulk programmatic tab changes
|
||||
/// the focus gate doesn't already cover; mirrors <see cref="CheckSoundService.Suppressed"/>.</summary>
|
||||
public static bool Suppressed { get; set; }
|
||||
|
||||
/// <summary>(Re)load the cue from the current cue configuration. Call at startup and whenever cue
|
||||
/// settings change, alongside <see cref="CheckSoundService.Reload"/>.</summary>
|
||||
public static void Reload()
|
||||
{
|
||||
switchSound = LoadCue(MainForm.CueId.TabSwitch, "tab switch.wav", AppConfig.Load());
|
||||
}
|
||||
|
||||
public static void Play()
|
||||
{
|
||||
if (Suppressed) return;
|
||||
if (AppConfig.Load().EnableTabSwitchCue) switchSound?.Play();
|
||||
}
|
||||
|
||||
private static CuePlayer? LoadCue(string cueId, string defaultFile, AppConfig cfg)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? path = cfg.MachineCueCustomPaths.TryGetValue(cueId, out var custom) && File.Exists(custom)
|
||||
? custom
|
||||
: CueSounds.ResolveDefaultPath(cueId, defaultFile, cfg);
|
||||
return path is not null && File.Exists(path) ? new CuePlayer(path) : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user