diff --git a/readme.html b/readme.html
index 5fd8189..cf8fee2 100644
--- a/readme.html
+++ b/readme.html
@@ -233,7 +233,7 @@ ul, ol { padding-left: 1.4em; }
| Connectivity | Connected, discovered and remembered peers. Adding a peer by address. A connection status read-out. |
| Audio inputs and outputs | The ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing (none) hides them. |
| Volume, pan and EQ for peers (optional) | Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Shown by default; untick “Show the volume, pan and EQ for peers tab” on the Appearance tab of Preferences to hide it. See Volume, pan and EQ for peers tab. |
-| Audio profile | Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group. |
+| Audio profile | Codec, packet size, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group. |
The system tray icon and its menu
@@ -480,9 +480,10 @@ ul, ol { padding-left: 1.4em; }
| Control | Shortcut | What it does |
| Audio codec | Alt+C | The codec is the method RemSound uses to package the sound before sending it. Three choices: PCM 48k 24-bit (uncompressed), Opus broadcast quality (loss tolerant), or Opus live latency (for jamming and monitoring). See codec choice. |
| Packet size | Alt+P | Standard (the default) or Small (for a local network only). Smaller packets save a couple of milliseconds of delay on the sending side, but they double how many packets are sent. |
-| Lock to audio clock | Alt+D | A timing setting on the sending side. It ties the sending of packets to the sound device's own hardware clock, which removes a little jitter (jitter means uneven packet timing). Brief clicks are possible if the connection can't keep up. The label changes depending on whether ASIO is in use, so it always describes what it does in your setup. |
+RemSound always locks its sending timing to the sound device's own hardware clock — this used to be a “Lock to audio clock” checkbox, but it is now always on, because turning it off only ever added delay. There is nothing to set.
+
Audio receive parameters
What you see in this section depends on whether an ASIO driver is chosen on the Audio inputs and outputs tab. With no ASIO driver, you see one delay setting (labelled simply “Audio latency”). With an ASIO driver chosen, you see two delay settings — one for each sound path — each with its own auto-tune toggle. The two paths are independent: a problem on one doesn't affect the other.
@@ -736,13 +737,12 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
14. Latency and audio quality
-Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:
+Latency is the small delay between sound leaving one computer and arriving at the other. Four controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:
- Audio latency in milliseconds (Alt+L) — the main target for how much sound the receiving side keeps in reserve.
- Buffer smoothness (Alt+B) — how hard the receiving side works to protect against sudden jitter.
- Packet size (Alt+P) — Standard or Small. Small packets shave a couple of milliseconds off the sending delay, but double how many packets are sent.
-- Lock to audio clock (Alt+D) — ties the timing of packets to the sound device's hardware clock, removing wobble caused by Windows.
- Continuous auto-tune — lets the receiving side choose the latency target for you, re-checking every few seconds.
@@ -792,18 +792,12 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
The saving is small — at most a few milliseconds end to end. Small packets are useful when you and your collaborator are on the same local network and want to chase every last millisecond. For any internet connection it's a false economy, because doubling how many packets are sent also doubles the chance of running into jitter at the wrong moment, which you hear as clicks.
-Lock to audio clock
+Locking to the audio clock (automatic)
-The Lock to audio clock checkbox ties RemSound's sending timing to the sound device's own hardware clock, instead of letting Windows decide the pace. It's off by default. The label tells you what it does in your particular setup:
-
-
-| Your setup | What “Lock to audio clock” does |
-| No ASIO driver chosen (WASAPI only) | The sender takes its timing from the WASAPI capture instead of from Windows' general timer. Tightens the sending delay. |
-| An ASIO driver chosen (WASAPI and ASIO both running) | Both paths tighten independently. Brief clicks are possible on either path if the connection can't keep up. |
-
+RemSound always ties its sending timing to the sound device's own hardware clock, instead of letting Windows decide the pace. This used to be a Lock to audio clock checkbox that was off by default; it is now always on and there is nothing to set, because turning it off only ever added delay. On a WASAPI-only setup the sender takes its timing from the WASAPI capture; when an ASIO driver is also in use, both paths tighten independently.
-Why you'd use it: Windows' general timer can wake the audio loop with up to about 6 ms of wobble, even at top priority. At target latencies under about 15 ms, that wobble shows up as clicks. Locking to the audio clock takes Windows' timer out of the picture — the sound device itself drives the timing.
+Why it matters: Windows' general timer can wake the audio loop with up to about 6 ms of wobble, even at top priority. At target latencies under about 15 ms, that wobble shows up as clicks. Locking to the audio clock takes Windows' timer out of the picture — the sound device itself drives the timing.
Continuous auto-tune
@@ -884,7 +878,6 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
| Alt+U | Toggle Use CPU and Windows performance settings in high priority mode (for this profile) |
| Alt+C | Focus Audio codec |
| Alt+P | Focus Packet size |
-| Alt+D | Toggle Lock to audio clock |
| Alt+L | Focus the latency control — the ASIO path when an ASIO driver is chosen, otherwise the single Audio latency control |
| Alt+T | Toggle continuous auto-tune — the ASIO path when an ASIO driver is chosen, otherwise the single Continuous auto-tune toggle |
| Alt+W | (Only when an ASIO driver is chosen.) Focus the WASAPI-path latency control |
@@ -1398,9 +1391,9 @@ RemSound.exe --connect 192.168.1.42
Setting it up
Everything lives in the Service menu on the menu bar:
-- Configure service profile… — opens a small window with three tabs (Audio send, Audio profile, Connectivity) where you choose what to send, how to send it, and who to send it to, plus a password. This is a separate profile from your normal ones and does not appear in the usual profile list. There is no “send my audio” switch because the service always sends. The Additional options button lets you turn the connect/disconnect sounds and the service's own log on or off.
-- Install service — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once.
-- Start service / Stop service — run or halt it now without waiting for a reboot. Also administrator actions.
+- Configure service profile… — opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. There is no “send my audio” switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The Additional options button lets you turn the connect/disconnect sounds and the service's own log on or off.
+- Install service — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. (When you first install RemSound on a PC, the installer also offers to set the service up for you, so you may have done this already.)
+- Start service / Stop service — run or halt it now without waiting for a reboot.
- Uninstall service — removes it entirely.
The top of the Service menu always shows the current state: not installed, installed and running, or installed and stopped.
@@ -1408,8 +1401,8 @@ RemSound.exe --connect 192.168.1.42
Good to know
- The service sends to the exact peer addresses you list in its profile (a computer on your network, or a reachable address across the internet). Automatic peer discovery and hole-punching are handled by the normal app, not the service.
-- Because the service runs even when you are not logged in, it sends from the system account — the installer arranges the firewall so its audio can get out.
-- When RemSound updates itself, the service keeps running the previous version until its next restart (or the next reboot), then picks up the new one. Nothing breaks in the meantime.
+- Because the service runs even when you are not logged in, it sends from the system account. If Windows Firewall ever prompts about RemSound, allow it so the audio can get out.
+- When RemSound updates itself, the service updates itself too, automatically — it notices the newer version, copies it in and restarts onto it on its own, with no prompt and nothing for you to do. (This happens on the service's own schedule shortly after the app updates; nothing breaks in the meantime.)
- If two computers should each stream to the other unattended, install the service on both.
@@ -1512,7 +1505,7 @@ RemSound.exe --connect 192.168.1.42
| Auto-tune | RemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with the Continuous auto-tune checkbox on the Audio profile tab. |
| Profile | A saved snapshot of every RemSound setting and choice — device ticks, send / receive states, codec, latency, peers, ASIO driver choice, the lot. (Keyboard shortcuts are the exception — they're shared across all profiles, not saved per profile.) Stored as one settings file. You pick one at startup, and can switch with File → Open profile. |
| New profile | An entry in the startup profile picker that begins a session with all the defaults — nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save. |
-| Lock to audio clock | A sending-side timing mode that takes its timing straight from the sound device's hardware clock instead of from Windows. Removes a few milliseconds of wobble at tight latency targets. Off by default. Set with the checkbox of the same name on the Audio profile tab. |
+| Lock to audio clock | A sending-side timing mode that takes its timing straight from the sound device's hardware clock instead of from Windows. Removes a few milliseconds of wobble at tight latency targets. RemSound now does this always — it used to be a checkbox on the Audio profile tab, but it is on permanently and no longer a setting. |
| Concealment | A receiving-side feature that fills brief gaps in the playback reserve with a small noise burst (the default) or an obvious click. You choose which on the Audio profile tab, in the Artefact sound type list. Opus also has its own repair of lost packets on top of this. |
| Remote control | A RemSound feature that lets one connected peer adjust another peer's listening volume (or toggle their receive mute) using global hotkeys. There are two sets of commands: one adjusts the receiver's RemSound volume slider, the other adjusts the receiver's Windows volume. Off by default on both ends; the receiver opts in via “Accept remote volume commands from peers” in the Preferences dialog (Ctrl+P), and the sender sets up hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the “I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume” case. See Remote control. |
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 08c040e..f766f5c 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -3870,10 +3870,10 @@ public sealed class MainForm : Form
sessionStartWatcher = null;
}
- /// Restores the WASAPI send mode, the "Send all applications" master toggle and the ticked
- /// app names from a loaded profile. Remembered apps that aren't running right now are seeded into the
- /// list (ticked, marked "not running") so they resume capture the moment they reappear. On Windows
- /// too old for process loopback the mode is forced back to devices.
+ /// Restores the WASAPI send mode and the ticked app names from a loaded profile (the main
+ /// window's "Send all applications" master toggle was removed 2026-07-16). Remembered apps that aren't
+ /// running right now are seeded into the list (ticked, marked "not running") so they resume capture the
+ /// moment they reappear. On Windows too old for process loopback the mode is forced back to devices.
private void RestoreSendModeFromProfile(Profile p)
{
suppressSendAppEvents = true;
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 945d4d3..e211465 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -71,6 +71,8 @@ internal static class SelfTest
RunStep(results, "Service silent-capture self-heal (issue #23 boot re-open ladder)", ServiceSilentCaptureSelfHeal);
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
+ RunStep(results, "Remembered peers migrate once (cleared list not resurrected)", RememberedPeersMigrationOnce);
+ RunStep(results, "Session-start watcher lifecycle (construct/rehook/dispose)", SessionStartWatcher);
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
@@ -1717,6 +1719,68 @@ internal static class SelfTest
finally { store.SaveRememberedApplications(original); }
}
+ /// The peers list went machine-wide (AppConfig) with a ONE-TIME migration from each old
+ /// profile's per-profile list. Regression guard for the bug where the migration re-ran every launch
+ /// and RESURRECTED peers the user had just cleared: after a clear, re-loading the same profile (whose
+ /// JSON still holds the old peers) must NOT bring them back. Touches the real AppConfig; saves/restores.
+ private static string? RememberedPeersMigrationOnce()
+ {
+ var store = new RemSoundSettingsStore("RemSound");
+ var saved = AppConfig.Load();
+ var savedPeers = saved.RememberedPeers;
+ var savedMigrated = saved.RememberedPeersMigrated;
+ try
+ {
+ // Clean slate: no global peers, migration not yet done.
+ var c0 = AppConfig.Load(); c0.RememberedPeers = new(); c0.RememberedPeersMigrated = false; c0.Save();
+
+ var p = new Profile { Title = "peers-migration-selftest" };
+ p.RememberedPeers = new List { "Alice", "Bob" };
+
+ // First load of a profile that has legacy peers migrates them and sets the one-time flag.
+ store.ApplyProfile(p);
+ var migrated = store.LoadRememberedPeers();
+ Check(migrated.Contains("Alice") && migrated.Contains("Bob"), "legacy per-profile peers must migrate into the global list");
+ Check(AppConfig.Load().RememberedPeersMigrated, "the one-time migration flag must be set after migrating");
+
+ // User clears the global peers list (the Preferences button).
+ store.SaveRememberedPeers(Array.Empty());
+ Check(store.LoadRememberedPeers().Count == 0, "clearing must empty the global peers list");
+
+ // Re-loading the SAME profile (its JSON still lists Alice/Bob) must NOT resurrect them.
+ store.ApplyProfile(p);
+ Check(store.LoadRememberedPeers().Count == 0,
+ "a cleared peers list must NOT be resurrected by re-loading a profile (migration is one-time)");
+ return "peers migrate once; a cleared list stays cleared across profile re-loads";
+ }
+ finally
+ {
+ var c = AppConfig.Load(); c.RememberedPeers = savedPeers; c.RememberedPeersMigrated = savedMigrated; c.Save();
+ }
+ }
+
+ /// The instant capture-on-app-open watcher (AudioSessionStartWatcher) must construct, re-hook
+ /// its default-device notification without throwing, and dispose idempotently — the plumbing behind
+ /// "catch a per-app send from its very start" and the service's boot session-kick. (It hooks live
+ /// WASAPI, so this proves lifecycle safety, not delivery of a real session event.)
+ private static string? SessionStartWatcher()
+ {
+ RemSound.Sender.AudioSessionStartWatcher w;
+ try { w = new RemSound.Sender.AudioSessionStartWatcher(_ => { }, _ => { }); }
+ catch (Exception ex) { return Skip($"session watcher could not construct (no audio endpoint?): {ex.GetType().Name}: {ex.Message}"); }
+ try
+ {
+ w.Rehook(); // re-point at the current default device — must never throw
+ w.Rehook();
+ return "constructed, re-hooked twice, and disposed idempotently without throwing";
+ }
+ finally
+ {
+ w.Dispose();
+ w.Dispose(); // idempotent
+ }
+ }
+
/// Pins the two-list semantics Ed specified 2026-07-16 (no send-all option): a TICKED app
/// must live in the Active list — even when it isn't running, marked "(not running)", so it can
/// always be found and unticked — and must NOT appear in Remembered; an UNTICKED remembered app
diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs
index 26ec9a4..1639955 100644
--- a/src/RemSound.App/ServiceSendHost.cs
+++ b/src/RemSound.App/ServiceSendHost.cs
@@ -137,7 +137,9 @@ public sealed class ServiceSendHost : IDisposable
// silence keepalive feeding back, which is why the peak matters.
var peak = sender.TakeMaxSenderPreEncodePeak();
if (peak > pulsePeakMax) pulsePeakMax = peak;
- if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; }
+ // Real audio proves the capture works — refund the self-heal ladder so a LATER hiccup (hours or
+ // days into an always-on stream) gets fresh re-open attempts instead of finding the budget spent.
+ if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; reopenAttempts = 0; }
pulseFramesSent += sender.TakeSenderAudioFramesSent();
// What is the DEVICE playing? The endpoint's own meter, independent of our capture stream.
@@ -256,7 +258,20 @@ public sealed class ServiceSendHost : IDisposable
{
sender.Stop();
var specs = BuildSendSpecs(profile); // re-resolve (the default device may have moved)
- if (specs.Count == 0) { log?.Invoke("service: re-open found no send sources — capture left stopped"); running = false; return; }
+ if (specs.Count == 0)
+ {
+ // The source went away (e.g. the only loopback device was unplugged). Release the whole
+ // send stack — presence, meter readers, the session watcher AND the perf-mode overrides
+ // — instead of sitting "running" with High priority / EcoQoS-off held while streaming
+ // nothing. The device-change watcher re-opens (via ApplyProfile) when a device returns.
+ log?.Invoke("service: re-open found no send sources — releasing until a device returns");
+ try { presence.Stop(); } catch { }
+ SwapMeterDevices(Array.Empty());
+ try { sessionKick?.Dispose(); } catch { } sessionKick = null;
+ try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { }
+ running = false;
+ return;
+ }
sender.Configure(specs);
sender.Start();
SwapMeterDevices(specs);
@@ -297,11 +312,16 @@ public sealed class ServiceSendHost : IDisposable
/// false (and stays stopped) if the profile has nothing to send or no reachable peers.
public bool ApplyProfile(Profile profile)
{
+ // Resolve sources + peer addresses OUTSIDE the lock. BuildEndpoints does DNS (Dns.GetHostAddresses)
+ // and BuildSendSpecs enumerates devices — either can block for seconds at boot as SYSTEM before the
+ // network/audio stack is fully up. Doing that while holding `gate` would stall Suspend() (yielding
+ // to the interactive app), the RunLoop tick and the self-heal for the whole timeout.
+ if (disposed) return false;
+ var specs = BuildSendSpecs(profile);
+ var endpoints = BuildEndpoints(profile);
lock (gate)
{
if (disposed) return false;
- var specs = BuildSendSpecs(profile);
- var endpoints = BuildEndpoints(profile);
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
@@ -536,6 +556,10 @@ public sealed class ServiceSendHost : IDisposable
}
var profile = loadProfile();
if (profile is null) return;
+ // A device hot-plug re-plumbs the audio graph much like a power resume or a fresh boot — refill the
+ // self-heal ladder so a brand-new device that comes up momentarily deaf still gets its re-opens.
+ everHeardAudio = false;
+ reopenAttempts = 0;
Suspend();
ApplyProfile(profile);
}
diff --git a/src/RemSound.App/ServiceUpdate.cs b/src/RemSound.App/ServiceUpdate.cs
index b4a09b9..34fda2d 100644
--- a/src/RemSound.App/ServiceUpdate.cs
+++ b/src/RemSound.App/ServiceUpdate.cs
@@ -17,9 +17,9 @@ namespace RemSound.App;
/// uncertainty (folder unknown, file missing mid-swap, unparseable version) means "don't act". After the
/// copy+restart the running bin == the app version, so it never re-triggers.
///
-/// Trust note: the service copies from a user-writable folder and runs it as SYSTEM — the same trust
-/// posture as the previous in-place scheme. Acceptable for this personal app; a hardened build would
-/// code-sign and verify before copying.
+/// Trust note: the service copies from a user-writable folder (the app's install location) and runs
+/// it as SYSTEM. That is a local-privilege-escalation surface — a hardened build would code-sign the app
+/// and verify the signature before copying. Accepted deliberately for this personal app.
///
internal static class ServiceUpdate
{
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 0924a36..353fa14 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -99,10 +99,15 @@ public sealed class AppConfig
/// Machine-wide remembered PEER entries — ONE shared address book across all profiles
/// (Ed, 2026-07: both remembered lists live in global, not the profile). Before this the list rode
/// in each profile's JSON, so it was per-profile in practice; each old profile's legacy list is
- /// unioned in here the first time it's opened (RemSoundSettingsStore.ApplyProfile). Null = none yet.
- /// Cleared from Preferences → General.
+ /// unioned in here ONCE (RemSoundSettingsStore.MigrateRememberedPeersToGlobal, gated by
+ /// ). Null = none yet. Cleared from Preferences → General.
public List? RememberedPeers { get; set; }
+ /// Set true after the one-time migration of a profile's legacy per-profile peers into
+ /// . Without this the migration re-ran every launch and re-unioned the
+ /// profile file's stale copy — which silently resurrected peers the user had just cleared.
+ public bool RememberedPeersMigrated { get; set; }
+
/// Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I
/// send" address book, companion to . Before 2026-07-16 this only
/// lived in the in-memory settings cache, which silently forgot the list on every app exit. Null =
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index eee527f..ec90ccb 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -81,7 +81,9 @@ public sealed class Profile
/// the whole system's default output. When false, only the apps named in
/// are sent. The MAIN window no longer has a "send all
/// applications" option (removed 2026-07-16): there, applications mode always means specific ticked
- /// apps, and whole-system audio is devices mode's job. The main window neither reads nor writes this.
+ /// apps, and whole-system audio is devices mode's job. The main window never READS this to drive
+ /// behaviour; it does still write the default true when it saves a profile (harmless — main-app and
+ /// service profiles live in separate stores and are never the same file).
public bool SendAllApplications { get; set; } = true;
/// In "applications" send mode: the process
/// names (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by
diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs
index d882c0f..280d0b3 100644
--- a/src/RemSound.Core/RemSoundSettingsStore.cs
+++ b/src/RemSound.Core/RemSoundSettingsStore.cs
@@ -588,22 +588,26 @@ public sealed class RemSoundSettingsStore
MigrateRememberedPeersToGlobal(profile);
}
- /// Migration for the peers list going machine-wide (2026-07-16): profiles written by older
- /// builds carry their own remembered-peers list, so the first time each one is opened its entries
- /// are UNIONED into the AppConfig book — nothing is lost, nothing is overwritten. Once the sets
- /// match this is a no-op (no file write).
+ /// ONE-TIME migration for the peers list going machine-wide (2026-07-16): profiles written by
+ /// older builds carry their own remembered-peers list, so the first opened profile that has peers has
+ /// them UNIONED into the AppConfig book (nothing lost, nothing overwritten), and a marker
+ /// () then stops it re-running — otherwise a cleared
+ /// list would be resurrected from the profile file on the next launch.
private static void MigrateRememberedPeersToGlobal(Profile profile)
{
- if (profile.RememberedPeers is not { Count: > 0 } legacy) return;
try
{
var c = AppConfig.Load();
+ // ONE-TIME only. Re-running every launch re-unioned the profile file's stale copy, which
+ // resurrected peers the user had just cleared in Preferences (the global store was emptied
+ // but the profile JSON still held them). The marker stops that.
+ if (c.RememberedPeersMigrated) return;
+ if (profile.RememberedPeers is not { Count: > 0 } legacy) return; // nothing to migrate yet; try again with a profile that has peers
var current = c.RememberedPeers ?? [];
- var merged = current
+ c.RememberedPeers = current
.Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
- if (merged.Count == current.Count) return; // nothing new — skip the write
- c.RememberedPeers = merged;
+ c.RememberedPeersMigrated = true;
c.Save();
}
catch { /* best-effort, like the app's other AppConfig writes */ }
diff --git a/src/RemSound.Sender/AudioSessionStartWatcher.cs b/src/RemSound.Sender/AudioSessionStartWatcher.cs
index 6ab8ac8..0208ebf 100644
--- a/src/RemSound.Sender/AudioSessionStartWatcher.cs
+++ b/src/RemSound.Sender/AudioSessionStartWatcher.cs
@@ -73,6 +73,9 @@ public sealed class AudioSessionStartWatcher : IDisposable
private void UnhookLocked()
{
try { if (manager is not null) manager.OnSessionCreated -= HandleSessionCreated; } catch { }
+ // Dispose the session manager too, not just the device — it holds its own WASAPI COM state, and
+ // Rehook() runs on every default-device change, so leaking it here is a slow WASAPI handle drip.
+ try { (manager as IDisposable)?.Dispose(); } catch { }
manager = null;
try { device?.Dispose(); } catch { }
device = null;
diff --git a/src/RemSound.Sender/PushModeWasapiBackend.cs b/src/RemSound.Sender/PushModeWasapiBackend.cs
index b2ea04e..e74432d 100644
--- a/src/RemSound.Sender/PushModeWasapiBackend.cs
+++ b/src/RemSound.Sender/PushModeWasapiBackend.cs
@@ -210,10 +210,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
}
catch (Exception ex)
{
+ // A device-open failure (device disabled/unplugged between enumeration and GetDevice/
+ // Initialize) must NOT propagate: it used to be rethrown, and because CompositeCaptureBackend
+ // and AudioSender don't wrap the engine's Start, it could crash the whole app during device
+ // churn. Match MixingEngine/AsioCaptureBackend — log, stay stopped, let the caller carry on
+ // (the device-change watcher / capture self-heal re-open when a good device appears).
lastError = ex.Message;
- onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
+ onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message} — staying stopped (will re-open when the device is available)");
StopInternal();
- throw;
}
}
}