ASIO switch: per-driver tick memory + fully async driver release (Ed's EVO->ReaRoute->EVO)

Diagnosis from Ed's 2026-07-26 desktop log: (1) the 'hang' was the EVO taking 5+s inside
its own Stop - the 8s bound caught it and unfroze, working as designed but still FELT;
(2) the silence returning to EVO was 18s of 'start requested but no sources configured' -
switching drivers rebuilds the pair list and clears ticks (deliberately: pair N is a
different physical channel on a different card), so nothing was ticked when he returned.

Fixes - completing the architecture rather than patching it:
- Per-driver tick memory: before the swap clears ticks, the outgoing driver's ticked
  pairs (send + receive) are remembered BY DRIVER NAME; returning to that driver
  restores its own ticks after the lists rebuild and re-applies audio, so it resumes
  by itself. Safety preserved: driver A's ticks never bleed onto driver B; pairs a
  smaller driver lacks are skipped. Snapshot/restore pinned by a new self-test.
- Async release: the UI thread no longer WAITS for the old driver to close at all.
  The old backend's callback is unhooked immediately (volatile write - it stops
  feeding the lanes before the new driver starts), then Stop/Dispose runs on a
  worker where the apartment's 8s bound still backstops a wedged driver. A slow
  close now costs nothing perceptually. Known accepted edge (documented): re-picking
  the SAME driver inside the close window can find the card still held; picking it
  again recovers.
- The once-per-second no-sources line is now transition-only (18 identical lines in
  Ed's log buried the signal).

Gate 60/60 incl. the ASIO-enabled churn: 52 transitions over the real Audient driver,
handles flat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 22:11:19 +01:00
co-authored by Claude Fable 5
parent 517a066923
commit 64c92a67bb
3 changed files with 136 additions and 4 deletions
+68
View File
@@ -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<string, (int[] Send, int[] Recv)> 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();
}
/// <summary>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.</summary>
internal static int[] SnapshotAsioTicks(CheckedListBox list)
{
var pairs = new List<int>();
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();
}
/// <summary>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.</summary>
internal static void RestoreAsioTicks(CheckedListBox list, int[] pairs)
{
if (pairs.Length == 0) return;
var wanted = new HashSet<int>(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<IPAddress?> ResolvePeerAddressAsync(string text) => PeerAddress.ResolveHostAsync(text);
+36
View File
@@ -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";
}
/// <summary>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.</summary>
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";
}
/// <summary>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,
+32 -4
View File
@@ -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);
}
/// <summary>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.</summary>
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}"); }
});
}
/// <summary>
/// (Re)create the composite backend with the current audio-mode + asio-driver-name +
/// tight-latency-WASAPI flag. Caller must hold <c>configGate</c>. 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();