using System.Runtime.CompilerServices; namespace MeetingAssistant.Recording; public sealed class MicrophoneAudioSource : IMeetingAudioSource { private static readonly TimeSpan DefaultRecoveryDelay = TimeSpan.FromSeconds(1); private readonly IMicrophoneCaptureSourceFactory captureSources; private readonly ILogger logger; private readonly TimeSpan recoveryDelay; private readonly MicrophoneSwitchMonitor? switchMonitor; private readonly MicrophoneDeviceSelection? selection; public MicrophoneAudioSource( IMicrophoneCaptureSourceFactory captureSources, ILogger logger, MicrophoneSwitchMonitor? switchMonitor = null, MicrophoneDeviceSelection? selection = null) : this(captureSources, logger, DefaultRecoveryDelay, switchMonitor, selection) { } internal MicrophoneAudioSource( IMicrophoneCaptureSourceFactory captureSources, ILogger logger, TimeSpan recoveryDelay, MicrophoneSwitchMonitor? switchMonitor = null, MicrophoneDeviceSelection? selection = null) { this.captureSources = captureSources; this.logger = logger; this.recoveryDelay = recoveryDelay; this.switchMonitor = switchMonitor; this.selection = selection; } public IAsyncEnumerable CaptureAsync(CancellationToken cancellationToken) { return CaptureAsync(new MeetingAssistantOptions(), cancellationToken); } public async IAsyncEnumerable CaptureAsync( MeetingAssistantOptions options, [EnumeratorCancellation] CancellationToken cancellationToken) { using var session = new CaptureSession(selection, cancellationToken); var monitoring = switchMonitor?.RunAsync(session.SwitchTo, session.Token) ?? Task.CompletedTask; try { await foreach (var chunk in CaptureWithRecoveryAsync(options, session, session.Token)) { yield return chunk; } } finally { session.Stop(); await monitoring; } } private async IAsyncEnumerable CaptureWithRecoveryAsync( MeetingAssistantOptions options, CaptureSession session, [EnumeratorCancellation] CancellationToken cancellationToken) { var failedAttempts = 0; while (!cancellationToken.IsCancellationRequested) { var captureToken = session.BeginCapture(); IMeetingAudioSource? captureSource = null; IAsyncEnumerator? capture = null; Exception? failure = null; try { captureSource = captureSources.CreateCapture(options); capture = captureSource .CaptureAsync(options, captureToken) .GetAsyncEnumerator(captureToken); } catch (OperationCanceledException) when (captureToken.IsCancellationRequested) { } catch (Exception exception) { failure = exception; } using var captureOwner = captureSource as IDisposable; if (cancellationToken.IsCancellationRequested) { yield break; } if (capture is not null) { try { while (!captureToken.IsCancellationRequested) { var hasNext = false; try { hasNext = await capture.MoveNextAsync(); } catch (OperationCanceledException) when (captureToken.IsCancellationRequested) { } catch (Exception exception) { failure = exception; } if (captureToken.IsCancellationRequested || failure is not null || !hasNext) { break; } if (failedAttempts > 0) { logger.LogInformation( "Microphone capture recovered after {FailedAttemptCount} failed attempt(s)", failedAttempts); failedAttempts = 0; } yield return capture.Current; } } finally { try { await capture.DisposeAsync(); } catch (OperationCanceledException) when (captureToken.IsCancellationRequested) { } catch (Exception exception) { failure ??= exception; } } } var switchRequested = captureToken.IsCancellationRequested; session.EndCapture(); if (cancellationToken.IsCancellationRequested) { yield break; } if (switchRequested) { logger.LogInformation("Replacing microphone capture after an accepted device-switch notification"); continue; } failedAttempts++; logger.LogWarning( failure, "Microphone capture stopped unexpectedly; re-resolving an available microphone in {RecoveryDelay}", recoveryDelay); if (!await WaitForRecoveryAsync(cancellationToken)) { yield break; } } } private async Task WaitForRecoveryAsync(CancellationToken cancellationToken) { try { await Task.Delay(recoveryDelay, cancellationToken); return true; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return false; } } private sealed class CaptureSession(MicrophoneDeviceSelection? selection, CancellationToken cancellationToken) : IDisposable { private readonly object gate = new(); private readonly CancellationTokenSource lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); private CancellationTokenSource? capture; public CancellationToken Token => lifetime.Token; public CancellationToken BeginCapture() { lock (gate) { capture = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); return capture.Token; } } public void EndCapture() { lock (gate) { capture?.Dispose(); capture = null; } } public void SwitchTo(MicrophoneDevice device, CancellationToken promptCancellation) { lock (gate) { if (lifetime.IsCancellationRequested || promptCancellation.IsCancellationRequested || selection is null) return; selection.Select(device.Id); capture?.Cancel(); } } public void Stop() { lock (gate) lifetime.Cancel(); } public void Dispose() { Stop(); EndCapture(); lifetime.Dispose(); } } }