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(); } 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); } } }