diff --git a/src/RemSound.Sender/AsioApartment.cs b/src/RemSound.Sender/AsioApartment.cs index 9a38094..a0545ff 100644 --- a/src/RemSound.Sender/AsioApartment.cs +++ b/src/RemSound.Sender/AsioApartment.cs @@ -23,6 +23,7 @@ internal sealed class AsioApartment : IDisposable private readonly object queueGate = new(); private readonly Queue queue = new(); private readonly AutoResetEvent workAvailable = new(false); + private readonly Action? log; private volatile bool shutdown; private sealed class WorkItem @@ -32,12 +33,14 @@ internal sealed class AsioApartment : IDisposable public Exception? Error; } - public AsioApartment(string name = "asio-control") + public AsioApartment(string name = "asio-control", Action? log = null) { + this.log = log; 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 + log?.Invoke($"asio apartment: thread up (STA, managed id {thread.ManagedThreadId})"); } /// Run on the apartment thread and block until it finishes, @@ -90,7 +93,9 @@ internal sealed class AsioApartment : IDisposable if (shutdown) return; shutdown = true; workAvailable.Set(); - try { if (thread.IsAlive && Thread.CurrentThread != thread) thread.Join(2000); } catch { /* leaked thread beats a hang */ } + var joined = true; // already-terminated or self-call counts as clean; only a real timeout is a leak + try { if (thread.IsAlive && Thread.CurrentThread != thread) joined = thread.Join(2000); } catch { /* leaked thread beats a hang */ } + log?.Invoke(joined ? "asio apartment: thread down (clean)" : "asio apartment: thread did not join in 2s (leaked)"); try { workAvailable.Dispose(); } catch { } try { started.Dispose(); } catch { } } diff --git a/src/RemSound.Sender/AsioCaptureBackend.cs b/src/RemSound.Sender/AsioCaptureBackend.cs index 59dfa30..51af029 100644 --- a/src/RemSound.Sender/AsioCaptureBackend.cs +++ b/src/RemSound.Sender/AsioCaptureBackend.cs @@ -51,7 +51,8 @@ internal sealed class AsioCaptureBackend : ICaptureBackend // 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(); + // Created in the ctor so it can announce its own thread up/down through the same diagnostic sink. + private readonly AsioApartment apartment; private List activeChannelPairIndices = []; private int recordChannelCount; private float[] mixScratch = new float[1024]; @@ -83,6 +84,7 @@ internal sealed class AsioCaptureBackend : ICaptureBackend this.driverName = driverName; this.onMixedSamples = onMixedSamples; this.onDiagnostic = onDiagnostic; + apartment = new AsioApartment($"asio-control:{driverName}", onDiagnostic); } /// @@ -141,6 +143,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend // channel driver becomes a throw so the catch below runs the same StopInternal cleanup. apartment.Invoke(() => { + // Step-by-step breadcrumbs, symmetric with the close path in StopInternal. If a + // native call here takes the process down (as the driver used to do on close), the + // last line in the log names exactly which stage died — with AutoFlush on, each line + // is on disk before the next native call runs. + onDiagnostic?.Invoke($"asio open: creating driver \"{driverName}\""); 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 — @@ -160,10 +167,13 @@ internal sealed class AsioCaptureBackend : ICaptureBackend 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})"); + onDiagnostic?.Invoke($"asio open: init record+playback ({recordChannelCount} ch @ {MixSampleRate} Hz)"); asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate); asio.AudioAvailable += OnAudioAvailable; captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)"; + onDiagnostic?.Invoke("asio open: starting stream (play)"); asio.Play(); + onDiagnostic?.Invoke("asio open: stream running"); }); uptime.Restart(); onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");