Files
codex 7b2bcd3631
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Preserve recovery transcripts and add user-approved agent requests
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.
2026-09-25 11:58:34 +02:00

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