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);
}