using MeetingAssistant.Recording; using MeetingAssistant.Speakers; 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 { private static async Task WaitUntilAsync(Func condition) { var deadline = DateTimeOffset.UtcNow.AddSeconds(5); while (DateTimeOffset.UtcNow < deadline) { if (condition()) { return; } await Task.Delay(25); } throw new TimeoutException("Condition was not met."); } [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); await WaitUntilAsync(() => artifactStore.Agenda == "Review API shape"); 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 StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md"); var artifactStore = new InMemoryMeetingArtifactStore(); var provider = new BlockingMeetingMetadataProvider(new MeetingMetadata( "Late Calendar Match", ["Ada"], "Late agenda", DateTimeOffset.Parse("2026-05-19T12:00:00+02:00"))); 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, provider); await coordinator.StartAsync(CancellationToken.None); Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal); provider.Release(); await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Title == "Late Calendar Match" && artifactStore.Agenda == "Late agenda"); Assert.Equal(["Ada"], noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal("Late agenda", artifactStore.Agenda); Assert.Equal(DateTimeOffset.Parse("2026-05-19T12:00:00+02:00"), artifactStore.ScheduledEnd); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartCreatesMeetingNoteWhenMetadataProviderFails() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md"); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\fallback-transcript.md"), noteStore, new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, new ThrowingMeetingMetadataProvider()); var status = await coordinator.StartAsync(CancellationToken.None); Assert.True(status.IsRecording); Assert.Equal("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md", status.MeetingNotePath); Assert.NotNull(artifactStore.CreatedArtifacts); Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartContinuesWhenObsidianOpenFails() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\open-fails.md"); var artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\open-fails-transcript.md"), noteStore, new ThrowingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); var status = await coordinator.StartAsync(CancellationToken.None); Assert.True(status.IsRecording); Assert.Equal("C:\\Vault\\Meetings\\Notes\\open-fails.md", status.MeetingNotePath); Assert.NotNull(artifactStore.CreatedArtifacts); 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 StartPassesDictationWordsToSpeechPipeline() { var audioSource = new ControlledAudioSource(); var provider = new CapturingPipelineOptionsProvider(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, dictationWordStore: new FixedDictationWordStore(["PBI", "Product Backlog Item"])); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(["PBI", "Product Backlog Item"], provider.Options?.DictationWords); 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); }); } [Fact] public async Task StopRelabelsFinishedTranscriptWithLearnedSpeakerIdentity() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1)); var transcriptStore = new InMemoryTranscriptStore(); var noteStore = new InMemoryMeetingNoteStore(); noteStore.UpdateSavedNote(MeetingNoteTemplate.Create( "Identity Test", DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"), attendees: ["Chris"], transcriptPath: "memory-transcript.md", assistantContextPath: "memory-context.md", summaryPath: "memory-summary.md")); var finalizer = new CapturingTranscriptFinalizer( [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello") ]); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync), transcriptStore, noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, speakerIdentificationService: new FixedSpeakerIdentificationService("Guest03", "Chris")); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker); } [Fact] public async Task StopAddsIdentifiedSpeakerToMeetingAttendees() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1)); var noteStore = new InMemoryMeetingNoteStore(); var finalizer = new CapturingTranscriptFinalizer( [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "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, speakerIdentificationService: new FixedSpeakerIdentificationService( "Guest03", "Chris", ["Chris", "Christopher"])); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); var attendees = Assert.IsType>(noteStore.SavedNote?.Frontmatter.Attendees); Assert.Contains("Chris", attendees); } [Fact] public async Task StopDoesNotDuplicateIdentifiedSpeakerWhenAliasIsAlreadyAttendee() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1)); var noteStore = new InMemoryMeetingNoteStore(); var finalizer = new CapturingTranscriptFinalizer( [ new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "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, speakerIdentificationService: new FixedSpeakerIdentificationService( "Guest03", "Christopher", ["Christopher", "Chris"])); await coordinator.StartAsync(CancellationToken.None); noteStore.UpdateAttendees(["Chris "]); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); var attendees = Assert.IsType>(noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal(["Chris "], attendees); } [Fact] public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris"); var provider = new OrderedChunkProvider(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, Interval = TimeSpan.FromMilliseconds(10) } }), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None); await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal))); await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal))); Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker); await Task.Delay(50); await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("second"); await coordinator.StopAsync(CancellationToken.None); Assert.Collection( transcriptStore.Segments, first => Assert.Equal("Guest03", first.Speaker), second => Assert.Equal("Chris", second.Speaker)); Assert.Contains( transcriptStore.ReplacedSegments, segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris"); } [Fact] public async Task LiveSpeakerIdentityMatchAddsIdentifiedSpeakerToMeetingAttendees() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore(); var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris"); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, Interval = TimeSpan.FromMilliseconds(10) } }), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None); await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Attendees.Contains("Chris") == true); var attendees = Assert.IsType>(noteStore.SavedNote?.Frontmatter.Attendees); Assert.Contains("Chris", attendees); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task LiveSpeakerIdentificationRetriesWhenAttendeesChange() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore(); var speakerIdentification = new CountingSpeakerIdentificationService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, Interval = TimeSpan.FromMilliseconds(20) } }), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None); await WaitUntilAsync(() => speakerIdentification.Requests.Count == 1); noteStore.UpdateAttendees(["Chris"]); await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2); await coordinator.StopAsync(CancellationToken.None); Assert.Equal(["Chris"], speakerIdentification.Requests.Last().MeetingNote.Frontmatter.Attendees); } [Fact] public async Task LiveSpeakerIdentificationRetriesWhenNewUnmappedSpeakerAppears() { var audioSource = new ControlledAudioSource(); var speakerIdentification = new CountingSpeakerIdentificationService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new ChangingSpeakerOrderedChunkProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, Interval = TimeSpan.FromMilliseconds(20) } }), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None); await WaitUntilAsync(() => speakerIdentification.Requests.Count == 1); await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None); await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2); await coordinator.StopAsync(CancellationToken.None); var samples = Assert.IsAssignableFrom>( speakerIdentification.Requests.Last().Samples); Assert.Contains(samples, sample => sample.Speaker == "Guest04"); } [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 IReadOnlyList Segments => segments; 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(); } public void UpdateSavedNote(MeetingNote note) { SavedNote = note with { Path = notePath }; } } 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; } public Task UpdateAssistantContextMetadataAsync( MeetingSessionArtifacts artifacts, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { Agenda = agenda; ScheduledEnd = scheduledEnd; 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 ThrowingMeetingMetadataProvider : IMeetingMetadataProvider { public Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { throw new InvalidOperationException("metadata unavailable"); } } private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider { private readonly MeetingMetadata metadata; private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); public BlockingMeetingMetadataProvider(MeetingMetadata metadata) { this.metadata = metadata; } public void Release() { release.TrySetResult(); } public async Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { await release.Task.WaitAsync(cancellationToken); return metadata; } } private sealed class ThrowingMeetingNoteOpener : IMeetingNoteOpener { public Task OpenAsync(string notePath, CancellationToken cancellationToken) { throw new InvalidOperationException("obsidian unavailable"); } } 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 FixedSpeakerIdentificationService : ISpeakerIdentificationService { private readonly string sourceSpeaker; private readonly string targetSpeaker; private readonly IReadOnlyList acceptedNames; public FixedSpeakerIdentificationService( string sourceSpeaker, string targetSpeaker, IReadOnlyList? acceptedNames = null) { this.sourceSpeaker = sourceSpeaker; this.targetSpeaker = targetSpeaker; this.acceptedNames = acceptedNames ?? [targetSpeaker]; } public Task ProcessFinishedTranscriptAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return Task.FromResult(new SpeakerIdentificationResult( request.Segments.Select(segment => segment.Speaker == sourceSpeaker ? segment with { Speaker = targetSpeaker } : segment).ToList(), new Dictionary { [sourceSpeaker] = targetSpeaker }, [new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)])); } public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return ProcessFinishedTranscriptAsync(request, cancellationToken); } } private sealed class BlockingSpeakerIdentificationService : ISpeakerIdentificationService { private readonly string sourceSpeaker; private readonly string targetSpeaker; private readonly IReadOnlyList acceptedNames; public BlockingSpeakerIdentificationService( string sourceSpeaker, string targetSpeaker, IReadOnlyList? acceptedNames = null) { this.sourceSpeaker = sourceSpeaker; this.targetSpeaker = targetSpeaker; this.acceptedNames = acceptedNames ?? [targetSpeaker]; } public TaskCompletionSource IdentificationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { IdentificationObserved.TrySetResult(); return Task.FromResult(new SpeakerIdentificationResult( request.Segments, new Dictionary { [sourceSpeaker] = targetSpeaker }, [new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)])); } public Task ProcessFinishedTranscriptAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } } private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService { public List Requests { get; } = []; public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { Requests.Add(request); return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } public Task ProcessFinishedTranscriptAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } } private sealed class FixedDictationWordStore : IDictationWordStore { private readonly IReadOnlyList words; public FixedDictationWordStore(IReadOnlyList words) { this.words = words; } public Task> ReadWordsAsync(CancellationToken cancellationToken) { return Task.FromResult(words); } public Task> AddWordAsync(string word, CancellationToken cancellationToken) { return Task.FromResult(words); } } private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider { public TaskCompletionSource OptionsObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public SpeechRecognitionPipelineOptions? Options { get; private set; } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { Options = options; OptionsObserved.TrySetResult(); await foreach (var _ in audio.WithCancellation(cancellationToken)) { yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", "ok"); } } } private sealed class OrderedChunkProvider : IStreamingTranscriptionProvider { public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var index = 0; await foreach (var _ in audio.WithCancellation(cancellationToken)) { var text = index++ == 0 ? "first" : "second"; yield return new TranscriptionSegment( TimeSpan.FromSeconds(index - 1), TimeSpan.FromSeconds(index + 1), "Guest03", $"{text} has enough words for identification."); } } } private sealed class ChangingSpeakerOrderedChunkProvider : IStreamingTranscriptionProvider { public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var index = 0; await foreach (var _ in audio.WithCancellation(cancellationToken)) { var speaker = index++ == 0 ? "Guest03" : "Guest04"; yield return new TranscriptionSegment( TimeSpan.FromSeconds(index - 1), TimeSpan.FromSeconds(index + 1), speaker, "This segment has enough words for identification."); } } } 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, SpeechRecognitionPipelineOptions options, [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, SpeechRecognitionPipelineOptions options, [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, SpeechRecognitionPipelineOptions options, [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}"); } } } private static byte[] Samples(params short[] samples) { var bytes = new byte[samples.Length * sizeof(short)]; Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length); return bytes; } }