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.
249 lines
11 KiB
C#
249 lines
11 KiB
C#
using MeetingAssistant.Recording;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace MeetingAssistant.Tests;
|
|
|
|
public sealed class MicrophoneAudioSourceTests
|
|
{
|
|
[Fact]
|
|
public async Task CaptureMovesToNewlyResolvedMicrophoneWhenCurrentCaptureFails()
|
|
{
|
|
var captureSources = new SequenceMicrophoneCaptureSourceFactory(
|
|
new FailingAfterChunkAudioSource(Pcm16(1_000)),
|
|
new ActiveAudioSource(Pcm16(2_000)));
|
|
var source = new MicrophoneAudioSource(
|
|
captureSources,
|
|
NullLogger<MicrophoneAudioSource>.Instance,
|
|
TimeSpan.Zero);
|
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await using var chunks = source
|
|
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
|
|
.GetAsyncEnumerator(cancellation.Token);
|
|
|
|
Assert.True(await chunks.MoveNextAsync());
|
|
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
Assert.True(await chunks.MoveNextAsync());
|
|
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
Assert.Equal(2, captureSources.CaptureCreationCount);
|
|
|
|
await cancellation.CancelAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CaptureWaitsForMicrophoneToBecomeAvailable()
|
|
{
|
|
var captureSources = new InitiallyUnavailableMicrophoneCaptureSourceFactory(
|
|
new ActiveAudioSource(Pcm16(3_000)));
|
|
var source = new MicrophoneAudioSource(
|
|
captureSources,
|
|
NullLogger<MicrophoneAudioSource>.Instance,
|
|
TimeSpan.Zero);
|
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await using var chunks = source
|
|
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
|
|
.GetAsyncEnumerator(cancellation.Token);
|
|
|
|
Assert.True(await chunks.MoveNextAsync());
|
|
Assert.Equal(3_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
Assert.Equal(2, captureSources.CaptureCreationCount);
|
|
|
|
await cancellation.CancelAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcceptedNewMicrophoneReplacesCaptureWithinTheSameStream()
|
|
{
|
|
var selection = new MicrophoneDeviceSelection();
|
|
var devices = new SelectableCaptureDevices(selection);
|
|
var prompts = new AcceptingPrompt();
|
|
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
|
|
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
|
|
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
|
|
TimeSpan.Zero, monitor, selection);
|
|
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
|
|
try
|
|
{
|
|
Assert.True(await chunks.MoveNextAsync());
|
|
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
devices.Available = [new("old", "Built-in"), new("new", "USB headset")];
|
|
Assert.Equal("new", (await prompts.Shown.Task.WaitAsync(TimeSpan.FromSeconds(2))).Id);
|
|
var nextChunk = chunks.MoveNextAsync().AsTask();
|
|
Assert.False(nextChunk.IsCompleted);
|
|
Assert.Null(selection.SelectedDeviceId);
|
|
|
|
prompts.Response.SetResult(true);
|
|
|
|
Assert.True(await nextChunk.WaitAsync(TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
Assert.Equal("new", selection.SelectedDeviceId);
|
|
Assert.False(lifetime.IsCancellationRequested);
|
|
}
|
|
finally
|
|
{
|
|
await lifetime.CancelAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EndingMeetingDuringCreationDisposesAbandonedCapture()
|
|
{
|
|
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var abandonedCapture = new DisposableAudioSource();
|
|
var factory = new CallbackCaptureFactory(() =>
|
|
{
|
|
lifetime.Cancel();
|
|
return abandonedCapture;
|
|
});
|
|
var source = new MicrophoneAudioSource(factory, NullLogger<MicrophoneAudioSource>.Instance);
|
|
await using var chunks = source.CaptureAsync(lifetime.Token).GetAsyncEnumerator();
|
|
|
|
Assert.False(await chunks.MoveNextAsync());
|
|
Assert.True(abandonedCapture.Disposed);
|
|
}
|
|
|
|
private sealed class CallbackCaptureFactory(Func<IMeetingAudioSource> create) : IMicrophoneCaptureSourceFactory
|
|
{
|
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options) => create();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcceptedSwitchDuringCreationDisposesAbandonedCapture()
|
|
{
|
|
var selection = new MicrophoneDeviceSelection();
|
|
var devices = new SwitchDuringCreationDevices(selection);
|
|
var prompts = new AcceptingPrompt();
|
|
prompts.Response.SetResult(true);
|
|
var monitor = new MicrophoneSwitchMonitor(devices, prompts,
|
|
NullLogger<MicrophoneSwitchMonitor>.Instance, TimeSpan.FromMilliseconds(10), TimeSpan.FromMinutes(1));
|
|
var source = new MicrophoneAudioSource(devices, NullLogger<MicrophoneAudioSource>.Instance,
|
|
TimeSpan.Zero, monitor, selection);
|
|
using var lifetime = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
await using var chunks = source.CaptureAsync(new MeetingAssistantOptions(), lifetime.Token).GetAsyncEnumerator();
|
|
try
|
|
{
|
|
Assert.True(await chunks.MoveNextAsync());
|
|
Assert.Equal("new", selection.SelectedDeviceId);
|
|
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
|
Assert.True(devices.AbandonedCapture.Disposed);
|
|
}
|
|
finally
|
|
{
|
|
await lifetime.CancelAsync();
|
|
}
|
|
}
|
|
|
|
private sealed class SwitchDuringCreationDevices(MicrophoneDeviceSelection selection)
|
|
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
|
{
|
|
private MicrophoneDevice[] available = [new("old", "Built-in")];
|
|
private int creations;
|
|
private volatile bool switchObserved;
|
|
public DisposableAudioSource AbandonedCapture { get; } = new();
|
|
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
|
|
{
|
|
if (selection.SelectedDeviceId == "new") switchObserved = true;
|
|
return Volatile.Read(ref available);
|
|
}
|
|
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
|
=> new(GetAvailableMicrophones(), available[0]);
|
|
|
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
|
{
|
|
if (++creations > 1) return new ActiveAudioSource(Pcm16(2_000));
|
|
Volatile.Write(ref available, [new("old", "Built-in"), new("new", "USB headset")]);
|
|
// Let the acceptance callback finish before returning the already-opened source.
|
|
if (!SpinWait.SpinUntil(() => switchObserved, TimeSpan.FromSeconds(2)))
|
|
throw new TimeoutException("The new microphone was not selected during capture creation.");
|
|
return AbandonedCapture;
|
|
}
|
|
}
|
|
|
|
private sealed class DisposableAudioSource : IMeetingAudioSource, IDisposable
|
|
{
|
|
public bool Disposed { get; private set; }
|
|
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
|
=> new ActiveAudioSource(Pcm16(1_000)).CaptureAsync(cancellationToken);
|
|
public void Dispose() => Disposed = true;
|
|
}
|
|
|
|
private sealed class AcceptingPrompt : IMicrophoneSwitchPromptService
|
|
{
|
|
public TaskCompletionSource<MicrophoneDevice> Shown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public TaskCompletionSource<bool> Response { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
|
{
|
|
Shown.TrySetResult(microphone);
|
|
return Response.Task.WaitAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
private sealed class SelectableCaptureDevices(MicrophoneDeviceSelection selection)
|
|
: IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
|
{
|
|
public volatile MicrophoneDevice[] Available = [new("old", "Built-in")];
|
|
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones() => Available;
|
|
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
|
=> new(Available, selection.Resolve(options.Recording.MicrophoneDeviceId, Available[0], Available));
|
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
|
=> new ActiveAudioSource(Pcm16(GetMicrophoneSnapshot(options).Current!.Id == "new" ? (short)2_000 : (short)1_000));
|
|
}
|
|
|
|
private static byte[] Pcm16(short sample)
|
|
{
|
|
return BitConverter.GetBytes(sample);
|
|
}
|
|
|
|
private sealed class SequenceMicrophoneCaptureSourceFactory(params IMeetingAudioSource[] sources)
|
|
: IMicrophoneCaptureSourceFactory
|
|
{
|
|
private readonly Queue<IMeetingAudioSource> sources = new(sources);
|
|
|
|
public int CaptureCreationCount { get; private set; }
|
|
|
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
|
{
|
|
CaptureCreationCount++;
|
|
return sources.Dequeue();
|
|
}
|
|
}
|
|
|
|
private sealed class InitiallyUnavailableMicrophoneCaptureSourceFactory(IMeetingAudioSource availableSource)
|
|
: IMicrophoneCaptureSourceFactory
|
|
{
|
|
public int CaptureCreationCount { get; private set; }
|
|
|
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
|
{
|
|
CaptureCreationCount++;
|
|
if (CaptureCreationCount == 1)
|
|
{
|
|
throw new InvalidOperationException("No microphone is currently available.");
|
|
}
|
|
|
|
return availableSource;
|
|
}
|
|
}
|
|
|
|
private sealed class FailingAfterChunkAudioSource(byte[] pcm) : IMeetingAudioSource
|
|
{
|
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.Yield();
|
|
yield return new AudioChunk(pcm, 16000, 1);
|
|
throw new InvalidOperationException("The active microphone was disconnected.");
|
|
}
|
|
}
|
|
|
|
private sealed class ActiveAudioSource(byte[] pcm) : IMeetingAudioSource
|
|
{
|
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
yield return new AudioChunk(pcm, 16000, 1);
|
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
}
|
|
}
|
|
}
|