Port managed client audio and Windows application
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 16:48:06 +02:00
parent 5a226ba543
commit 82ad4c2811
56 changed files with 2304 additions and 250 deletions
@@ -16,7 +16,7 @@ public sealed record InputDeviceInfo(string Id, string Name, bool IsDefault)
/// 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()
public static IReadOnlyList<InputDeviceInfo> List(bool input = true)
{
var result = new List<InputDeviceInfo>();
InputDeviceCapture.IMMDeviceEnumerator? enumerator = null;
@@ -29,7 +29,7 @@ public static class InputDeviceEnumerator
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*/,
if (enumerator.GetDefaultAudioEndpoint(input ? 1 : 0, 0 /*eConsole*/,
out var defDev) == 0 && defDev != null)
{
try { if (defDev.GetId(out string id) == 0) defaultId = id; }
@@ -37,7 +37,7 @@ public static class InputDeviceEnumerator
}
// DEVICE_STATE_ACTIVE = 0x1 — only currently-usable endpoints.
if (enumerator.EnumAudioEndpoints(1 /*eCapture*/, 0x1, out collectionPtr) != 0
if (enumerator.EnumAudioEndpoints(input ? 1 : 0, 0x1, out collectionPtr) != 0
|| collectionPtr == IntPtr.Zero)
return result;
@@ -107,6 +107,7 @@ public sealed class InputDeviceCapture : IDisposable
private const int FrameSamples = 960; // 20 ms
private readonly string? _deviceId; // null = system default capture endpoint
private readonly bool _loopback;
private IAudioClient? _audioClient;
private IAudioCaptureClient? _captureClient;
@@ -123,8 +124,9 @@ public sealed class InputDeviceCapture : IDisposable
// Init-done signal: Set() by the capture thread after activation completes.
private readonly ManualResetEventSlim _initDone = new(false);
private bool _initOk;
private int _disposed;
public InputDeviceCapture(string? deviceId) => _deviceId = deviceId;
public InputDeviceCapture(string? deviceId, bool loopback = false) { _deviceId = deviceId; _loopback = loopback; }
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically &lt;100 ms).
/// Returns false if the device cannot be opened.</summary>
@@ -148,15 +150,13 @@ public sealed class InputDeviceCapture : IDisposable
{
_running = false;
_bufferEvent?.Set();
_captureThread?.Join(500);
try { _audioClient?.Stop(); } catch { /* device already gone */ }
if (_captureThread is not null && !_captureThread.Join(5000)) throw new TimeoutException("Capture did not stop.");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
Stop();
if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; }
if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; }
_bufferEvent?.Dispose();
_initDone.Dispose();
}
@@ -165,10 +165,19 @@ public sealed class InputDeviceCapture : IDisposable
private void CaptureThreadProc()
{
_initOk = ActivateAndStart();
_initDone.Set();
if (!_initOk) return;
CaptureLoop();
try
{
_initOk = ActivateAndStart();
_initDone.Set();
if (_initOk && _running) CaptureLoop();
}
catch { _initOk = false; _initDone.Set(); }
finally
{
try { _audioClient?.Stop(); } catch { }
if (_captureClient != null) { Marshal.ReleaseComObject(_captureClient); _captureClient = null; }
if (_audioClient != null) { Marshal.ReleaseComObject(_audioClient); _audioClient = null; }
}
}
private bool ActivateAndStart()
@@ -192,7 +201,7 @@ public sealed class InputDeviceCapture : IDisposable
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
int hr = _deviceId is null
? enumerator.GetDefaultAudioEndpoint(1 /*eCapture*/, 0 /*eConsole*/, out device)
? enumerator.GetDefaultAudioEndpoint(_loopback ? 0 : 1, 0 /*eConsole*/, out device)
: enumerator.GetDevice(_deviceId, out device);
if (hr != 0 || device == null) return false;
@@ -220,7 +229,7 @@ public sealed class InputDeviceCapture : IDisposable
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
// AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY = 0x08000000
const uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u;
uint streamFlags = 0x00040000u | 0x80000000u | 0x08000000u | (_loopback ? 0x00020000u : 0u);
foreach (int ch in new[] { 2, 1 })
{
@@ -327,9 +336,7 @@ public sealed class InputDeviceCapture : IDisposable
private void FlushFrame()
{
var copy = new short[_accumBuf.Length];
_accumBuf.AsSpan().CopyTo(copy);
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
PcmFrameReady?.Invoke(_accumBuf, FrameSamples, _channels); // Borrowed until callback returns.
_accumCount = 0;
}