114 lines
6.0 KiB
C#
114 lines
6.0 KiB
C#
using System.Runtime.InteropServices;
|
|||
|
|
using VoiceCat.Audio;
|
||
|
|
using static VoiceCat.App.Audio.InputDeviceCapture;
|
||
|
|
|
||
|
|
namespace VoiceCat.App.Audio;
|
||
|
|
|
||
|
|
public sealed class WasapiAudioBackend : IAudioDeviceBackend
|
||
|
|
{
|
||
|
|
public IReadOnlyList<AudioDeviceInfo> 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 PcmRing pcm = new(32768);
|
||
|
|
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 void Write(ReadOnlySpan<short> 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();
|
||
|
|
Span<short> discard = stackalloc short[1920];
|
||
|
|
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<short>((void*)buffer, checked((int)frames * 2));
|
||
|
|
// Keep queued playback bounded to ~120 ms, then fill underflow with silence.
|
||
|
|
while (pcm.Count > 11520) pcm.Read(discard);
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|