using System.Runtime.InteropServices; // Single-process WASAPI loopback capture via AUDIOCLIENT_ACTIVATION_PARAMS // (Windows 10 2004+ / Build 19041+). // // Threading: ALL WASAPI init runs on the capture thread (MTA). If called from the // WinForms UI thread (STA), ActivateAudioInterfaceAsync fires ActivateCompleted on // an MTA pool thread; COM marshals that back to the STA pump — but the STA thread is // blocked on CompletionEvent.Wait → deadlock. MTA capture thread avoids this. // // COM QI policy: the COM objects returned by the process-loopback activation path // reject QueryInterface for their own IIDs under .NET's RCW mechanism. Every call // to IAudioClient and IAudioCaptureClient is therefore dispatched via raw vtable // pointer arithmetic, bypassing .NET COM interop entirely. namespace VoiceCat.App.Audio; public sealed class ProcessLoopbackCapture : IDisposable { public enum Mode { Include, Exclude } // Fired on the capture thread every 20 ms (960 samples/channel @ 48 kHz, interleaved s16). public event Action? PcmFrameReady; private const int SampleRate = 48000; private const int FrameSamples = 960; // 20 ms private const string LoopbackDevicePath = "VAD\\Process_Loopback"; private readonly int _pid; private readonly Mode _mode; // Raw COM pointers — managed via explicit AddRef/Release, no RCW wrapping. private IntPtr _audioClientPtr; // IAudioClient* private IntPtr _captureClientPtr; // IAudioCaptureClient* private AutoResetEvent? _bufferEvent; private Thread? _captureThread; private volatile bool _running; private int _channels; // Accumulator: assembles driver-callback-sized fragments into FrameSamples chunks. private short[] _accumBuf = []; private int _accumCount; // Init-done signal: Set() by the capture thread after ActivateClient() completes. private readonly ManualResetEventSlim _initDone = new(false); private bool _initOk; public ProcessLoopbackCapture(int pid, Mode mode) { _pid = pid; _mode = mode; } /// Starts capture. Blocks until WASAPI activation completes (typically <100 ms). /// Returns false if the process cannot be captured. public bool Start() { if (_running) return false; _running = true; _captureThread = new Thread(CaptureThreadProc) { IsBackground = true, Name = $"ProcLoopback:{_pid}", }; _captureThread.Start(); bool ok = _initDone.Wait(5000) && _initOk; if (!ok) _running = false; return ok; } public void Stop() { _running = false; _bufferEvent?.Set(); _captureThread?.Join(500); if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr); } public void Dispose() { Stop(); ComRelease(ref _captureClientPtr); ComRelease(ref _audioClientPtr); _bufferEvent?.Dispose(); _initDone.Dispose(); } // ── Capture thread (MTA) ────────────────────────────────────────────────── private void CaptureThreadProc() { _initOk = ActivateAndStart(); _initDone.Set(); if (!_initOk) return; CaptureLoop(); } private bool ActivateAndStart() { if (!ActivateClient()) return false; _bufferEvent = new AutoResetEvent(false); if (AC_SetEventHandle(_audioClientPtr, _bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0) return false; return AC_Start(_audioClientPtr) >= 0; } // ── Activation ─────────────────────────────────────────────────────────── private unsafe bool ActivateClient() { var activationParams = new AudioClientActivationParams { ActivationType = 1, // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK TargetProcessId = (uint)_pid, ProcessLoopbackMode = _mode == Mode.Include ? 0u : 1u, }; var handler = new ActivationCompletionHandler(); var audioClientIid = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"); IntPtr opPtr; { AudioClientActivationParams* pParams = &activationParams; // PROPVARIANT (VT_BLOB) x64: vt(2)+res(6)+cbSize(4)+pad(4)+pBlobData(8) = 24 B. var pv = stackalloc byte[24]; *(ushort*)(pv + 0) = 65; *(uint*) (pv + 8) = (uint)sizeof(AudioClientActivationParams); *(nint*) (pv + 16) = (nint)pParams; int hr = ActivateAudioInterfaceAsync( LoopbackDevicePath, ref audioClientIid, (IntPtr)pv, handler, out opPtr); if (hr < 0) return false; } if (!handler.CompletionEvent.Wait(3000)) { ComRelease(ref opPtr); return false; } // Call GetActivateResult via vtable (slot 3) — avoids QI on the async-op object. if (!Vtable_GetActivateResult(handler.OperationPtr, out int activateHr, out IntPtr activatedPtr)) { handler.ReleaseOp(); ComRelease(ref opPtr); return false; } handler.ReleaseOp(); ComRelease(ref opPtr); if (activateHr < 0 || activatedPtr == IntPtr.Zero) return false; // Store the raw IAudioClient* — do NOT create an RCW; use vtable dispatch instead. _audioClientPtr = activatedPtr; // activatedPtr already has ref count from GetActivateResult; don't double-release. return InitializeStream(); } // IActivateAudioInterfaceAsyncOperation vtable slot 3: GetActivateResult(HRESULT*, IUnknown**) private static unsafe bool Vtable_GetActivateResult(IntPtr op, out int activateHr, out IntPtr activatedPtr) { activateHr = unchecked((int)0x80004005); activatedPtr = IntPtr.Zero; if (op == IntPtr.Zero) return false; void** vtable = *(void***)op.ToPointer(); var fn = (delegate* unmanaged[Stdcall])vtable[3]; fixed (int* pHr = &activateHr) fixed (IntPtr* pPtr = &activatedPtr) return fn(op, pHr, pPtr) >= 0; } private unsafe bool InitializeStream() { // Try s16 stereo first; fall back to s16 mono. foreach (int ch in new[] { 2, 1 }) { var fmt = new WaveFormatEx { wFormatTag = 1, // WAVE_FORMAT_PCM nChannels = (ushort)ch, nSamplesPerSec = SampleRate, wBitsPerSample = 16, nBlockAlign = (ushort)(ch * 2), nAvgBytesPerSec = (uint)(SampleRate * ch * 2), cbSize = 0, }; // Process-loopback requires LOOPBACK (deliver rendered audio) + EVENTCALLBACK + // AUTOCONVERTPCM (resample the app's native format to our requested s16/48k). Without // LOOPBACK every buffer comes back AUDCLNT_BUFFERFLAGS_SILENT; without AUTOCONVERTPCM // the requested format is rejected. Matches the MS ApplicationLoopback sample. // AUDCLNT_SHAREMODE_SHARED = 0 // AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000 // AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000 // AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000 const uint streamFlags = 0x00020000u | 0x00040000u | 0x80000000u; int hr = AC_Initialize(_audioClientPtr, 0, streamFlags, 2_000_000 /*200ms hns*/, 0, &fmt, null); if (hr >= 0) { _channels = ch; _accumBuf = new short[FrameSamples * ch]; _accumCount = 0; break; } if (ch == 1) return false; } var captureIid = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"); int getHr = AC_GetService(_audioClientPtr, ref captureIid, out _captureClientPtr); return getHr >= 0 && _captureClientPtr != IntPtr.Zero; } // ── Capture loop ───────────────────────────────────────────────────────── private void CaptureLoop() { while (_running) { _bufferEvent!.WaitOne(100); if (!_running) break; while (_running) { int hr = CC_GetNextPacketSize(_captureClientPtr, out uint packetSize); if (hr < 0 || packetSize == 0) break; hr = CC_GetBuffer(_captureClientPtr, out IntPtr dataPtr, out uint framesAvailable, out uint flags); if (hr < 0) break; bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT if (framesAvailable > 0) { if (silent) AccumulateSilence((int)framesAvailable); else AccumulatePcm(dataPtr, (int)framesAvailable); } CC_ReleaseBuffer(_captureClientPtr, framesAvailable); } } } private unsafe void AccumulatePcm(IntPtr data, int frames) { var src = (short*)data.ToPointer(); int total = frames * _channels; int idx = 0; while (idx < total) { int space = _accumBuf.Length - _accumCount; int copy = Math.Min(total - idx, space); fixed (short* dst = _accumBuf) Buffer.MemoryCopy(src + idx, dst + _accumCount, copy * 2L, copy * 2L); _accumCount += copy; idx += copy; if (_accumCount == _accumBuf.Length) FlushFrame(); } } private void AccumulateSilence(int frames) { int total = frames * _channels; int idx = 0; while (idx < total) { int space = _accumBuf.Length - _accumCount; int fill = Math.Min(total - idx, space); Array.Clear(_accumBuf, _accumCount, fill); _accumCount += fill; idx += fill; if (_accumCount == _accumBuf.Length) FlushFrame(); } } private void FlushFrame() { var copy = new short[_accumBuf.Length]; _accumBuf.AsSpan().CopyTo(copy); PcmFrameReady?.Invoke(copy, FrameSamples, _channels); _accumCount = 0; } // ── IAudioClient vtable helpers (raw dispatch, no RCW / no QI) ─────────── // // Vtable layout (IUnknown base: 0=QI 1=AddRef 2=Release; then IAudioClient methods): // 3=Initialize 4=GetBufferSize 5=GetStreamLatency 6=GetCurrentPadding // 7=IsFormatSupported 8=GetMixFormat 9=GetDevicePeriod // 10=Start 11=Stop 12=Reset 13=SetEventHandle 14=GetService private static unsafe int AC_Initialize(IntPtr ac, int shareMode, uint streamFlags, long hnsBufferDuration, long hnsPeriodicity, WaveFormatEx* pFormat, Guid* pSession) { var fn = (delegate* unmanaged[Stdcall]) (*(void***)ac)[3]; return fn(ac, shareMode, streamFlags, hnsBufferDuration, hnsPeriodicity, pFormat, pSession); } private static unsafe int AC_Start(IntPtr ac) { var fn = (delegate* unmanaged[Stdcall])(*(void***)ac)[10]; return fn(ac); } private static unsafe int AC_Stop(IntPtr ac) { var fn = (delegate* unmanaged[Stdcall])(*(void***)ac)[11]; return fn(ac); } private static unsafe int AC_SetEventHandle(IntPtr ac, IntPtr eventHandle) { var fn = (delegate* unmanaged[Stdcall])(*(void***)ac)[13]; return fn(ac, eventHandle); } private static unsafe int AC_GetService(IntPtr ac, ref Guid riid, out IntPtr ppv) { var fn = (delegate* unmanaged[Stdcall])(*(void***)ac)[14]; fixed (Guid* pIid = &riid) fixed (IntPtr* pPpv = &ppv) return fn(ac, pIid, pPpv); } // ── IAudioCaptureClient vtable helpers ──────────────────────────────────── // // Vtable (IUnknown: 0-2; then): 3=GetBuffer 4=ReleaseBuffer 5=GetNextPacketSize private static unsafe int CC_GetBuffer(IntPtr cc, out IntPtr ppData, out uint pNumFrames, out uint pdwFlags) { var fn = (delegate* unmanaged[Stdcall]) (*(void***)cc)[3]; ulong devPos = 0, qpcPos = 0; fixed (IntPtr* p0 = &ppData) fixed (uint* p1 = &pNumFrames) fixed (uint* p2 = &pdwFlags) return fn(cc, p0, p1, p2, &devPos, &qpcPos); } private static unsafe int CC_ReleaseBuffer(IntPtr cc, uint numFrames) { var fn = (delegate* unmanaged[Stdcall])(*(void***)cc)[4]; return fn(cc, numFrames); } private static unsafe int CC_GetNextPacketSize(IntPtr cc, out uint pNumFrames) { var fn = (delegate* unmanaged[Stdcall])(*(void***)cc)[5]; fixed (uint* p = &pNumFrames) return fn(cc, p); } // ── COM utilities ───────────────────────────────────────────────────────── private static unsafe void ComRelease(ref IntPtr ptr) { if (ptr == IntPtr.Zero) return; var fn = (delegate* unmanaged[Stdcall])(*(void***)ptr)[2]; // IUnknown::Release fn(ptr); ptr = IntPtr.Zero; } // ── P/Invoke & structs ──────────────────────────────────────────────────── [DllImport("Mmdevapi.dll", CharSet = CharSet.Unicode)] private static extern int ActivateAudioInterfaceAsync( string deviceInterfacePath, ref Guid riid, IntPtr activationParams, [MarshalAs(UnmanagedType.Interface)] IActivateAudioInterfaceCompletionHandler completionHandler, out IntPtr activationOperation); [StructLayout(LayoutKind.Sequential)] private struct AudioClientActivationParams { public int ActivationType; // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK = 1 public uint TargetProcessId; public uint ProcessLoopbackMode; // INCLUDE=0, EXCLUDE=1 } [StructLayout(LayoutKind.Sequential, Pack = 2)] private struct WaveFormatEx { public ushort wFormatTag; public ushort nChannels; public uint nSamplesPerSec; public uint nAvgBytesPerSec; public ushort nBlockAlign; public ushort wBitsPerSample; public ushort cbSize; } // ── Completion handler CCW ──────────────────────────────────────────────── // // Only this object still uses .NET COM interop (as a CCW). The activateOperation // parameter is IntPtr to avoid QI on the incoming async-op pointer. [ComVisible(true), ClassInterface(ClassInterfaceType.None)] private sealed class ActivationCompletionHandler : IActivateAudioInterfaceCompletionHandler { public readonly ManualResetEventSlim CompletionEvent = new(false); public IntPtr OperationPtr { get; private set; } public void ActivateCompleted(IntPtr activateOperation) { OperationPtr = activateOperation; if (OperationPtr != IntPtr.Zero) Marshal.AddRef(OperationPtr); CompletionEvent.Set(); } public void ReleaseOp() { if (OperationPtr == IntPtr.Zero) return; Marshal.Release(OperationPtr); OperationPtr = IntPtr.Zero; } } [ComImport, Guid("41D949AB-9862-444A-80F6-C261334DA5EB"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IActivateAudioInterfaceCompletionHandler { void ActivateCompleted(IntPtr activateOperation); } }