diff --git a/server/VERSION b/server/VERSION index 53a63ce..a5e37d0 100644 --- a/server/VERSION +++ b/server/VERSION @@ -1 +1 @@ -server-v2.3 +server-v2.4 diff --git a/server/remsound-relay-update.sh b/server/remsound-relay-update.sh index 0dba8a3..1b3ff34 100644 --- a/server/remsound-relay-update.sh +++ b/server/remsound-relay-update.sh @@ -88,41 +88,46 @@ read_current_version() { printf '%s' "${TAG_PREFIX}v0" } -# Parse a tag like "server-v2.10" into a comparable numeric form. -# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable. -tag_to_version() { - local tag="$1" - # strip the prefix - tag="${tag#"$TAG_PREFIX"}" - # strip a leading "v" if present - tag="${tag#v}" - local major minor - major="${tag%%.*}" - minor="${tag#*.}" - # if there's no dot, minor==major. Treat as MAJOR.0. - if [[ "$minor" == "$tag" ]]; then - minor="0" - fi - # keep only digits — survive things like "v2.0-rc1" by ignoring the suffix. - major="${major//[^0-9]/}" - minor="${minor//[^0-9]/}" - : "${major:=0}" - : "${minor:=0}" - printf '%s.%s' "$major" "$minor" -} +# Compare two release tags by their dotted numeric version, parsed the SAME way as the Python +# release selector in get_latest_release() — split on every '.', keep the digits of each +# component, then pad to equal length before comparing. Delegated to python3 (already a hard +# dependency of this script) so the upgrade GATE and the release SELECTOR can never disagree, and +# so multi-component tags (server-v2.3.1), pre-release suffixes (server-v2.3-rc1) and missing +# components are all handled correctly. +# +# This replaces a bash-only parser that collapsed everything after the FIRST dot into the "minor" +# field and then stripped the dot — so server-v2.3.1 read as "2.31" and was wrongly judged NEWER +# than server-v2.3. The moment any patch-style tag existed, the hourly update check would STOP and +# RESTART the live relay (a real multi-second outage for every connected client), and it could even +# "upgrade" to an OLDER build (server-v2.9.1 -> "2.91" > server-v2.10 -> "2.10"). 2026-06-12. -# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise. +# Returns 0 if $1 is a strictly newer version than $2, 1 otherwise (equal counts as NOT newer). tag_newer_than() { - local left right lv rv - left="$(tag_to_version "$1")" - right="$(tag_to_version "$2")" - # Numeric compare major then minor. - lv="${left%.*}"; rv="${right%.*}" - if (( lv > rv )); then return 0; fi - if (( lv < rv )); then return 1; fi - lv="${left#*.}"; rv="${right#*.}" - if (( lv > rv )); then return 0; fi - return 1 + TAG_PREFIX="$TAG_PREFIX" python3 - "$1" "$2" <<'PY' +import os, sys + +prefix = os.environ.get("TAG_PREFIX", "server-") + +def parse(tag): + if tag.startswith(prefix): + tag = tag[len(prefix):] + if tag.startswith("v"): + tag = tag[1:] + out = [] + for part in tag.split("."): + digits = "".join(c for c in part if c.isdigit()) + out.append(int(digits) if digits else 0) + return out + +left = parse(sys.argv[1]) +right = parse(sys.argv[2]) +# Pad to equal length so 2.3 and 2.3.0 compare EQUAL — a shorter tuple must not read as older, +# or a re-tagged same-version release would trigger a needless stop/restart of the relay. +n = max(len(left), len(right)) +left += [0] * (n - len(left)) +right += [0] * (n - len(right)) +sys.exit(0 if left > right else 1) +PY } # -------- GitHub releases query --------------------------------------------- diff --git a/server/remsound-relay.py b/server/remsound-relay.py index 796d0f9..6e77ef2 100644 --- a/server/remsound-relay.py +++ b/server/remsound-relay.py @@ -362,6 +362,12 @@ class Relay: return now = time.monotonic() entry = self.v2_clients.get(client_id) + # Capture whether this packet came from the endpoint this client_id is CURRENTLY registered + # at, BEFORE the NAT-rebind update below overwrites entry.addr. Used to reject spoofed + # control packets: the roster broadcast ships every member's client_id to all members, so on + # an internet-facing relay anyone who joins learns the others' ids and could otherwise forge + # a BYE to evict them. A genuine BYE always comes from the client's own registered endpoint. + from_registered_endpoint = entry is not None and entry.addr == addr if entry is None: # Admit attempt. if len(self.v2_clients) >= self.max_clients: @@ -398,6 +404,14 @@ class Relay: self.v2_roster_dirty = True return if pkt_type == TYPE_LOBBY_BYE: + # Only the endpoint a client is registered at may say goodbye for it — otherwise a + # forged BYE bearing a known client_id (learned from the roster) could evict any peer. + if not from_registered_endpoint: + self.log.warning( + "event=bye_rejected reason=endpoint_mismatch client_id=%s from=%s", + client_id, _fmt_addr(addr), + ) + return self.v2_clients.pop(client_id, None) self.log.info( "event=client_left client_id=%s addr=%s reason=bye", @@ -526,16 +540,28 @@ def main() -> int: ready, _, _ = select.select([sock], [], [], SOCKET_POLL_TIMEOUT_SECONDS) except InterruptedError: continue + except OSError as e: + # select() itself failed (e.g. a transient resource-pressure error on a long- + # running, low-RAM host). Log and pause briefly rather than spin or exit. + log.warning("event=select_failed err=%s", e) + time.sleep(0.1) + continue now = time.monotonic() - if ready: - try: + # Per-iteration work, fully guarded. A relay that must stay up for DAYS — and that is + # reachable from the open internet — can never let a single packet or a housekeeping + # tick crash the whole process: that would drop EVERY connected client and force a ~5s + # systemd restart. Anything unexpected is logged (with a traceback) and we carry on. + try: + if ready: data, addr = sock.recvfrom(RECV_BUFFER_BYTES) - except OSError as e: - log.warning("event=recv_failed err=%s", e) - continue - relay.handle_packet(data, addr) - relay.tick(now) - relay.maybe_log_stats(now) + relay.handle_packet(data, addr) + relay.tick(now) + relay.maybe_log_stats(now) + except OSError as e: + # recvfrom, or a sendto that escaped its own guard — transient; keep serving. + log.warning("event=io_error err=%s", e) + except Exception: + log.exception("event=loop_error — recovered, continuing") finally: log.info("event=shutdown") sock.close() diff --git a/sounds/start up.wav b/sounds/start up.wav new file mode 100644 index 0000000..66e3854 Binary files /dev/null and b/sounds/start up.wav differ diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs index 841a003..9c38bf3 100644 --- a/src/RemSound.App/AboutDialog.cs +++ b/src/RemSound.App/AboutDialog.cs @@ -20,6 +20,21 @@ internal sealed class AboutDialog : Form /// updates" path. private const string ReleaseNotes = """ + RemSound v3.9 + + Listening for a long time no longer slowly builds up + delay. On the standard (non-ASIO) path the incoming audio + buffer used to creep deeper after a network hiccup and + never settle back, so a connection that started tight + could feel laggy by morning. It now eases itself back to + your chosen latency, gently and silently, so a long + session stays as tight as it began. + + RemSound's log now also records whether your microphone + audio is actually leaving the machine, alongside how loud + it is — so a "my mic isn't getting through" report can be + pinned down from the log instead of guessed at. + RemSound v3.8 You can now start a brand-new profile at any time. A diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 22ef227..5f0c88e 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -3626,7 +3626,15 @@ public sealed class MainForm : Form if (!connected) return; var all = allSendEndpoints; - // Endpoints the heartbeat currently considers long-dead, keyed by "ip:port". + // Decide which selected peers to actually TRANSMIT audio to. We arm only peers that are + // genuinely reachable — anyone whose heartbeat has been Unreachable for longer than + // AudioPruneUnreachableAfter is dropped from the send set, so RemSound never streams audio + // into a dead address. The heartbeat keeps pinging EVERY selected peer regardless (it runs + // off SetTrackedPeers, not this set), so the instant a peer comes back it is re-armed. + // + // Carve-out: a peer we are actively RECEIVING audio from stays armed even if its heartbeat + // reads Unreachable — that covers an asymmetric path where audio flows but the heartbeat + // round-trip doesn't, so a working stream is never cut. HashSet? dead = null; if (all.Length > 0 && heartbeatService is { } hb) { @@ -3634,7 +3642,8 @@ public sealed class MainForm : Form { if (ph.State == PeerHealthState.Unreachable && ph.AgeOfLastPong is { } age - && age > AudioPruneUnreachableAfter) + && age > AudioPruneUnreachableAfter + && !receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, TimeSpan.FromSeconds(3))) { (dead ??= new HashSet(StringComparer.OrdinalIgnoreCase)) .Add($"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}"); @@ -3646,17 +3655,24 @@ public sealed class MainForm : Form ? all : all.Where(ep => !dead.Contains($"{ep.Address}:{ep.Port}")).ToArray(); - // Safety net: never silence EVERY peer through pruning. If the whole set looks dead (a - // total network drop), keep sending to all — the wasted SendTo per dead endpoint is - // cheaper than masking a global outage or missing the recovery. - if (armed.Length == 0) armed = all; + // There used to be a "never silence EVERY peer" safety net here that re-armed the whole + // set when pruning would leave nobody. Removed 2026-06-12: when NO peer is reachable we + // must send NOTHING, not blast audio at every dead address. Otherwise someone connected to + // a single peer that goes offline keeps uploading the full stream into the void — exactly + // a-singer's issue #8 ("Not connected to any peer ... sending 51.1 kB/s ... sent 2116.5 MB" + // after the only receiver was switched off hours earlier). The heartbeat still probes all + // peers, so the moment one answers again it is re-armed and audio resumes on its own. var signature = string.Join("|", armed.Select(ep => $"{ep.Address}:{ep.Port}").OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); if (signature == activeAudioReceiverSignature) return; activeAudioReceiverSignature = signature; sender.SetReceivers(armed); var pruned = all.Length - armed.Length; - if (pruned > 0) + if (armed.Length == 0) + { + logFile.Event($"audio receivers updated: 0 active — no reachable peer, not sending (heartbeat still probing {all.Length})"); + } + else if (pruned > 0) { logFile.Event($"audio receivers updated: {armed.Length} active, {pruned} pruned (unreachable >{AudioPruneUnreachableAfter.TotalSeconds:0}s); heartbeat still probing all"); } @@ -5443,6 +5459,7 @@ public sealed class MainForm : Form var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer(); var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB; var capPeak = sender.TakeMaxSenderPreEncodePeak(); + var sndAudFr = sender.TakeSenderAudioFramesSent(); var clippedNow = sender.ClippedSampleCount; var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow; var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer(); @@ -5478,7 +5495,7 @@ public sealed class MainForm : Form $"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " + $"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " + $"filtErr={filteredErrorFrames:0.0}f " + - $"capPeak={capPeak:0.000} 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} " + + $"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} " + $"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " + $"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " + @@ -5539,8 +5556,15 @@ public sealed class MainForm : Form var selfMeter = processSelfMeter.Take(); var captureMs = sender.TakeCaptureWorkMs(); var sendMs = sender.TakeSendWorkMs(); + // capPeak = loudest sample reaching the encoder; sndAudFrΔ = audio frames that + // actually left the socket this second. Together they split "mic captured but + // nothing sent" (drop at encode/encrypt) from "mic captured and sent" — the + // measurement the "mic only works in ASIO" report needs, now on the talker side too. + var capPeak = sender.TakeMaxSenderPreEncodePeak(); + var sndAudFr = sender.TakeSenderAudioFramesSent(); logFile.Event( $"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " + + $"capPeak={capPeak:0.000} sndAudFrΔ={sndAudFr} " + $"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " + $"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " + $"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " + @@ -6430,6 +6454,10 @@ public sealed class MainForm : Form public const string ProfileSwitch = "profile-switch"; public const string ProfileMenuOpen = "profile-menu-open"; public const string Update = "update"; + // Startup cue is special: it plays from Program.cs before any profile loads, so its + // enable flag and custom-path live machine-wide in AppConfig, not the per-profile + // settings store. This id is still used by the Preferences cue list for display/keying. + public const string Startup = "startup"; } /// Load one cue sound. Resolution order: diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 8feba9a..d4ff887 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -77,36 +77,68 @@ internal sealed class PreferencesDialog : Form Padding = new Padding(6, 2, 6, 2), }; - /// Describes one cue. ends up in the listbox row; - /// is the well-known key from ; the - /// LoadEnabled / SaveEnabled pair routes the checkbox state to the right - /// getter/setter so we don't need a hard-coded - /// switch on index. + /// 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 + /// delegates close over the right backing store so the handlers don't need to know whether + /// a row is per-profile () or machine-wide + /// ( — the Startup cue, which fires before any profile loads). + /// tells the handlers whether toggling the row should flag + /// a pending profile save; machine-wide rows persist immediately and never do. private sealed record CueRowDescriptor( string DisplayName, string CueId, - Func LoadEnabled, - Action SaveEnabled); + string DefaultFileName, + bool IsProfileSetting, + Func LoadEnabled, + Action SaveEnabled, + Func LoadCustomPath, + Action SaveCustomPath); - private static readonly CueRowDescriptor[] CueRows = - [ - new("Connect sound", MainForm.CueId.Connect, - s => s.LoadEnableConnectCue(), (s, v) => s.SaveEnableConnectCue(v)), - new("Disconnect sound", MainForm.CueId.Disconnect, - s => s.LoadEnableDisconnectCue(), (s, v) => s.SaveEnableDisconnectCue(v)), - new("Recording start sound", MainForm.CueId.RecordStart, - s => s.LoadEnableRecordStartCue(), (s, v) => s.SaveEnableRecordStartCue(v)), - new("Recording stop sound", MainForm.CueId.RecordStop, - s => s.LoadEnableRecordStopCue(), (s, v) => s.SaveEnableRecordStopCue(v)), - new("Profile saved sound", MainForm.CueId.Save, - s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)), - new("Profile switched sound", MainForm.CueId.ProfileSwitch, - s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)), - new("Profile menu open sound", MainForm.CueId.ProfileMenuOpen, - s => s.LoadEnableProfileMenuOpenCue(), (s, v) => s.SaveEnableProfileMenuOpenCue(v)), - new("Update sound", MainForm.CueId.Update, - s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)), - ]; + // Built per-dialog (not static) so the per-profile rows can close over the live `settings` + // store while the Startup row closes over machine-wide AppConfig. Order = listbox order. + private readonly CueRowDescriptor[] cueRows; + + private static CueRowDescriptor[] BuildCueRows(RemSoundSettingsStore settings) + { + CueRowDescriptor ProfileRow(string name, string id, string file, + Func load, Action save) => + new(name, id, file, true, + () => load(settings), v => save(settings, v), + () => settings.LoadCustomCuePath(id), p => settings.SaveCustomCuePath(id, p)); + + return + [ + ProfileRow("Connect sound", MainForm.CueId.Connect, "connect.wav", + s => s.LoadEnableConnectCue(), (s, v) => s.SaveEnableConnectCue(v)), + ProfileRow("Disconnect sound", MainForm.CueId.Disconnect, "disconnect.wav", + s => s.LoadEnableDisconnectCue(), (s, v) => s.SaveEnableDisconnectCue(v)), + ProfileRow("Recording start sound", MainForm.CueId.RecordStart, "record start.wav", + s => s.LoadEnableRecordStartCue(), (s, v) => s.SaveEnableRecordStartCue(v)), + ProfileRow("Recording stop sound", MainForm.CueId.RecordStop, "record stop.wav", + s => s.LoadEnableRecordStopCue(), (s, v) => s.SaveEnableRecordStopCue(v)), + ProfileRow("Profile saved sound", MainForm.CueId.Save, "save.wav", + s => s.LoadEnableSaveCue(), (s, v) => s.SaveEnableSaveCue(v)), + ProfileRow("Profile switched sound", MainForm.CueId.ProfileSwitch, "profile.wav", + s => s.LoadEnableProfileSwitchCue(), (s, v) => s.SaveEnableProfileSwitchCue(v)), + ProfileRow("Profile menu open sound", MainForm.CueId.ProfileMenuOpen, "profile menu open.wav", + s => s.LoadEnableProfileMenuOpenCue(), (s, v) => s.SaveEnableProfileMenuOpenCue(v)), + ProfileRow("Update sound", MainForm.CueId.Update, "update.wav", + s => s.LoadEnableUpdateCue(), (s, v) => s.SaveEnableUpdateCue(v)), + // Startup cue — machine-wide (AppConfig), because it plays before a profile is loaded. + // Persists immediately on change and never flags a profile save (IsProfileSetting=false). + new("Startup sound", MainForm.CueId.Startup, "start up.wav", false, + () => AppConfig.Load().EnableStartupCue, + v => { var c = AppConfig.Load(); c.EnableStartupCue = v; TrySaveConfig(c); }, + () => AppConfig.Load().StartupCueCustomPath, + p => { var c = AppConfig.Load(); c.StartupCueCustomPath = p; TrySaveConfig(c); }), + ]; + } + + private static void TrySaveConfig(AppConfig cfg) + { + try { cfg.Save(); } catch { /* harmless — the choice just won't survive a restart */ } + } private readonly AccessibleCheckBox acceptRemoteVolumeBox = new() { @@ -227,6 +259,7 @@ internal sealed class PreferencesDialog : Form Action unsubscribeUpnpStatusChanged) { this.getUpnpSnapshot = getUpnpSnapshot; + cueRows = BuildCueRows(settings); Text = "Preferences"; FormBorderStyle = FormBorderStyle.FixedDialog; @@ -270,13 +303,13 @@ internal sealed class PreferencesDialog : Form "Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information); }; - // Populate the cue listbox — order matches the CueRows array, and the index of a - // selected row maps 1:1 to a CueRowDescriptor. Each row's ticked state is loaded - // from the active profile's per-cue enable flag via the descriptor. + // Populate the cue listbox — order matches the cueRows array, and the index of a + // selected row maps 1:1 to a CueRowDescriptor. Each row's ticked state is loaded via + // the descriptor (per-profile cues from the settings store; the Startup cue from AppConfig). cueList.Items.Clear(); - foreach (var c in CueRows) + foreach (var c in cueRows) { - cueList.Items.Add(c.DisplayName, c.LoadEnabled(settings)); + cueList.Items.Add(c.DisplayName, c.LoadEnabled()); } if (cueList.Items.Count > 0) cueList.SelectedIndex = 0; cueList.ItemCheck += (_, e) => @@ -284,28 +317,31 @@ internal sealed class PreferencesDialog : Form // ItemCheck fires BEFORE the visual state actually flips; e.NewValue is what // it's about to become, so the persisted value matches what the user just // clicked. - if (e.Index < 0 || e.Index >= CueRows.Length) return; + if (e.Index < 0 || e.Index >= cueRows.Length) return; var nowEnabled = e.NewValue == CheckState.Checked; - CueRows[e.Index].SaveEnabled(settings, nowEnabled); - ChangedAnyProfileSetting = true; + var row = cueRows[e.Index]; + row.SaveEnabled(nowEnabled); + // Machine-wide rows (the Startup cue) persist immediately and aren't part of the + // profile, so they must not arm the "save profile?" prompt on the way out. + if (row.IsProfileSetting) ChangedAnyProfileSetting = true; }; // 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(settings); - RefreshCueActionButtons(settings); + cueList.SelectedIndexChanged += (_, _) => RefreshCueActionButtons(); + RefreshCueActionButtons(); playSelectedCueButton.Click += (_, _) => { - if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return; - OnPlayClicked(CueRows[cueList.SelectedIndex], settings); + if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return; + OnPlayClicked(cueRows[cueList.SelectedIndex]); }; browseSelectedCueButton.Click += (_, _) => { - if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return; - OnBrowseClicked(browseSelectedCueButton, CueRows[cueList.SelectedIndex], settings); - RefreshCueActionButtons(settings); + if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return; + OnBrowseClicked(browseSelectedCueButton, cueRows[cueList.SelectedIndex]); + RefreshCueActionButtons(); }; // Right-click "Use default sound" context menu lives on the Browse button. It acts @@ -315,26 +351,26 @@ internal sealed class PreferencesDialog : Form var useDefaultItem = new ToolStripMenuItem("Use default sound"); useDefaultItem.Click += (_, _) => { - if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) return; - var cue = CueRows[cueList.SelectedIndex]; - if (settings.LoadCustomCuePath(cue.CueId) is not null) + if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) return; + var cue = cueRows[cueList.SelectedIndex]; + if (cue.LoadCustomPath() is not null) { - settings.SaveCustomCuePath(cue.CueId, null); - ChangedAnyProfileSetting = true; - RefreshCueActionButtons(settings); + cue.SaveCustomPath(null); + if (cue.IsProfileSetting) ChangedAnyProfileSetting = true; + RefreshCueActionButtons(); } }; browseCtx.Opening += (_, _) => { - if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= CueRows.Length) + if (cueList.SelectedIndex < 0 || cueList.SelectedIndex >= cueRows.Length) { useDefaultItem.Enabled = false; useDefaultItem.Text = "Use default sound"; } else { - var cue = CueRows[cueList.SelectedIndex]; - useDefaultItem.Enabled = settings.LoadCustomCuePath(cue.CueId) is not null; + var cue = cueRows[cueList.SelectedIndex]; + useDefaultItem.Enabled = cue.LoadCustomPath() is not null; useDefaultItem.Text = $"Use default {cue.DisplayName.ToLowerInvariant()}"; useDefaultItem.AccessibleName = useDefaultItem.Text; } @@ -557,10 +593,10 @@ internal sealed class PreferencesDialog : Form /// based on whether a custom path is set). When the selection is empty — e.g. the /// listbox briefly clears during a profile reload — both buttons get a generic label /// and are disabled so a stray click can't act on a stale index. - private void RefreshCueActionButtons(RemSoundSettingsStore settings) + private void RefreshCueActionButtons() { var idx = cueList.SelectedIndex; - if (idx < 0 || idx >= CueRows.Length) + if (idx < 0 || idx >= cueRows.Length) { playSelectedCueButton.Text = "&Play selected sound"; playSelectedCueButton.AccessibleName = "Play selected sound"; @@ -571,12 +607,12 @@ internal sealed class PreferencesDialog : Form return; } - var cue = CueRows[idx]; + var cue = cueRows[idx]; playSelectedCueButton.Enabled = true; playSelectedCueButton.Text = $"&Play {cue.DisplayName.ToLowerInvariant()}"; playSelectedCueButton.AccessibleName = $"Play {cue.DisplayName.ToLowerInvariant()}"; - var customPath = settings.LoadCustomCuePath(cue.CueId); + var customPath = cue.LoadCustomPath(); browseSelectedCueButton.Enabled = true; if (string.IsNullOrWhiteSpace(customPath)) { @@ -599,30 +635,16 @@ internal sealed class PreferencesDialog : Form /// preview exactly what the cue would play if it fired now. Reads through the settings /// cache so we see whatever the user has changed in this dialog session, including /// custom paths not yet persisted to the profile JSON. - private static string? ResolveCueFilePath(CueRowDescriptor cue, RemSoundSettingsStore settings) + private static string? ResolveCueFilePath(CueRowDescriptor cue) { - var customPath = settings.LoadCustomCuePath(cue.CueId); + var customPath = cue.LoadCustomPath(); if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath)) { return customPath; } - // Default WAV filename is built from the cue ID — same convention as MainForm. - // The dictionary kept here makes the mapping explicit and lets us pretty-print - // "record start" / "record stop" with the space rather than the cue ID's hyphen. - var defaultFileName = cue.CueId switch - { - MainForm.CueId.Connect => "connect.wav", - MainForm.CueId.Disconnect => "disconnect.wav", - MainForm.CueId.RecordStart => "record start.wav", - MainForm.CueId.RecordStop => "record stop.wav", - MainForm.CueId.Save => "save.wav", - MainForm.CueId.ProfileSwitch => "profile.wav", - MainForm.CueId.ProfileMenuOpen => "profile menu open.wav", - MainForm.CueId.Update => "update.wav", - _ => null, - }; - if (defaultFileName is null) return null; - var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName); + // 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; } @@ -632,9 +654,9 @@ internal sealed class PreferencesDialog : Form /// resolves — e.g. a cue without a default WAV and no custom path — show a small popup /// so the user knows why nothing happened, rather than silently doing nothing and /// leaving them wondering whether the Play button worked. - private void OnPlayClicked(CueRowDescriptor cue, RemSoundSettingsStore settings) + private void OnPlayClicked(CueRowDescriptor cue) { - var path = ResolveCueFilePath(cue, settings); + var path = ResolveCueFilePath(cue); if (path is null) { MessageBox.Show(this, @@ -665,10 +687,10 @@ internal sealed class PreferencesDialog : Form /// replaces the default WAV). Writes through the settings cache, since custom cue paths /// are per-profile — clearing here also flips ChangedAnyProfileSetting so the save-prompt /// fires on the way out. - private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings) + private void OnBrowseClicked(Button btn, CueRowDescriptor cue) { var soundsFolder = AppConfig.SoundsDirectory; - var existing = settings.LoadCustomCuePath(cue.CueId); + var existing = cue.LoadCustomPath(); var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing) ? Path.GetDirectoryName(existing) ?? soundsFolder : soundsFolder; @@ -691,17 +713,18 @@ internal sealed class PreferencesDialog : Form // user on a specific shipped-default file across updates. if (pickedFullPath.StartsWith(soundsFolderFullPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { - settings.SaveCustomCuePath(cue.CueId, null); + cue.SaveCustomPath(null); } else { - settings.SaveCustomCuePath(cue.CueId, pickedFullPath); + cue.SaveCustomPath(pickedFullPath); } - ChangedAnyProfileSetting = true; + // The Startup cue is machine-wide, not part of the profile — don't arm the save prompt. + if (cue.IsProfileSetting) ChangedAnyProfileSetting = true; // Refresh the visible action-button labels so the "(custom)" tag appears or // disappears right away. Belt-and-braces: the caller also refreshes, but doing it // here makes the function self-consistent. - RefreshCueActionButtons(settings); + RefreshCueActionButtons(); } /// Pull the latest UPnP snapshot and update the inline status label. Always diff --git a/src/RemSound.App/Program.cs b/src/RemSound.App/Program.cs index ea21293..fafc9ed 100644 --- a/src/RemSound.App/Program.cs +++ b/src/RemSound.App/Program.cs @@ -89,6 +89,14 @@ internal static class Program } } + // We hold the single-instance lock — THIS copy has taken over (any stuck older copy was + // force-closed just above). Play the one-shot startup cue here: after the take-over + // decision is settled and before the profile picker/load, so if a copy was already + // running you only hear it once the NEW process is in charge. The earlier "switch to the + // running copy" / "cancel" paths returned before this point, so a copy that bowed out + // never plays it. Fire-and-forget WaveOut, so it sounds even when we launch into the tray. + PlayStartupCueIfEnabled(); + // We hold the single-instance lock. Listen for a later copy asking us to surface, and // route that request to whichever main window is open at the time. instance.StartActivationListener(); @@ -369,4 +377,25 @@ internal static class Program } catch { /* never let cue consolidation disturb startup */ } } + + /// 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 ) + /// if it exists on disk, otherwise the bundled sounds\start up.wav. 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. + private static void PlayStartupCueIfEnabled() + { + try + { + var cfg = AppConfig.Load(); + if (!cfg.EnableStartupCue) return; + var custom = cfg.StartupCueCustomPath; + var path = !string.IsNullOrWhiteSpace(custom) && File.Exists(custom) + ? custom + : Path.Combine(AppConfig.SoundsDirectory, "start up.wav"); + if (!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 2f14e3e..d7856bf 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -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. --> - 3.8.0 + 3.9.1 @@ -103,6 +103,13 @@ sounds\profile menu open.wav PreserveNewest + + + sounds\start up.wav + PreserveNewest +