using MeetingAssistant.Recording; using Microsoft.Extensions.Logging.Abstractions; namespace MeetingAssistant.Tests; public sealed class MicrophoneAudioSourceTests { [Fact] public async Task CaptureMovesToNewlyResolvedMicrophoneWhenCurrentCaptureFails() { var captureSources = new SequenceMicrophoneCaptureSourceFactory( new FailingAfterChunkAudioSource(Pcm16(1_000)), new ActiveAudioSource(Pcm16(2_000))); var source = new MicrophoneAudioSource( captureSources, NullLogger.Instance, TimeSpan.Zero); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var chunks = source .CaptureAsync(new MeetingAssistantOptions(), cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await chunks.MoveNextAsync()); Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm)); Assert.True(await chunks.MoveNextAsync()); Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm)); Assert.Equal(2, captureSources.CaptureCreationCount); await cancellation.CancelAsync(); } [Fact] public async Task CaptureWaitsForMicrophoneToBecomeAvailable() { var captureSources = new InitiallyUnavailableMicrophoneCaptureSourceFactory( new ActiveAudioSource(Pcm16(3_000))); var source = new MicrophoneAudioSource( captureSources, NullLogger.Instance, TimeSpan.Zero); using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var chunks = source .CaptureAsync(new MeetingAssistantOptions(), cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await chunks.MoveNextAsync()); Assert.Equal(3_000, BitConverter.ToInt16(chunks.Current.Pcm)); Assert.Equal(2, captureSources.CaptureCreationCount); await cancellation.CancelAsync(); } [Fact] public async Task AcceptedNewMicrophoneReplacesCaptureWithinTheSameStream() { var selection = new MicrophoneDeviceSelection(); var devices = new SelectableCaptureDevices(selection); var prompts = new AcceptingPrompt(); var monitor = new MicrophoneSwitchMonitor(devices, prompts, NullLogger.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1)); var source = new MicrophoneAudioSource(devices, NullLogger.Instance, TimeSpan.Zero, monitor, selection); using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator(); try { Assert.True(await chunks.MoveNextAsync()); Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm)); devices.Available = [new("old", "Built-in"), new("new", "USB headset")]; Assert.Equal("new", (await prompts.Shown.Task.WaitAsync(TimeSpan.FromSeconds(2))).Id); var nextChunk = chunks.MoveNextAsync().AsTask(); Assert.False(nextChunk.IsCompleted); Assert.Null(selection.SelectedDeviceId); prompts.Response.SetResult(true); Assert.True(await nextChunk.WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm)); Assert.Equal("new", selection.SelectedDeviceId); Assert.False(lifetime.IsCancellationRequested); } finally { await lifetime.CancelAsync(); } } [Fact] public async Task EndingMeetingDuringCreationDisposesAbandonedCapture() { using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var abandonedCapture = new DisposableAudioSource(); var factory = new CallbackCaptureFactory(() => { lifetime.Cancel(); return abandonedCapture; }); var source = new MicrophoneAudioSource(factory, NullLogger.Instance); await using var chunks = source.CaptureAsync(lifetime.Token).GetAsyncEnumerator(); Assert.False(await chunks.MoveNextAsync()); Assert.True(abandonedCapture.Disposed); } private sealed class CallbackCaptureFactory(Func create) : IMicrophoneCaptureSourceFactory { public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) => create(); } [Fact] public async Task AcceptedSwitchDuringCreationDisposesAbandonedCapture() { var selection = new MicrophoneDeviceSelection(); var devices = new SwitchDuringCreationDevices(selection); var prompts = new AcceptingPrompt(); prompts.Response.SetResult(true); var monitor = new MicrophoneSwitchMonitor(devices, prompts, NullLogger.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1)); var source = new MicrophoneAudioSource(devices, NullLogger.Instance, TimeSpan.Zero, monitor, selection); using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator(); try { Assert.True(await chunks.MoveNextAsync()); Assert.Equal("new", selection.SelectedDeviceId); Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm)); Assert.True(devices.AbandonedCapture.Disposed); } finally { await lifetime.CancelAsync(); } } private sealed class SwitchDuringCreationDevices(MicrophoneDeviceSelection selection) : IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory { private MicrophoneDevice[] available = [new("old", "Built-in")]; private int creations; private volatile bool switchObserved; public DisposableAudioSource AbandonedCapture { get; } = new(); public IReadOnlyList GetAvailableMicrophones() { if (selection.SelectedDeviceId == "new") switchObserved = true; return Volatile.Read(ref available); } public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options) => new(GetAvailableMicrophones(), available[0]); public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) { if (++creations > 1) return new ActiveAudioSource(Pcm16(2_000)); Volatile.Write(ref available, [new("old", "Built-in"), new("new", "USB headset")]); // Let the acceptance callback finish before returning the already-opened source. if (!SpinWait.SpinUntil(() => switchObserved, TimeSpan.FromSeconds(2))) throw new TimeoutException("The new microphone was not selected during capture creation."); return AbandonedCapture; } } private sealed class DisposableAudioSource : IMeetingAudioSource, IDisposable { public bool Disposed { get; private set; } public IAsyncEnumerable CaptureAsync(CancellationToken cancellationToken) => new ActiveAudioSource(Pcm16(1_000)).CaptureAsync(cancellationToken); public void Dispose() => Disposed = true; } private sealed class AcceptingPrompt : IMicrophoneSwitchPromptService { public TaskCompletionSource Shown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken) { Shown.TrySetResult(microphone); return Response.Task.WaitAsync(cancellationToken); } } private sealed class SelectableCaptureDevices(MicrophoneDeviceSelection selection) : IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory { public volatile MicrophoneDevice[] Available = [new("old", "Built-in")]; public IReadOnlyList GetAvailableMicrophones() => Available; public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options) => new(Available, selection.Resolve(options.Recording.MicrophoneDeviceId, Available[0], Available)); public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) => new ActiveAudioSource(Pcm16(GetMicrophoneSnapshot(options).Current!.Id == "new" ? (short)2_000 : (short)1_000)); } private static byte[] Pcm16(short sample) { return BitConverter.GetBytes(sample); } private sealed class SequenceMicrophoneCaptureSourceFactory(params IMeetingAudioSource[] sources) : IMicrophoneCaptureSourceFactory { private readonly Queue sources = new(sources); public int CaptureCreationCount { get; private set; } public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) { CaptureCreationCount++; return sources.Dequeue(); } } private sealed class InitiallyUnavailableMicrophoneCaptureSourceFactory(IMeetingAudioSource availableSource) : IMicrophoneCaptureSourceFactory { public int CaptureCreationCount { get; private set; } public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) { CaptureCreationCount++; if (CaptureCreationCount == 1) { throw new InvalidOperationException("No microphone is currently available."); } return availableSource; } } private sealed class FailingAfterChunkAudioSource(byte[] pcm) : IMeetingAudioSource { public async IAsyncEnumerable CaptureAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Yield(); yield return new AudioChunk(pcm, 16000, 1); throw new InvalidOperationException("The active microphone was disconnected."); } } private sealed class ActiveAudioSource(byte[] pcm) : IMeetingAudioSource { public async IAsyncEnumerable CaptureAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { yield return new AudioChunk(pcm, 16000, 1); await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } } }