diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index c8f512f..4828723 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -545,6 +545,14 @@ public sealed class MainForm : Form // (NVDA users typically press a few keys in quick succession to scan through items), // short enough that a deliberate selection feels responsive. Auto-stop on Tick. private readonly System.Windows.Forms.Timer asioDriverChangeDebounce = new() { Interval = 300 }; + + // Session memory of which ASIO channel pairs were ticked, PER DRIVER NAME (send + receive). + // Ticks are deliberately cleared on a driver swap — pair N is a different physical channel on a + // different card, so raw ticks must never survive the swap (see the clear in the debounce handler). + // But clearing alone meant switching AWAY and BACK left you silent until you re-ticked by hand + // (Ed, 2026-07-26: EVO → ReaRoute → EVO = no audio). This map restores each driver's OWN ticks + // when you return to it, so audio resumes by itself — safety and convenience both. + private readonly Dictionary asioTicksByDriver = new(StringComparer.OrdinalIgnoreCase); private string sendOutputDevicesSignature = string.Empty; private string sendInputDevicesSignature = string.Empty; private string receiveOutputDevicesSignature = string.Empty; @@ -1045,8 +1053,17 @@ public sealed class MainForm : Form // driver is loaded; pair 2 of the Audient is a different physical channel from // pair 2 of the Komplete. If we let the old ticks survive a driver swap, the // wrong channels would be captured/rendered until the user noticed and re-ticked. + // Before clearing, remember the OUTGOING driver's ticks so returning to it can + // restore them (see asioTicksByDriver) — per-driver memory keeps the safety + // property while making "switch away and back" resume audio on its own. if (driverActuallyChanged) { + if (!string.IsNullOrWhiteSpace(previousDriver)) + { + asioTicksByDriver[previousDriver!] = ( + SnapshotAsioTicks(asioSendDevicesList), + SnapshotAsioTicks(asioReceiveOutputDevicesList)); + } try { suppressDeviceCheckChange = true; @@ -1063,6 +1080,25 @@ public sealed class MainForm : Form UpdateBothIndependentVisibility(); ApplyContinuousTuneTimer(); ApplyAsioMode(); + + // Returning to a driver we remember: re-tick ITS pairs (the lists were just rebuilt for it) + // and re-apply, so audio resumes without the user re-ticking by hand. A driver we've not + // seen this session restores nothing — same as before. + if (driverActuallyChanged && !string.IsNullOrWhiteSpace(newDriver) + && asioTicksByDriver.TryGetValue(newDriver!, out var remembered) + && (remembered.Send.Length > 0 || remembered.Recv.Length > 0)) + { + try + { + suppressDeviceCheckChange = true; + RestoreAsioTicks(asioSendDevicesList, remembered.Send); + RestoreAsioTicks(asioReceiveOutputDevicesList, remembered.Recv); + } + finally { suppressDeviceCheckChange = false; } + logFile.Event($"asio ticks restored for \"{newDriver}\": send pairs=[{string.Join(",", remembered.Send)}], receive pairs=[{string.Join(",", remembered.Recv)}]"); + ApplyAudioRuntime(); + ApplyReceiveDevices(); + } }; healthLabel.AccessibleName = "Connection health"; statusLabel.AccessibleName = "Status"; @@ -7292,6 +7328,38 @@ public sealed class MainForm : Form .ToArray(); } + /// The ticked ASIO channel-pair indices in a list (parsed from the synthetic "asio:N" + /// ids). Internal + static so the self-test can pin the per-driver tick memory round-trip. + internal static int[] SnapshotAsioTicks(CheckedListBox list) + { + var pairs = new List(); + for (var i = 0; i < list.Items.Count; i++) + { + if (list.GetItemChecked(i) && list.Items[i] is AudioDeviceChoice { DeviceId: { } id } + && AsioDeviceId.TryParse(id, out var pair)) + { + pairs.Add(pair); + } + } + return pairs.ToArray(); + } + + /// Re-tick the given pair indices in a freshly rebuilt ASIO list. Pairs the new driver + /// doesn't have (fewer channels) are silently skipped. Caller must hold suppressDeviceCheckChange. + internal static void RestoreAsioTicks(CheckedListBox list, int[] pairs) + { + if (pairs.Length == 0) return; + var wanted = new HashSet(pairs); + for (var i = 0; i < list.Items.Count; i++) + { + if (list.Items[i] is AudioDeviceChoice { DeviceId: { } id } + && AsioDeviceId.TryParse(id, out var pair) && wanted.Contains(pair)) + { + list.SetItemChecked(i, true); + } + } + } + // Address split + resolve moved to Core (PeerAddress) so the app and the service share one // implementation — these thin wrappers keep the existing call sites readable. private static Task ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text); diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 606d536..dc358b2 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -68,6 +68,7 @@ internal static class SelfTest RunStep(results, "Profile persistence tripwire (every field wired or declared)", ProfileDriftTripwire); RunStep(results, "Cue variant resolution (dedupe, order, chosen-default fallback)", CueVariantResolution); RunStep(results, "Updater swap + rollback (a failed update restores exactly)", UpdaterSwapRollback); + RunStep(results, "ASIO per-driver tick memory (snapshot/restore round-trip)", AsioTickMemory); RunStep(results, "App settings save and reload", SettingsRoundTrip); RunStep(results, "Per-peer shaping DSP", PeerShapingDsp); RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs); @@ -963,6 +964,41 @@ internal static class SelfTest return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint"; } + /// Per-driver ASIO tick memory (Ed's EVO→ReaRoute→EVO silence): switching drivers clears + /// the pair ticks by design (pair N is a different physical channel on a different card), and the + /// memory restores each driver's OWN ticks on return. Pins the snapshot/restore round-trip, that a + /// smaller driver silently skips pairs it doesn't have, and that unticked lists snapshot empty. + private static string? AsioTickMemory() + { + using var list = new CheckedListBox(); + list.Items.Add(new AudioDeviceChoice("Pair 1", AsioDeviceId.Format(0), CaptureKind.Loopback)); + list.Items.Add(new AudioDeviceChoice("Pair 2", AsioDeviceId.Format(1), CaptureKind.Loopback)); + list.Items.Add(new AudioDeviceChoice("Pair 3", AsioDeviceId.Format(2), CaptureKind.Loopback)); + list.SetItemChecked(2, true); // Ed's Loop-back pair + + var snap = MainForm.SnapshotAsioTicks(list); + Check(snap.SequenceEqual(new[] { 2 }), $"the ticked pair index must snapshot exactly (got [{string.Join(",", snap)}])"); + + // Simulate the swap: rebuild for a 16-channel driver (more pairs), nothing ticked, then return. + list.Items.Clear(); + for (var i = 0; i < 8; i++) list.Items.Add(new AudioDeviceChoice($"RR Pair {i + 1}", AsioDeviceId.Format(i), CaptureKind.Loopback)); + Check(MainForm.SnapshotAsioTicks(list).Length == 0, "a freshly rebuilt, unticked list must snapshot empty"); + + // Return to the original driver: rebuild its 3 pairs and restore — Pair 3 must come back ticked. + list.Items.Clear(); + for (var i = 0; i < 3; i++) list.Items.Add(new AudioDeviceChoice($"Pair {i + 1}", AsioDeviceId.Format(i), CaptureKind.Loopback)); + MainForm.RestoreAsioTicks(list, snap); + Check(!list.GetItemChecked(0) && !list.GetItemChecked(1) && list.GetItemChecked(2), + "restore must re-tick exactly the remembered pair"); + + // A remembered pair beyond a smaller driver's range is skipped, never thrown on. + list.Items.Clear(); + list.Items.Add(new AudioDeviceChoice("Only pair", AsioDeviceId.Format(0), CaptureKind.Loopback)); + MainForm.RestoreAsioTicks(list, snap); + Check(!list.GetItemChecked(0), "a pair the smaller driver doesn't have must be silently skipped"); + return "snapshot exact; restore re-ticks the remembered pair; out-of-range pairs skipped"; + } + /// The updater's back-up-and-swap is the one piece of code that can BRICK an install: a bad /// rollback leaves a half-swapped folder that won't start. Pins, on real temp folders: the swap /// replaces + adds exactly the release's files (user files untouched), the backup holds the originals, diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs index f847040..ede0e88 100644 --- a/src/RemSound.Sender/AudioSender.cs +++ b/src/RemSound.Sender/AudioSender.cs @@ -367,8 +367,7 @@ public sealed class AudioSender : IDisposable // disposes this instance, so disposing it here can't pull the rug from a live engine. if (persistentAsio is not null) { - try { persistentAsio.Dispose(); } - catch (Exception ex) { diagnostic?.Invoke($"asio: release-on-idle dispose threw {ex.GetType().Name}: {ex.Message}"); } + ReleaseAsioBackendInBackground(persistentAsio, "release-on-deselect"); persistentAsio = null; persistentAsioDriverName = null; } @@ -381,7 +380,7 @@ public sealed class AudioSender : IDisposable { if (persistentAsio is not null) { - try { persistentAsio.Dispose(); } catch { /* ignore */ } + ReleaseAsioBackendInBackground(persistentAsio, "driver-switch"); } persistentAsio = new AsioCaptureBackend( currentAsioDriverName!, @@ -401,6 +400,25 @@ public sealed class AudioSender : IDisposable : defaultLane.OnMixedSamples); } + /// Close an outgoing ASIO backend WITHOUT making the caller wait. The caller here is the + /// UI thread (driver switch / deselect), and a slow driver close — Ed's EVO once took 5+ seconds + /// inside its own Stop — froze the window for the duration even with the close on the apartment + /// thread, because the caller still blocked on it. Now: the callback is unhooked IMMEDIATELY (a + /// volatile write, no driver call — so the old driver stops feeding the lanes before the new one + /// starts), and the actual Stop/Dispose runs on a worker, where the apartment's 8 s bound still + /// backstops a wedged driver. Known edge, accepted: re-selecting the SAME driver within the close + /// window can find the card still held and fail to open — the capture-start failure is logged, and + /// picking the driver again once the close finishes recovers it. + private void ReleaseAsioBackendInBackground(AsioCaptureBackend backend, string why) + { + backend.SetCallback(_ => { }); + Task.Run(() => + { + try { backend.Dispose(); } + catch (Exception ex) { diagnostic?.Invoke($"asio: background {why} release threw {ex.GetType().Name}: {ex.Message}"); } + }); + } + /// /// (Re)create the composite backend with the current audio-mode + asio-driver-name + /// tight-latency-WASAPI flag. Caller must hold configGate. Preserves the running @@ -566,13 +584,23 @@ public sealed class AudioSender : IDisposable StartEngineWithCurrentSources(); } + // Transition-only guard for the no-sources line: the UI's periodic re-apply retries every second + // while nothing is ticked, and logging it every time buried the useful lines (Ed's 2026-07-26 log + // had 18 identical lines in a row). Log the first occurrence, then stay quiet until sources return. + private bool loggedNoSources; + private void StartEngineWithCurrentSources() { if (pendingSources.Count == 0) { - diagnostic?.Invoke("sender: start requested but no sources configured"); + if (!loggedNoSources) + { + loggedNoSources = true; + diagnostic?.Invoke("sender: start requested but no sources configured (repeats suppressed until sources appear)"); + } return; } + loggedNoSources = false; defaultLane.ResetForStart(); asioLane.ResetForStart();