using System.Runtime.InteropServices; // Captures a second hardware input for AUX_DEVICE and feeds it through vc_stream_feed_pcm. // Endpoint identifiers are WASAPI-specific and cannot be exchanged with the core's miniaudio ids. namespace VoiceCat.App.Audio; /// An audio input (capture) endpoint for the aux-stream device picker. /// is a WASAPI endpoint id (round-trip only; never construct by hand) — pass null to capture the /// system default. returns the friendly name for ComboBox display. public sealed record InputDeviceInfo(string Id, string Name, bool IsDefault) { public override string ToString() => Name; } /// Enumerates WASAPI capture endpoints. Separate from the core's vc_list_devices because /// the aux device is opened client-side and needs a WASAPI id, not a miniaudio one. public static class InputDeviceEnumerator { public static IReadOnlyList List() { var result = new List(); InputDeviceCapture.IMMDeviceEnumerator? enumerator = null; IntPtr collectionPtr = IntPtr.Zero; string? defaultId = null; try { enumerator = (InputDeviceCapture.IMMDeviceEnumerator)Activator.CreateInstance( Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; // Resolve the default capture endpoint id so the picker can flag it. if (enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, out var defDev) == 0 && defDev != null) { try { if (defDev.GetId(out string id) == 0) defaultId = id; } finally { Marshal.ReleaseComObject(defDev); } } // DEVICE_STATE_ACTIVE = 0x1 — only currently-usable endpoints. if (enumerator.EnumAudioEndpoints(1 /*eCapture*/, 0x1, out collectionPtr) != 0 || collectionPtr == IntPtr.Zero) return result; var collection = (InputDeviceCapture.IMMDeviceCollection) Marshal.GetObjectForIUnknown(collectionPtr); collection.GetCount(out int count); for (int i = 0; i < count; i++) { if (collection.Item(i, out var dev) != 0 || dev == null) continue; try { if (dev.GetId(out string id) != 0) continue; string name = ReadFriendlyName(dev) ?? "Unknown input device"; result.Add(new InputDeviceInfo(id, name, id == defaultId)); } finally { Marshal.ReleaseComObject(dev); } } } catch { /* no audio subsystem / WASAPI unavailable — return what we have */ } finally { if (collectionPtr != IntPtr.Zero) Marshal.Release(collectionPtr); if (enumerator != null) Marshal.ReleaseComObject(enumerator); } return result.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase).ToList(); } private static string? ReadFriendlyName(InputDeviceCapture.IMMDevice dev) { if (dev.OpenPropertyStore(0 /*STGM_READ*/, out IntPtr storePtr) != 0 || storePtr == IntPtr.Zero) return null; try { var store = (InputDeviceCapture.IPropertyStore)Marshal.GetObjectForIUnknown(storePtr); // PKEY_Device_FriendlyName = {a45c254e-df1c-4efd-8020-67d146a850e0}, pid 14. var key = new InputDeviceCapture.PropertyKey { fmtid = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"), pid = 14, }; if (store.GetValue(ref key, out var pv) != 0) return null; try { // VT_LPWSTR = 31. return pv.vt == 31 ? Marshal.PtrToStringUni(pv.pointerValue) : null; } finally { InputDeviceCapture.PropVariantClear(ref pv); } } finally { Marshal.Release(storePtr); } } } /// Captures a single hardware input device in WASAPI shared mode and raises a 20 ms /// (960 samples/channel @ 48 kHz, interleaved s16) frame event. The caller feeds these to the /// core's external-feed stream. Threading mirrors ProcessLoopbackCapture: all WASAPI work runs on /// a dedicated background (MTA) thread; the event fires on that thread. public sealed class InputDeviceCapture : IDisposable { /// Fired on the capture thread every 20 ms: (interleaved s16 PCM, samplesPerChannel /// = 960, channels). public event Action? PcmFrameReady; private const int SampleRate = 48000; private const int FrameSamples = 960; // 20 ms private readonly string? _deviceId; // null = system default capture endpoint private IAudioClient? _audioClient; private IAudioCaptureClient? _captureClient; 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 activation completes. private readonly ManualResetEventSlim _initDone = new(false); private bool _initOk; public InputDeviceCapture(string? deviceId) => _deviceId = deviceId; /// Starts capture. Blocks until WASAPI activation completes (typically <100 ms). /// Returns false if the device cannot be opened. public bool Start() { if (_running) return false; _running = true; _captureThread = new Thread(CaptureThreadProc) { IsBackground = true, Name = "AuxInputCapture", }; _captureThread.Start(); bool ok = _initDone.Wait(5000) && _initOk; if (!ok) _running = false; return ok; } public void Stop() { _running = false; _bufferEvent?.Set(); _captureThread?.Join(500); try { _audioClient?.Stop(); } catch { /* device already gone */ } } public void Dispose() { Stop(); if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; } if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; } _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 (_audioClient!.SetEventHandle(_bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0) return false; return _audioClient.Start() >= 0; } private bool ActivateClient() { IMMDeviceEnumerator? enumerator = null; IMMDevice? device = null; try { enumerator = (IMMDeviceEnumerator)Activator.CreateInstance( Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; int hr = _deviceId is null ? enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, out device) : enumerator.GetDevice(_deviceId, out device); if (hr != 0 || device == null) return false; var iidAudioClient = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"); if (device.Activate(ref iidAudioClient, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object acObj) != 0 || acObj is not IAudioClient ac) return false; _audioClient = ac; return InitializeStream(); } catch { return false; } finally { if (device != null) Marshal.ReleaseComObject(device); if (enumerator != null) Marshal.ReleaseComObject(enumerator); } } private bool InitializeStream() { // Try s16 stereo first; fall back to s16 mono. AUTOCONVERTPCM lets the audio engine // resample/convert the device's native format to our requested 48 kHz s16; EVENTCALLBACK // drives the buffer-ready event. Shared mode (0), no LOOPBACK (this is a capture device). // AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000 // AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000 // AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000 const uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u; 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, }; IntPtr pFmt = Marshal.AllocHGlobal(Marshal.SizeOf()); try { Marshal.StructureToPtr(fmt, pFmt, false); int hr = _audioClient!.Initialize(0 /*AUDCLNT_SHAREMODE_SHARED*/, streamFlags, 2_000_000 /*200 ms hns*/, 0, pFmt, IntPtr.Zero); if (hr >= 0) { _channels = ch; _accumBuf = new short[FrameSamples * ch]; _accumCount = 0; var iidCapture = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"); if (_audioClient.GetService(ref iidCapture, out object ccObj) != 0 || ccObj is not IAudioCaptureClient cc) return false; _captureClient = cc; return true; } if (ch == 1) return false; } finally { Marshal.FreeHGlobal(pFmt); } } return false; } // ── Capture loop ───────────────────────────────────────────────────────── private void CaptureLoop() { while (_running) { _bufferEvent!.WaitOne(100); if (!_running) break; while (_running) { if (_captureClient!.GetNextPacketSize(out uint packetSize) < 0 || packetSize == 0) break; if (_captureClient.GetBuffer(out IntPtr dataPtr, out uint framesAvailable, out uint flags, out _, out _) < 0) break; bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT if (framesAvailable > 0) { if (silent) AccumulateSilence((int)framesAvailable); else AccumulatePcm(dataPtr, (int)framesAvailable); } _captureClient.ReleaseBuffer(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; } // ── COM declarations ─────────────────────────────────────────────────────── // // Declared internal here so InputDeviceEnumerator can share them. These are standard MMDevice // / WASAPI interfaces; a normal capture endpoint honours QueryInterface, so RCW marshalling is // safe (unlike ProcessLoopbackCapture's process-loopback objects, which need raw vtable calls). [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDeviceEnumerator { [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices); [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); } [ComImport, Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDeviceCollection { [PreserveSig] int GetCount(out int count); [PreserveSig] int Item(int index, out IMMDevice device); } [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDevice { [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); [PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr propStore); [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetState(out int state); } [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IPropertyStore { [PreserveSig] int GetCount(out int count); [PreserveSig] int GetAt(int index, out PropertyKey key); [PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value); [PreserveSig] int SetValue(ref PropertyKey key, ref PropVariant value); [PreserveSig] int Commit(); } [ComImport, Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioClient { [PreserveSig] int Initialize(int shareMode, uint streamFlags, long hnsBufferDuration, long hnsPeriodicity, IntPtr pFormat, IntPtr audioSessionGuid); [PreserveSig] int GetBufferSize(out uint numBufferFrames); [PreserveSig] int GetStreamLatency(out long latency); [PreserveSig] int GetCurrentPadding(out uint numPaddingFrames); [PreserveSig] int IsFormatSupported(int shareMode, IntPtr pFormat, out IntPtr closestMatch); [PreserveSig] int GetMixFormat(out IntPtr deviceFormat); [PreserveSig] int GetDevicePeriod(out long defaultDevicePeriod, out long minimumDevicePeriod); [PreserveSig] int Start(); [PreserveSig] int Stop(); [PreserveSig] int Reset(); [PreserveSig] int SetEventHandle(IntPtr eventHandle); [PreserveSig] int GetService(ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out object ppv); } [ComImport, Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioCaptureClient { [PreserveSig] int GetBuffer(out IntPtr data, out uint numFramesToRead, out uint flags, out ulong devicePosition, out ulong qpcPosition); [PreserveSig] int ReleaseBuffer(uint numFramesRead); [PreserveSig] int GetNextPacketSize(out uint numFramesInNextPacket); } [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; } [StructLayout(LayoutKind.Sequential)] internal struct PropertyKey { public Guid fmtid; public int pid; } // Minimal PROPVARIANT: we only ever read VT_LPWSTR (friendly name). x64 layout — the value // union starts at offset 8 after vt(2)+reserved(6). [StructLayout(LayoutKind.Explicit)] internal struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr pointerValue; } [DllImport("ole32.dll")] internal static extern int PropVariantClear(ref PropVariant pvar); }