using MeetingAssistant.Recording; using MeetingAssistant.Transcription; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Summary; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Threading.Channels; namespace MeetingAssistant.Tests; public sealed class RecordingCoordinatorTests { [Fact] public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt() { var audioSource = new ControlledAudioSource(); var provider = new EchoStreamingTranscriptionProvider(); var store = new InMemoryTranscriptStore(); var noteStore = new InMemoryMeetingNoteStore(); var noteOpener = new CapturingMeetingNoteOpener(); var artifactStore = new InMemoryMeetingArtifactStore(); var audioArchive = new InMemoryRecordedAudioStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), store, noteStore, noteOpener, artifactStore, audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); var started = await coordinator.ToggleAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0 }, 16000, 1), CancellationToken.None); await store.WaitForTextAsync("chunk:2"); var stopped = await coordinator.ToggleAsync(CancellationToken.None); Assert.True(started.IsRecording); Assert.Equal(noteStore.SavedNote?.Path, noteOpener.OpenedPath); Assert.Equal(started.MeetingNotePath, noteOpener.OpenedPath); Assert.Equal(started.MeetingNotePath, artifactStore.CreatedArtifacts?.MeetingNotePath); Assert.True(provider.FirstChunkWasObservedBeforeSourceCompleted); Assert.False(stopped.IsRecording); } [Fact] public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md"); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md"); var noteOpener = new CapturingMeetingNoteOpener(); var artifactStore = new InMemoryMeetingArtifactStore(); var audioArchive = new InMemoryRecordedAudioStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), transcriptStore, noteStore, noteOpener, artifactStore, audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { Vault = new VaultOptions { AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context", SummariesFolder = "C:\\Vault\\Meetings\\Summaries" } }), NullLogger.Instance); var status = await coordinator.StartAsync(CancellationToken.None); Assert.True(status.IsRecording); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath); Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript); Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal); Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal); Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal); Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath); Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath); Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal); Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md"); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, new FixedMeetingMetadataProvider(new MeetingMetadata( "Architecture Sync", ["Ada ", "Grace"], "Review API shape", DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")))); await coordinator.StartAsync(CancellationToken.None); Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title); Assert.Equal(["Ada ", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal("Review API shape", artifactStore.Agenda); Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0 }, 16000, 1)); var provider = new FinalSegmentOnAudioCompletionProvider(); var store = new InMemoryTranscriptStore(); var noteStore = new InMemoryMeetingNoteStore(); var noteOpener = new CapturingMeetingNoteOpener(); var artifactStore = new InMemoryMeetingArtifactStore(); var audioArchive = new InMemoryRecordedAudioStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), store, noteStore, noteOpener, artifactStore, audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); var stopped = await coordinator.StopAsync(CancellationToken.None); Assert.False(stopped.IsRecording); await store.WaitForTextAsync("final:2"); } [Fact] public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp() { var audioSource = new ControlledAudioSource(); var provider = new BlockingBeforeTranscriptionProvider(); var store = new InMemoryTranscriptStore(); var audioArchive = new InMemoryRecordedAudioStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), store, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await provider.WaitUntilWaitingForBackendAsync(); await audioSource.WriteAsync(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1), CancellationToken.None); await audioArchive.WaitForAppendAsync(); Assert.Equal([4], audioArchive.AppendedChunkSizes); provider.MarkBackendReady(); await store.WaitForTextAsync("chunk:4"); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1)); var transcriptStore = new InMemoryTranscriptStore(); var finalizer = new CapturingTranscriptFinalizer( [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello"), new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Speaker 1", "there") ]); var audioArchive = new InMemoryRecordedAudioStore("memory-recording.wav"); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal("memory-recording.wav", finalizer.AudioPath); Assert.Collection( finalizer.LiveSegments, segment => Assert.Equal("final:4", segment.Text)); Assert.Equal([4], audioArchive.AppendedChunkSizes); Assert.True(audioArchive.Completed); Assert.True(audioArchive.Deleted); Assert.Collection( transcriptStore.ReplacedSegments, first => { Assert.Equal("Speaker 0", first.Speaker); Assert.Equal("hello", first.Text); }, second => { Assert.Equal("Speaker 1", second.Speaker); Assert.Equal("there", second.Text); }); } [Theory] [InlineData(0, null)] [InlineData(1, null)] [InlineData(2, 2)] [InlineData(5, 5)] public async Task StopUsesMeetingNoteAttendeeCountAsSpeakerHintWhenThereAreMultipleAttendees( int attendeeCount, int? expectedNumSpeakers) { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var noteStore = new InMemoryMeetingNoteStore(); var finalizer = new CapturingTranscriptFinalizer( [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello") ]); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); noteStore.UpdateAttendees(Enumerable.Range(1, attendeeCount).Select(index => $"Person {index}")); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal(expectedNumSpeakers, finalizer.Options?.NumSpeakers); } [Fact] public async Task StopAddsMeetingEndTimeAndRunsSummaryAfterFinishedTranscript() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var noteStore = new InMemoryMeetingNoteStore(); var artifactStore = new InMemoryMeetingArtifactStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); var startTime = noteStore.SavedNote?.Frontmatter.StartTime; await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.NotNull(startTime); Assert.NotNull(noteStore.SavedNote?.Frontmatter.EndTime); Assert.True(noteStore.SavedNote?.Frontmatter.EndTime >= startTime); Assert.Equal(artifactStore.CreatedArtifacts, summaryPipeline.Artifacts); } [Fact] public async Task StopUpdatesTranscriptMetadataWithMeetingEndTime() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime); } [Fact] public async Task StopUpdatesAssistantContextStateThroughSummaryLifecycle() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal( [AssistantContextState.Summarizing, AssistantContextState.Finished], artifactStore.States); } [Fact] public async Task StopMarksAssistantContextErrorWhenSummaryFails() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(succeeded: false), Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal( [AssistantContextState.Summarizing, AssistantContextState.Error], artifactStore.States); } [Fact] public async Task StopMarksSpeakerRecognitionBeforeSummaryWhenFinalizerIsConfigured() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" }, WhisperLocal = new WhisperLocalOptions { Diarization = new PyannoteDiarizationOptions { Enabled = true } } }), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal( [AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished], artifactStore.States); } [Fact] public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments() { var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var store = new VaultTranscriptStore( Options.Create(new MeetingAssistantOptions { Vault = new VaultOptions { TranscriptsFolder = vaultFolder } }), NullLogger.Instance); var session = await store.CreateSessionAsync(CancellationToken.None); await store.AppendAsync( session, new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello vault"), CancellationToken.None); Assert.True(Directory.Exists(vaultFolder)); Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal); Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath)); } [Fact] public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav() { var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var store = new TemporaryRecordedAudioStore( Options.Create(new MeetingAssistantOptions { Recording = new RecordingOptions { SampleRate = 16000, Channels = 1, TemporaryRecordingsFolder = recordingFolder } }), NullLogger.Instance); await using var session = await store.CreateSessionAsync(CancellationToken.None); await session.AppendAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None); await session.CompleteAsync(CancellationToken.None); Assert.True(Directory.Exists(recordingFolder)); Assert.EndsWith(".wav", session.AudioPath, StringComparison.Ordinal); Assert.True(new FileInfo(session.AudioPath).Length > 44); } [Fact] public async Task TemporaryRecordedAudioStoreDeletesStaleRecordingsOnStartup() { var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(recordingFolder); var staleRecording = Path.Combine(recordingFolder, "stale.wav"); var unrelatedFile = Path.Combine(recordingFolder, "keep.txt"); await File.WriteAllTextAsync(staleRecording, "stale"); await File.WriteAllTextAsync(unrelatedFile, "keep"); var store = new TemporaryRecordedAudioStore( Options.Create(new MeetingAssistantOptions { Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder } }), NullLogger.Instance); await store.DeleteStaleRecordingsAsync(CancellationToken.None); Assert.False(File.Exists(staleRecording)); Assert.True(File.Exists(unrelatedFile)); } private static MeetingAssistantOptions CreateOptionsWithoutFinalizer() { return new MeetingAssistantOptions { Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" }, WhisperLocal = new WhisperLocalOptions { Diarization = new PyannoteDiarizationOptions { Enabled = false } } }; } private sealed class InMemoryTranscriptStore : ITranscriptStore { private readonly List segments = []; private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly string transcriptPath; public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md") { this.transcriptPath = transcriptPath; } public Task CreateSessionAsync(CancellationToken cancellationToken) { return Task.FromResult(new TranscriptSession(transcriptPath)); } public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken) { segments.Add(segment); segmentWritten.TrySetResult(); return Task.CompletedTask; } public async Task WaitForTextAsync(string text) { var deadline = DateTimeOffset.UtcNow.AddSeconds(5); while (DateTimeOffset.UtcNow < deadline) { if (segments.Any(segment => segment.Text.Contains(text, StringComparison.Ordinal))) { return; } await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100)); } throw new TimeoutException($"Segment containing '{text}' was not written."); } public IReadOnlyList ReplacedSegments { get; private set; } = []; public MeetingNote? MetadataMeetingNote { get; private set; } public Task ReplaceAsync( TranscriptSession session, IReadOnlyList replacementSegments, CancellationToken cancellationToken) { ReplacedSegments = replacementSegments; return Task.CompletedTask; } public Task UpdateMetadataAsync( TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { MetadataMeetingNote = meetingNote; return Task.CompletedTask; } } private sealed class InMemoryMeetingNoteStore : IMeetingNoteStore { private readonly string notePath; public InMemoryMeetingNoteStore(string notePath = "memory-meeting.md") { this.notePath = notePath; } public MeetingNote? SavedNote { get; private set; } public Task SaveAsync(MeetingNote note, CancellationToken cancellationToken) { SavedNote = note with { Path = notePath }; return Task.FromResult(SavedNote); } public Task ReadAsync(string path, CancellationToken cancellationToken) { return Task.FromResult(SavedNote ?? throw new FileNotFoundException(path)); } public void UpdateAttendees(IEnumerable attendees) { if (SavedNote is null) { throw new InvalidOperationException("No meeting note has been saved."); } SavedNote.Frontmatter.Attendees = attendees.ToList(); } } private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener { public string? OpenedPath { get; private set; } public Task OpenAsync(string notePath, CancellationToken cancellationToken) { OpenedPath = notePath; return Task.CompletedTask; } } private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore { public MeetingSessionArtifacts? CreatedArtifacts { get; private set; } public List States { get; } = []; public string? Agenda { get; private set; } public DateTimeOffset? ScheduledEnd { get; private set; } public Task CreateAssistantContextAsync( MeetingSessionArtifacts artifacts, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { CreatedArtifacts = artifacts; Agenda = agenda; ScheduledEnd = scheduledEnd; return Task.CompletedTask; } public Task UpdateAssistantContextStateAsync( MeetingSessionArtifacts artifacts, AssistantContextState state, CancellationToken cancellationToken) { States.Add(state); return Task.CompletedTask; } } private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider { private readonly MeetingMetadata? metadata; public FixedMeetingMetadataProvider(MeetingMetadata? metadata) { this.metadata = metadata; } public Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { return Task.FromResult(metadata); } } private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline { private readonly bool succeeded; public CapturingMeetingSummaryPipeline(bool succeeded = true) { this.succeeded = succeeded; } public MeetingSessionArtifacts? Artifacts { get; private set; } public Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { Artifacts = artifacts; return Task.FromResult(new MeetingSummaryRunResult( artifacts.SummaryPath, succeeded ? "summary ok" : "summary failed", succeeded, succeeded ? null : "error")); } } private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore { private readonly string audioPath; public InMemoryRecordedAudioStore(string audioPath = "memory-recording.wav") { this.audioPath = audioPath; } public List AppendedChunkSizes { get; } = []; public bool Completed { get; private set; } public bool Deleted { get; private set; } private TaskCompletionSource AppendObserved { get; set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task WaitForAppendAsync() { return AppendObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public Task CreateSessionAsync(CancellationToken cancellationToken) { return Task.FromResult(new Sink(this, audioPath)); } public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } private sealed class Sink : IRecordedAudioSink { private readonly InMemoryRecordedAudioStore store; public Sink(InMemoryRecordedAudioStore store, string audioPath) { this.store = store; AudioPath = audioPath; } public string AudioPath { get; } public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken) { store.AppendedChunkSizes.Add(chunk.Pcm.Length); store.AppendObserved.TrySetResult(); return Task.CompletedTask; } public Task CompleteAsync(CancellationToken cancellationToken) { store.Completed = true; return Task.CompletedTask; } public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } public Task DeleteAsync(CancellationToken cancellationToken) { store.Deleted = true; return Task.CompletedTask; } } } private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { private readonly IStreamingTranscriptionProvider provider; private readonly Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize; public TestSpeechRecognitionPipelineFactory( IStreamingTranscriptionProvider provider, Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>>? finalize = null) { this.provider = provider; this.finalize = finalize ?? ((_, _, _, _) => Task.FromResult>([])); } public ISpeechRecognitionPipeline Create() { return new TestSpeechRecognitionPipeline(provider, finalize); } } private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline { private readonly Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize; public TestSpeechRecognitionPipeline( IStreamingTranscriptionProvider provider, Func, SpeechRecognitionPipelineOptions, CancellationToken, Task>> finalize) : base(provider) { this.finalize = finalize; } protected override Task> BuildFinishedTranscriptAsync( string audioPath, IReadOnlyList liveSegments, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { return finalize(audioPath, liveSegments, options, cancellationToken); } } private sealed class CapturingTranscriptFinalizer { private readonly IReadOnlyList segments; public CapturingTranscriptFinalizer(IReadOnlyList segments) { this.segments = segments; } public string? AudioPath { get; private set; } public IReadOnlyList LiveSegments { get; private set; } = []; public SpeechRecognitionPipelineOptions? Options { get; private set; } public Task> FinalizeAsync( string audioPath, IReadOnlyList liveSegments, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { AudioPath = audioPath; LiveSegments = liveSegments; Options = options; return Task.FromResult(segments); } } private sealed class ControlledAudioSource : IMeetingAudioSource { private readonly Channel chunks = Channel.CreateUnbounded(); public IAsyncEnumerable CaptureAsync(CancellationToken cancellationToken) { return chunks.Reader.ReadAllAsync(cancellationToken); } public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken) { return chunks.Writer.WriteAsync(chunk, cancellationToken); } } private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource { private readonly AudioChunk chunk; private readonly TaskCompletionSource captured = new(TaskCreationOptions.RunContinuationsAsynchronously); public CapturedChunkThenCancelAudioSource(AudioChunk chunk) { this.chunk = chunk; } public async IAsyncEnumerable CaptureAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { yield return chunk; captured.TrySetResult(); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } finally { captured.TrySetResult(); } } public Task WaitUntilCapturedAsync() { return captured.Task.WaitAsync(TimeSpan.FromSeconds(5)); } } private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider { public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var chunk in audio.WithCancellation(cancellationToken)) { FirstChunkWasObservedBeforeSourceCompleted = true; yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}"); } } } private sealed class FinalSegmentOnAudioCompletionProvider : IStreamingTranscriptionProvider { public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var byteCount = 0; await foreach (var chunk in audio.WithCancellation(cancellationToken)) { byteCount += chunk.Pcm.Length; } yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"final:{byteCount}"); } } private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider { private readonly TaskCompletionSource waitingForBackend = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource backendReady = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task WaitUntilWaitingForBackendAsync() { return waitingForBackend.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void MarkBackendReady() { backendReady.TrySetResult(); } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { waitingForBackend.TrySetResult(); await backendReady.Task.WaitAsync(cancellationToken); await foreach (var chunk in audio.WithCancellation(cancellationToken)) { yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"chunk:{chunk.Pcm.Length}"); } } } }