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

115 lines
4.3 KiB
C#

namespace MeetingAssistant.Recording;
public sealed class MicrophoneSwitchMonitor
{
private readonly IMicrophoneDeviceProvider devices;
private readonly IMicrophoneSwitchPromptService prompts;
private readonly ILogger<MicrophoneSwitchMonitor> logger;
private readonly TimeSpan pollInterval;
private readonly TimeSpan promptLifetime;
public MicrophoneSwitchMonitor(
IMicrophoneDeviceProvider devices,
IMicrophoneSwitchPromptService prompts,
ILogger<MicrophoneSwitchMonitor> logger)
: this(devices, prompts, logger, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))
{
}
internal MicrophoneSwitchMonitor(
IMicrophoneDeviceProvider devices,
IMicrophoneSwitchPromptService prompts,
ILogger<MicrophoneSwitchMonitor> logger,
TimeSpan pollInterval,
TimeSpan promptLifetime)
{
this.devices = devices;
this.prompts = prompts;
this.logger = logger;
this.pollInterval = pollInterval;
this.promptLifetime = promptLifetime;
}
public async Task RunAsync(Action<MicrophoneDevice, CancellationToken> switchMicrophone, CancellationToken cancellationToken)
{
var known = (ReadDevices() ?? []).Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
var pending = new Dictionary<string, PendingPrompt>(StringComparer.OrdinalIgnoreCase);
try
{
while (true)
{
await Task.Delay(pollInterval, cancellationToken);
var available = ReadDevices();
if (available is null) continue;
var availableIds = available.Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (id, prompt) in pending.ToArray())
{
if (!availableIds.Contains(id)) await prompt.Cancellation.CancelAsync();
if (prompt.Task.IsCompleted)
{
prompt.Cancellation.Dispose();
pending.Remove(id);
}
}
foreach (var device in available)
{
if (known.Add(device.Id))
{
var promptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
promptCancellation.CancelAfter(promptLifetime);
pending.Add(device.Id, new PendingPrompt(promptCancellation,
OfferAsync(device, switchMicrophone, promptCancellation.Token)));
}
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
finally
{
foreach (var prompt in pending.Values) await prompt.Cancellation.CancelAsync();
await Task.WhenAll(pending.Values.Select(prompt => prompt.Task));
foreach (var prompt in pending.Values) prompt.Cancellation.Dispose();
}
}
private async Task OfferAsync(MicrophoneDevice device, Action<MicrophoneDevice, CancellationToken> switchMicrophone,
CancellationToken cancellationToken)
{
try
{
if (await prompts.ConfirmSwitchAsync(device, cancellationToken)
&& !cancellationToken.IsCancellationRequested
&& ReadDevices()?.Any(current => current.Id.Equals(device.Id, StringComparison.OrdinalIgnoreCase)) == true
&& !cancellationToken.IsCancellationRequested)
{
switchMicrophone(device, cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not offer microphone {MicrophoneName}", device.Name);
}
}
private IReadOnlyList<MicrophoneDevice>? ReadDevices()
{
try
{
return devices.GetAvailableMicrophones();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not check for newly available microphones");
return null;
}
}
private sealed record PendingPrompt(CancellationTokenSource Cancellation, Task Task);
}