Files
RemSound/src/RemSound.App/CueSounds.cs
T
EdnunpandClaude Opus 4.8 2fb9274a95 v5.2: stability and polish — deep-audit bug fixes + install-flow fixes
Verified findings from a multi-dimension code audit, plus the two install-flow bugs:
- Fix Opus encoder use-after-free on a codec/rate change while streaming (guard swap vs encode).
- Fix "both" single-file recording dropping audio + drifting (drain both directions in lockstep).
- Fix broken clip counter, UPnP teardown on exit, auto-update-restart foreground grant, and a
  malformed-Opus-format packet orphaning a playout session forever.
- Post-install relaunch now respects start-minimised; uninstall is path-aware so it won't clear a
  different copy's run-at-startup.
- Perf/hygiene: cache AppConfig off UI hot paths, fold per-peer EQ+gain into one pass, deterministic
  disposal (tray menu, timers, COM shortcut, Process handles, process meter), ring-buffer overflow
  guard, receiver session-lock fix, remote-control allow-list moved onto the UI thread.
- Remove dead code (two IsAsioBackend, SessionPlayout.Reset, IsSameEndpoint, RemSoundUpdater
  IDisposable); several stale-doc fixes.

Deferred (not in this release): drift-estimator tweak, peer-discovery pruning, uninstall retry-loop,
encryption nonce. Wire format unchanged (interops v3.3-v5.1). Version -> 5.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:54:53 +01:00

100 lines
5.0 KiB
C#

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>(); }
var ordered = matches.OrderBy(m => m.Order).ToList();
// If numbered variants exist, drop the bare unnumbered file: it labels as "Sound 1" (below), the
// same as "<cue> 1.wav", so the two would show as indistinguishable "Sound 1" rows a screen-reader
// user can't tell apart. The bare name is a legacy single-file fallback; numbered is what ships.
if (ordered.Exists(m => m.Order >= 1) && ordered.Exists(m => m.Order == 0))
ordered.RemoveAll(m => m.Order == 0);
return ordered.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);
}
}