444 lines
18 KiB
C#
444 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>An audio input (capture) endpoint for the aux-stream device picker. <see cref="Id"/>
|
|
/// is a WASAPI endpoint id (round-trip only; never construct by hand) — pass null to capture the
|
|
/// system default. <see cref="ToString"/> returns the friendly name for ComboBox display.</summary>
|
|
public sealed record InputDeviceInfo(string Id, string Name, bool IsDefault)
|
|
{
|
|
public override string ToString() => Name;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public static class InputDeviceEnumerator
|
|
{
|
|
public static IReadOnlyList<InputDeviceInfo> List()
|
|
{
|
|
var result = new List<InputDeviceInfo>();
|
|
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); }
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public sealed class InputDeviceCapture : IDisposable
|
|
{
|
|
/// <summary>Fired on the capture thread every 20 ms: (interleaved s16 PCM, samplesPerChannel
|
|
/// = 960, channels).</summary>
|
|
public event Action<short[], int /*samplesPerChannel*/, int /*channels*/>? 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;
|
|
|
|
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically <100 ms).
|
|
/// Returns false if the device cannot be opened.</summary>
|
|
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<WaveFormatEx>());
|
|
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);
|
|
}
|