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.
115 lines
4.3 KiB
C#
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);
|
|
}
|