diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 33636d4..bd5e257 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -73,6 +73,7 @@ internal static class SelfTest
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, "ASIO apartment thread (single STA home for driver calls)", AsioApartmentThread);
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);
@@ -1762,6 +1763,32 @@ internal static class SelfTest
}
}
+ /// The dedicated ASIO control thread (AsioApartment) must run every work item on ONE STA
+ /// thread (not the caller's), propagate exceptions back to the caller, and keep working after a work
+ /// item throws — the guarantees that let the ASIO driver be opened AND closed from a single, pumped
+ /// home thread (the fix for the native crash-on-close).
+ private static string? AsioApartmentThread()
+ {
+ using var apt = new RemSound.Sender.AsioApartment("asio-selftest");
+ var state = ApartmentState.Unknown;
+ int workThread = 0, workThread2 = 0;
+ apt.Invoke(() => { state = Thread.CurrentThread.GetApartmentState(); workThread = Environment.CurrentManagedThreadId; });
+ apt.Invoke(() => workThread2 = Environment.CurrentManagedThreadId);
+ Check(state == ApartmentState.STA, "the ASIO apartment must run work on an STA thread");
+ Check(workThread == workThread2, "all work must run on the SAME dedicated thread");
+ Check(workThread != Environment.CurrentManagedThreadId, "work must run on the apartment thread, not the caller's");
+
+ var threw = false;
+ try { apt.Invoke(() => throw new InvalidOperationException("boom")); }
+ catch (InvalidOperationException ex) when (ex.Message == "boom") { threw = true; }
+ Check(threw, "an exception on the apartment thread must propagate to the caller");
+
+ var ranAfter = false;
+ apt.Invoke(() => ranAfter = true);
+ Check(ranAfter, "the apartment must keep working after a work item threw");
+ return "runs work on one dedicated STA thread; exceptions propagate; survives a throw";
+ }
+
/// 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
diff --git a/src/RemSound.Sender/AsioApartment.cs b/src/RemSound.Sender/AsioApartment.cs
new file mode 100644
index 0000000..9a38094
--- /dev/null
+++ b/src/RemSound.Sender/AsioApartment.cs
@@ -0,0 +1,127 @@
+using System.Runtime.InteropServices;
+
+namespace RemSound.Sender;
+
+///
+/// A dedicated STA thread with a Windows message pump, used to own the ENTIRE lifecycle of one ASIO
+/// driver — create, initialise, start, stop and (crucially) dispose. ASIO drivers are COM objects that
+/// generally require every one of their control calls to be made from a single thread, and several
+/// (notably Audient) crash NATIVELY on close when that isn't honoured, or when there is no message pump
+/// running to service the messages the driver posts during init/reset/close. The previous code made
+/// these calls on whatever thread happened to call Start/Stop, with only a Sleep() before the close —
+/// which is why closing the driver (on "ASIO → none" or a driver switch) could take the whole process
+/// down with an access violation that left no managed stack.
+///
+/// Route every AsioOut control call through and the driver gets a stable home
+/// thread and a live pump. The ASIO audio callback still runs on the driver's own real-time thread — that
+/// is unchanged; only the control calls move here.
+///
+internal sealed class AsioApartment : IDisposable
+{
+ private readonly Thread thread;
+ private readonly ManualResetEventSlim started = new(false);
+ private readonly object queueGate = new();
+ private readonly Queue queue = new();
+ private readonly AutoResetEvent workAvailable = new(false);
+ private volatile bool shutdown;
+
+ private sealed class WorkItem
+ {
+ public required Action Action;
+ public required ManualResetEventSlim Done;
+ public Exception? Error;
+ }
+
+ public AsioApartment(string name = "asio-control")
+ {
+ thread = new Thread(Run) { IsBackground = true, Name = name };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ started.Wait(); // don't accept Invoke()s until the pump is up
+ }
+
+ /// Run on the apartment thread and block until it finishes,
+ /// rethrowing anything it raised. If called from the apartment thread itself, or after Dispose, runs
+ /// inline so a teardown path can never deadlock on itself.
+ public void Invoke(Action action)
+ {
+ if (shutdown || Thread.CurrentThread == thread) { action(); return; }
+ var item = new WorkItem { Action = action, Done = new ManualResetEventSlim(false) };
+ lock (queueGate) queue.Enqueue(item);
+ workAvailable.Set();
+ item.Done.Wait();
+ item.Done.Dispose();
+ if (item.Error is not null) throw item.Error;
+ }
+
+ private void Run()
+ {
+ // Force a message queue to exist on this thread before anyone posts to it.
+ PeekMessage(out _, IntPtr.Zero, 0, 0, PM_NOREMOVE);
+ started.Set();
+
+ var handles = new[] { workAvailable.SafeWaitHandle.DangerousGetHandle() };
+ while (!shutdown)
+ {
+ // Wake on either queued work or an incoming window message, so the driver's posted messages
+ // are dispatched promptly (that servicing is what several ASIO drivers need to close cleanly).
+ MsgWaitForMultipleObjects(1, handles, false, INFINITE, QS_ALLINPUT);
+
+ while (PeekMessage(out var msg, IntPtr.Zero, 0, 0, PM_REMOVE))
+ {
+ TranslateMessage(ref msg);
+ DispatchMessage(ref msg);
+ }
+
+ while (true)
+ {
+ WorkItem? item;
+ lock (queueGate) item = queue.Count > 0 ? queue.Dequeue() : null;
+ if (item is null) break;
+ try { item.Action(); }
+ catch (Exception ex) { item.Error = ex; }
+ finally { item.Done.Set(); }
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (shutdown) return;
+ shutdown = true;
+ workAvailable.Set();
+ try { if (thread.IsAlive && Thread.CurrentThread != thread) thread.Join(2000); } catch { /* leaked thread beats a hang */ }
+ try { workAvailable.Dispose(); } catch { }
+ try { started.Dispose(); } catch { }
+ }
+
+ // ---- native message pump ----
+ private const uint QS_ALLINPUT = 0x04FF;
+ private const uint INFINITE = 0xFFFFFFFF;
+ private const uint PM_REMOVE = 0x0001;
+ private const uint PM_NOREMOVE = 0x0000;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MSG
+ {
+ public IntPtr hwnd;
+ public uint message;
+ public IntPtr wParam;
+ public IntPtr lParam;
+ public uint time;
+ public int ptX;
+ public int ptY;
+ }
+
+ [DllImport("user32.dll")]
+ private static extern uint MsgWaitForMultipleObjects(uint nCount, IntPtr[] pHandles, bool bWaitAll, uint dwMilliseconds, uint dwWakeMask);
+
+ [DllImport("user32.dll")]
+ private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
+
+ [DllImport("user32.dll")]
+ private static extern bool TranslateMessage(ref MSG lpMsg);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr DispatchMessage(ref MSG lpMsg);
+}
diff --git a/src/RemSound.Sender/AsioCaptureBackend.cs b/src/RemSound.Sender/AsioCaptureBackend.cs
index b22aaff..59dfa30 100644
--- a/src/RemSound.Sender/AsioCaptureBackend.cs
+++ b/src/RemSound.Sender/AsioCaptureBackend.cs
@@ -48,6 +48,10 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
private readonly object gate = new();
private AsioOut? asio;
+ // Every AsioOut control call (create / init / play / stop / dispose) is marshalled onto this one
+ // dedicated STA+message-pump thread. That single-threaded, pumped home is what lets the driver close
+ // WITHOUT the native crash we used to hit on "ASIO → none" and driver switches — see AsioApartment.
+ private readonly AsioApartment apartment = new();
private List activeChannelPairIndices = [];
private int recordChannelCount;
private float[] mixScratch = new float[1024];
@@ -133,36 +137,34 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
try
{
- asio = new AsioOut(driverName);
- // Always open with the driver's full input channel count. Pulling channels we
- // don't immediately need is essentially free — the driver fills them anyway —
- // and it removes the need to ever reopen the AsioOut when the user toggles a
- // higher-numbered channel pair. Reopening is what previously caused 15-second
- // freezes when both sender and receiver held the same single-client driver
- // (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
- recordChannelCount = asio.DriverInputChannelCount;
- if (recordChannelCount <= 0)
+ // Open + init + play, ALL on the ASIO apartment thread (see the apartment field). A zero-
+ // channel driver becomes a throw so the catch below runs the same StopInternal cleanup.
+ apartment.Invoke(() =>
{
- onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels");
- StopInternal();
- return;
- }
- asio.InputChannelOffset = 0;
- // Sanity-check that the requested pairs are within the driver's channel range.
- // We open the full count anyway, but if a saved spec references a pair above
- // the driver's range, the OnAudioAvailable mixer would silently emit zero —
- // surface that as a diagnostic so it's not mysterious.
- var maxPairIndex = activeChannelPairIndices.Max();
- var highestNeededChannel = (maxPairIndex + 1) * 2;
- if (highestNeededChannel > recordChannelCount)
- {
- onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
- // Continue anyway — out-of-range pairs just contribute silence to the mix.
- }
- asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
- asio.AudioAvailable += OnAudioAvailable;
- captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
- asio.Play();
+ asio = new AsioOut(driverName);
+ // Always open with the driver's full input channel count. Pulling channels we
+ // don't immediately need is essentially free — the driver fills them anyway —
+ // and it removes the need to ever reopen the AsioOut when the user toggles a
+ // higher-numbered channel pair. Reopening is what previously caused 15-second
+ // freezes when both sender and receiver held the same single-client driver
+ // (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
+ recordChannelCount = asio.DriverInputChannelCount;
+ if (recordChannelCount <= 0)
+ throw new InvalidOperationException($"driver \"{driverName}\" reports zero input channels");
+ asio.InputChannelOffset = 0;
+ // Sanity-check that the requested pairs are within the driver's channel range.
+ // We open the full count anyway, but if a saved spec references a pair above
+ // the driver's range, the OnAudioAvailable mixer would silently emit zero —
+ // surface that as a diagnostic so it's not mysterious.
+ var maxPairIndex = activeChannelPairIndices.Max();
+ var highestNeededChannel = (maxPairIndex + 1) * 2;
+ if (highestNeededChannel > recordChannelCount)
+ onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
+ asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
+ asio.AudioAvailable += OnAudioAvailable;
+ captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
+ asio.Play();
+ });
uptime.Restart();
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");
}
@@ -207,22 +209,25 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
private void StopInternal()
{
- if (asio is not null)
+ var toClose = asio;
+ if (toClose is not null)
{
- // Step-by-step logging: closing some drivers (notably Audient) can crash NATIVELY inside their
- // own Stop/Dispose — a native access violation blows past these try/catch blocks and kills the
- // process with no managed stack (why the ASIO->none crash leaves no crash file). Logging each
- // step means the log ends right AFTER the line for whichever native call died, pinpointing it.
- onDiagnostic?.Invoke("asio close: unhooking callback");
- try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
- // Let any ASIO buffer callback already in flight finish before we stop/release the driver, so
- // the native close isn't racing a live callback (a common trigger for the crash).
- System.Threading.Thread.Sleep(60);
- onDiagnostic?.Invoke("asio close: stopping stream");
- try { asio.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); }
- onDiagnostic?.Invoke("asio close: releasing driver (dispose)");
- try { asio.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); }
- onDiagnostic?.Invoke("asio close: driver released cleanly");
+ // Close the driver on the ASIO apartment thread — the same single, pumped thread it was opened
+ // on. That is the fix for the native access violation that used to kill the process here (it
+ // blew past these try/catch blocks with no managed stack). Step-by-step logging still pinpoints
+ // any native call that dies, and the callback is unhooked + drained before stop/dispose so the
+ // close isn't racing a live buffer callback (a common trigger for the crash).
+ apartment.Invoke(() =>
+ {
+ onDiagnostic?.Invoke("asio close: unhooking callback");
+ try { toClose.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
+ System.Threading.Thread.Sleep(60);
+ onDiagnostic?.Invoke("asio close: stopping stream");
+ try { toClose.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); }
+ onDiagnostic?.Invoke("asio close: releasing driver (dispose)");
+ try { toClose.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); }
+ onDiagnostic?.Invoke("asio close: driver released cleanly");
+ });
asio = null;
}
uptime.Stop();
@@ -230,7 +235,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
recordChannelCount = 0;
}
- public void Dispose() => Stop();
+ public void Dispose()
+ {
+ Stop();
+ apartment.Dispose(); // shut down the dedicated ASIO thread last, after the driver is closed
+ }
private static List ParseChannelPairIndices(IReadOnlyList specs)
{
diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs
index 3556035..f847040 100644
--- a/src/RemSound.Sender/AudioSender.cs
+++ b/src/RemSound.Sender/AudioSender.cs
@@ -358,16 +358,19 @@ public sealed class AudioSender : IDisposable
if (!willUseAsio)
{
- // Mode no longer uses ASIO. Ed's approach (2026-07-15): do NOT close the driver here — closing
- // it is the native call that crashes Audient (the "ASIO -> none kills the app" bug). Instead
- // keep the persistent instance OPEN but PARKED: rewire its callback to a no-op and drop it to
- // zero active pairs. That stops it feeding any lane WITHOUT a native close, so no crash. It's
- // reused instantly if the user turns ASIO back on, and only truly closes on a driver CHANGE
- // (below) or app exit — where the OS reclaims it anyway.
+ // ASIO is no longer selected (driver set to "(none)", or a WASAPI-only mode). RELEASE the
+ // driver now — close it on its dedicated apartment thread, which is safe as of the AsioApartment
+ // change — so the sound card is FREE for other applications (a DAW, etc.) instead of being held
+ // exclusively for the whole time RemSound is open. This replaces the old "park it open forever"
+ // workaround, which existed only because closing the Audient driver crashed. The composite is
+ // rebuilt without ASIO immediately after (RebuildEngineLocked); the composite borrows but never
+ // disposes this instance, so disposing it here can't pull the rug from a live engine.
if (persistentAsio is not null)
{
- try { persistentAsio.SetCallback(static _ => { }); } catch { /* ignore */ }
- try { persistentAsio.UpdateSources(Array.Empty()); } catch { /* ignore */ }
+ try { persistentAsio.Dispose(); }
+ catch (Exception ex) { diagnostic?.Invoke($"asio: release-on-idle dispose threw {ex.GetType().Name}: {ex.Message}"); }
+ persistentAsio = null;
+ persistentAsioDriverName = null;
}
return;
}