using System.Runtime.InteropServices; using VoiceCat.Audio; using static VoiceCat.App.Audio.InputDeviceCapture; namespace VoiceCat.App.Audio; public sealed class WasapiAudioBackend : IAudioDeviceBackend { public IReadOnlyList Enumerate(bool input) => InputDeviceEnumerator.List(input).Select(d => new AudioDeviceInfo(d.Id, d.Name, d.IsDefault)).ToArray(); public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler handler) { var capture = new InputDeviceCapture(NormalizeDeviceId(deviceId), loopback); capture.PcmFrameReady += (pcm, _, channels) => handler(pcm, channels); if (!capture.Start()) { capture.Dispose(); throw new InvalidOperationException("WASAPI capture could not start."); } return new Capture(capture); } public IAudioPlayback OpenPlayback(string? deviceId = null) => new Playback(deviceId); // The old miniaudio ABI persisted its raw device union as hex. Its Windows member // is a null-terminated UTF-16 WASAPI endpoint id; preserve saved selections. internal static string? NormalizeDeviceId(string? id) { if (string.IsNullOrEmpty(id)) return null; if (id.StartsWith('{')) return id; try { byte[] bytes = Convert.FromHexString(id); string decoded = System.Text.Encoding.Unicode.GetString(bytes).Split('\0')[0]; return decoded.StartsWith('{') ? decoded : null; } catch (FormatException) { return id; } } private sealed class Capture(InputDeviceCapture capture) : IAudioCapture { public void Dispose() => capture.Dispose(); } private sealed class Playback : IAudioPlayback { private readonly string? deviceId; private readonly AdaptivePcmBuffer pcm = new(2); private readonly ManualResetEventSlim initialized = new(false); private readonly Thread thread; private volatile bool running = true; private bool ready; private int disposed; internal Playback(string? deviceId) { this.deviceId = deviceId; thread = new Thread(Work) { IsBackground = true, Name = "VoiceCat WASAPI playback" }; thread.Start(); if (!initialized.Wait(5000) || !ready) { Dispose(); throw new InvalidOperationException("WASAPI playback could not start."); } } public int BufferMilliseconds { get => pcm.BufferMilliseconds; set => pcm.BufferMilliseconds = value; } public void Write(ReadOnlySpan stereoPcm) => pcm.TryWrite(stereoPcm); private unsafe void Work() { IMMDeviceEnumerator? enumerator = null; IMMDevice? device = null; IAudioClient? client = null; IAudioRenderClient? render = null; using var bufferReady = new AutoResetEvent(false); try { enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!; int result = deviceId is null ? enumerator.GetDefaultAudioEndpoint(0, 0, out device) : enumerator.GetDevice(deviceId, out device); if (result < 0 || device is null) return; Guid iid = typeof(IAudioClient).GUID; if (device.Activate(ref iid, 0x17, 0, out object audio) < 0) return; client = (IAudioClient)audio; WaveFormat format = new() { Format = 1, Channels = 2, Samples = 48000, Bytes = 192000, Align = 4, Bits = 16 }; if (client.Initialize(0, 0x00040000u | 0x80000000u | 0x08000000u, 600000, 0, (nint)(&format), 0) < 0) return; if (client.GetBufferSize(out uint capacity) < 0 || client.SetEventHandle(bufferReady.SafeWaitHandle.DangerousGetHandle()) < 0) return; iid = typeof(IAudioRenderClient).GUID; if (client.GetService(ref iid, out object output) < 0) return; render = (IAudioRenderClient)output; if (client.Start() < 0) return; ready = true; initialized.Set(); while (running) { bufferReady.WaitOne(20); // Scheduling wait is outside the buffer-fill cycle. if (!running || client.GetCurrentPadding(out uint padding) < 0) break; uint frames = capacity - Math.Min(capacity, padding); if (frames == 0) continue; if (render.GetBuffer(frames, out nint buffer) < 0) break; var destination = new Span((void*)buffer, checked((int)frames * 2)); int count = pcm.Read(destination); destination[count..].Clear(); if (render.ReleaseBuffer(frames, 0) < 0) break; } } catch { ready = false; } finally { initialized.Set(); try { client?.Stop(); } catch { } if (render is not null) Marshal.ReleaseComObject(render); if (client is not null) Marshal.ReleaseComObject(client); if (device is not null) Marshal.ReleaseComObject(device); if (enumerator is not null) Marshal.ReleaseComObject(enumerator); } } public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; running = false; if (!thread.Join(5000)) throw new TimeoutException("WASAPI playback did not stop."); initialized.Dispose(); } } [StructLayout(LayoutKind.Sequential, Pack = 2)] private struct WaveFormat { internal ushort Format, Channels; internal uint Samples, Bytes; internal ushort Align, Bits, Extra; } [ComImport, Guid("F294ACFC-3146-4483-A7BF-ADDCA7C260E2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IAudioRenderClient { [PreserveSig] int GetBuffer(uint frames, out nint data); [PreserveSig] int ReleaseBuffer(uint frames, uint flags); } }