Public Access
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
235 lines
7.6 KiB
C#
235 lines
7.6 KiB
C#
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<MicrophoneAudioSource> logger;
|
|
private readonly TimeSpan recoveryDelay;
|
|
private readonly MicrophoneSwitchMonitor? switchMonitor;
|
|
private readonly MicrophoneDeviceSelection? selection;
|
|
|
|
public MicrophoneAudioSource(
|
|
IMicrophoneCaptureSourceFactory captureSources,
|
|
ILogger<MicrophoneAudioSource> logger,
|
|
MicrophoneSwitchMonitor? switchMonitor = null,
|
|
MicrophoneDeviceSelection? selection = null)
|
|
: this(captureSources, logger, DefaultRecoveryDelay, switchMonitor, selection)
|
|
{
|
|
}
|
|
|
|
internal MicrophoneAudioSource(
|
|
IMicrophoneCaptureSourceFactory captureSources,
|
|
ILogger<MicrophoneAudioSource> 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<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
|
{
|
|
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
|
}
|
|
|
|
public async IAsyncEnumerable<AudioChunk> 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<AudioChunk> CaptureWithRecoveryAsync(
|
|
MeetingAssistantOptions options,
|
|
CaptureSession session,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
var failedAttempts = 0;
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var captureToken = session.BeginCapture();
|
|
IMeetingAudioSource? captureSource = null;
|
|
IAsyncEnumerator<AudioChunk>? 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<bool> 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();
|
|
}
|
|
}
|
|
}
|