using MeetingAssistant.Recording; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Speakers; using MeetingAssistant.Screenshots; using MeetingAssistant.Transcription; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Summary; using MeetingAssistant.Workflow; using Microsoft.Extensions.Configuration; 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, string? timeoutMessage = null) { var deadline = DateTimeOffset.UtcNow.AddSeconds(15); while (DateTimeOffset.UtcNow < deadline) { if (condition()) { return; } await Task.Delay(25); } throw new TimeoutException(timeoutMessage ?? "Condition was not met."); } private static async Task AppendTextWithRetryAsync(string path, string text) { var deadline = DateTimeOffset.UtcNow.AddSeconds(5); while (true) { try { await File.AppendAllTextAsync(path, text); return; } catch (IOException) when (DateTimeOffset.UtcNow < deadline) { await Task.Delay(25); } } } private static bool FileContainsText(string path, string text) { try { return File.Exists(path) && File.ReadAllText(path).Contains(text, StringComparison.Ordinal); } catch (IOException) { return false; } } private static TranscriptionSegment ParseTranscriptLine(string line) { var bracket = line.IndexOf(']'); var rest = bracket >= 0 && bracket + 1 < line.Length ? line[(bracket + 1)..].TrimStart() : line; var separator = rest.IndexOf(": ", StringComparison.Ordinal); if (separator < 0) { return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", rest); } return new TranscriptionSegment( TimeSpan.Zero, TimeSpan.Zero, rest[..separator], rest[(separator + 2)..]); } [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 TranscriptLineWorkflowRuleTransformsLiveTranscriptAfterDurableAppend() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var rulesPath = Path.Combine(root, "rules.yaml"); Directory.CreateDirectory(root); await File.WriteAllTextAsync(rulesPath, MeetingWorkflowTestRules.MaskedProfanityRedactionYaml); var options = new MeetingAssistantOptions { Vault = { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts", AssistantContextFolder = "Context", SummariesFolder = "Summaries" }, Automation = { RulesPath = rulesPath } }; var audioSource = new ControlledAudioSource(); var noteStore = new MarkdownMeetingNoteStore( Options.Create(options), NullLogger.Instance); var artifactStore = new MarkdownMeetingArtifactStore( NullLogger.Instance); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory( new FixedSegmentStreamingTranscriptionProvider( new TranscriptionSegment( TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(5), "Guest-1", "Azure returned ***** here."))), new VaultTranscriptStore( Options.Create(options), NullLogger.Instance), noteStore, new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(options), NullLogger.Instance, meetingWorkflowEngine: new MeetingWorkflowEngine( new FileMeetingWorkflowRulesProvider( NullLogger.Instance), noteStore, artifactStore, NullLogger.Instance)); var started = await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "Azure returned")); await coordinator.StopAsync(CancellationToken.None); var content = await File.ReadAllTextAsync(started.TranscriptPath!); Assert.Contains("[00:00:04] Guest-1: Azure returned [redacted] here.", content); Assert.DoesNotContain("*****", content); } [Fact] public async Task TranscriptLineWorkflowFailureKeepsWritingLiveTranscriptSegments() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, meetingWorkflowEngine: new FailingFirstTranscriptLineWorkflowEngine()); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("first has enough words for identification."); await transcriptStore.WaitForTextAsync("second has enough words for identification."); await coordinator.StopAsync(CancellationToken.None); Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("first has enough words for identification.", StringComparison.Ordinal)); Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("second has enough words for identification.", StringComparison.Ordinal)); } [Fact] public async Task TranscriptLineWorkflowRewriteRunsAfterOriginalLineIsWritten() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory( new FixedSegmentStreamingTranscriptionProvider( new TranscriptionSegment( TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(5), "Guest-1", "Azure returned ***** here."))), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, meetingWorkflowEngine: new ObservingTranscriptRewriteWorkflowEngine(transcriptStore)); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("Azure returned [redacted] here."); await coordinator.StopAsync(CancellationToken.None); Assert.DoesNotContain(transcriptStore.Segments, segment => segment.Text.Contains("*****", StringComparison.Ordinal)); Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("Azure returned [redacted] here.", StringComparison.Ordinal)); } [Fact] public async Task PendingTranscriptLineWorkflowRewriteDoesNotRemoveFollowingLine() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var workflowEngine = new DelayedFirstTranscriptLineWorkflowEngine(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, meetingWorkflowEngine: workflowEngine); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("first has enough words for identification."); await workflowEngine.WaitUntilFirstLineWorkflowStartedAsync(); await audioSource.WriteAsync(new AudioChunk([2, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("second has enough words for identification."); workflowEngine.CompleteFirstLineWorkflow(); await transcriptStore.WaitForTextAsync("rewritten first has enough words for identification."); await coordinator.StopAsync(CancellationToken.None); var segments = transcriptStore.Segments; Assert.Equal( ["rewritten first has enough words for identification.", "second has enough words for identification."], segments.Select(segment => segment.Text).ToArray()); } [Fact] public async Task TranscriptReconnectMarkerIsReplacedByNextAzureTranscriptLine() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider( [ new TranscriptionSegment( TimeSpan.Zero, TimeSpan.Zero, "System", "", TranscriptionSegmentKind.Marker, MarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Guest-1", "speech after reconnect", ReplacesMarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(4), "Guest-2", "following speech") ])), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await coordinator.StopAsync(CancellationToken.None); var segments = transcriptStore.Segments; Assert.Equal( ["speech after reconnect", "following speech"], segments.Select(segment => segment.Text).ToArray()); Assert.DoesNotContain(segments, segment => segment.Text.Contains("Reconnecting", StringComparison.Ordinal)); } [Fact] public async Task TranscriptReconnectMarkerUpdatesReuseSameLineUntilAzureTranscriptResumes() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new StaticOnAudioCompletionProvider( [ new TranscriptionSegment( TimeSpan.Zero, TimeSpan.Zero, "System", "", TranscriptionSegmentKind.Marker, MarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.Zero, TimeSpan.Zero, "System", "", TranscriptionSegmentKind.Marker, MarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.Zero, TimeSpan.Zero, "System", "", TranscriptionSegmentKind.Marker, MarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Guest-1", "speech after reconnect", ReplacesMarkerId: "azure-reconnect"), new TranscriptionSegment( TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(4), "Guest-2", "following speech") ])), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await coordinator.StopAsync(CancellationToken.None); var segments = transcriptStore.Segments; Assert.Equal( ["speech after reconnect", "following speech"], segments.Select(segment => segment.Text).ToArray()); Assert.DoesNotContain(segments, segment => segment.Text.Contains("Reconnecting", StringComparison.Ordinal) || segment.Text.Contains("disconnected", StringComparison.OrdinalIgnoreCase)); } [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 clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")); 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, inactivityClock: clock); 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.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext); Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary); 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.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath); Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath); Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title); Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StopTreatsRunsShorterThanConfiguredMinimumAsAbort() { var audioSource = new ControlledAudioSource(); var artifactCleaner = new CapturingMeetingRunArtifactCleaner(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions { Recording = { MinimumCompletedMeetingDuration = TimeSpan.FromMinutes(1) } }), NullLogger.Instance, artifactCleaner: artifactCleaner); var started = await coordinator.StartAsync(CancellationToken.None); var stopped = await coordinator.StopAsync(CancellationToken.None); Assert.False(stopped.IsRecording); Assert.Null(stopped.MeetingNotePath); Assert.False(summaryPipeline.WasRun); Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath); } [Fact] public async Task StopDeletesDefaultOnlyArtifactsInsteadOfSummarizing() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); try { var options = new MeetingAssistantOptions { Vault = { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts", AssistantContextFolder = "Assistant Context", SummariesFolder = "Summaries" } }; var audioSource = new ControlledAudioSource(); var artifactCleaner = new CapturingMeetingRunArtifactCleaner(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new VaultTranscriptStore( Options.Create(options), NullLogger.Instance), new MarkdownMeetingNoteStore( Options.Create(options), NullLogger.Instance), new CapturingMeetingNoteOpener(), new MarkdownMeetingArtifactStore( NullLogger.Instance), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(options), NullLogger.Instance, artifactCleaner: artifactCleaner); var started = await coordinator.StartAsync(CancellationToken.None); var stopped = await coordinator.StopAsync(CancellationToken.None); Assert.False(stopped.IsRecording); Assert.Null(stopped.MeetingNotePath); Assert.False(summaryPipeline.WasRun); Assert.Equal(started.MeetingNotePath, artifactCleaner.DeletedArtifacts?.MeetingNotePath); Assert.Equal(started.TranscriptPath, artifactCleaner.DeletedArtifacts?.TranscriptPath); Assert.Equal(started.AssistantContextPath, artifactCleaner.DeletedArtifacts?.AssistantContextPath); } finally { if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } } } [Fact] public async Task InactivitySafeguardStopsNormallyWhenPromptIsAcceptedAndUsesTranscriptEndTime() { var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T10:00:00+02:00")); var promptService = new CapturingMeetingInactivityPromptService(MeetingInactivityPromptResponse.Stop); var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var noteStore = new InMemoryMeetingNoteStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FixedSegmentStreamingTranscriptionProvider( new TranscriptionSegment( TimeSpan.FromMinutes(3), TimeSpan.FromMinutes(4), "Guest-01", "The latest transcript text before silence."))), transcriptStore, noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions { Recording = { InactivitySafeguard = { FirstPromptAfter = TimeSpan.FromSeconds(2), ReminderPromptAfter = [], AutoStopAfter = TimeSpan.FromMinutes(30), InferredEndPadding = TimeSpan.FromMinutes(1), CheckInterval = TimeSpan.FromSeconds(1) } } }), NullLogger.Instance, inactivityPromptService: promptService, inactivityClock: clock); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("latest transcript text", StringComparison.Ordinal))); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(2)); await promptService.WaitForPromptAsync(); await WaitUntilAsync(() => summaryPipeline.WasRun); Assert.Single(promptService.Requests); Assert.False(coordinator.CurrentStatus.IsRecording); Assert.True(summaryPipeline.WasRun); Assert.Equal( DateTimeOffset.Parse("2026-06-02T10:05:00+02:00"), noteStore.SavedNote?.Frontmatter.EndTime); } [Fact] public async Task InactivitySafeguardAutoStopsNormallyAndUsesMeetingStartWhenNoTranscriptArrives() { var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:00:00+02:00")); var promptService = new CapturingMeetingInactivityPromptService(); var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions { Recording = { InactivitySafeguard = { FirstPromptAfter = TimeSpan.Zero, ReminderPromptAfter = [], AutoStopAfter = TimeSpan.FromSeconds(5), InferredEndPadding = TimeSpan.FromMinutes(1), CheckInterval = TimeSpan.FromSeconds(1) } } }), NullLogger.Instance, inactivityPromptService: promptService, inactivityClock: clock); await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording); await WaitUntilAsync(() => summaryPipeline.WasRun); Assert.Empty(promptService.Requests); Assert.False(coordinator.CurrentStatus.IsRecording); Assert.True(summaryPipeline.WasRun); Assert.Equal( DateTimeOffset.Parse("2026-06-02T11:01:00+02:00"), noteStore.SavedNote?.Frontmatter.EndTime); } [Fact] public async Task InactivitySafeguardContinuesMonitoringWhenPromptIsIgnored() { var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:30:00+02:00")); var promptService = new IgnoringMeetingInactivityPromptService(); var audioSource = new ControlledAudioSource(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions { Recording = { InactivitySafeguard = { FirstPromptAfter = TimeSpan.FromSeconds(2), ReminderPromptAfter = [], AutoStopAfter = TimeSpan.FromSeconds(5), CheckInterval = TimeSpan.FromSeconds(1) } } }), NullLogger.Instance, inactivityPromptService: promptService, inactivityClock: clock); await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(2)); await promptService.WaitForPromptAsync(); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(3)); await WaitUntilAsync(() => summaryPipeline.WasRun); Assert.Single(promptService.Requests); Assert.True(summaryPipeline.WasRun); } [Fact] public async Task InactivitySafeguardResetsPromptScheduleWhenNewTranscriptTextArrives() { var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T12:00:00+02:00")); var promptService = new CapturingMeetingInactivityPromptService( MeetingInactivityPromptResponse.Continue, MeetingInactivityPromptResponse.Continue); var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { Recording = { InactivitySafeguard = { FirstPromptAfter = TimeSpan.FromSeconds(2), ReminderPromptAfter = [TimeSpan.FromSeconds(5)], AutoStopAfter = TimeSpan.FromMinutes(30), CheckInterval = TimeSpan.FromSeconds(1) } } }), NullLogger.Instance, inactivityPromptService: promptService, inactivityClock: clock); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("chunk:2", StringComparison.Ordinal))); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => promptService.Requests.Count == 1); await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None); await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("chunk:4", StringComparison.Ordinal))); await WaitUntilAsync(() => clock.PendingDelayCount > 0); clock.Advance(TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => promptService.Requests.Count == 2); Assert.Equal( [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)], promptService.Requests.Select(request => request.Threshold).ToArray()); 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("Architecture Sync", artifactStore.ContextMeetingNote?.Frontmatter.Title); 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 StartWithPromptedMetadataBypassesLookupAndRunsWorkflowWithMetadata() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\prompted-metadata-meeting.md"); var artifactStore = new InMemoryMeetingArtifactStore(); var metadataProvider = new CountingMeetingMetadataProvider(); var workflowEngine = new MetadataObservingWorkflowEngine(() => noteStore.SavedNote); 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, meetingMetadataProvider: metadataProvider, meetingWorkflowEngine: workflowEngine); var promptedMetadata = new MeetingMetadata( "Prompted Architecture Sync", ["Ada "], "Prompted agenda", DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")); await coordinator.StartFromPromptAsync(promptedMetadata, CancellationToken.None); await WaitUntilAsync(() => workflowEngine.ObservedEvents.Any(entry => entry.Type == MeetingWorkflowEventType.StateTransition && entry.Title == "Prompted Architecture Sync")); Assert.Equal(0, metadataProvider.CallCount); Assert.Equal("Prompted Architecture Sync", noteStore.SavedNote?.Frontmatter.Title); Assert.Equal(["Ada "], noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal("Prompted agenda", artifactStore.Agenda); Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd); Assert.Collection( workflowEngine.ObservedEvents.Where(entry => entry.Type is MeetingWorkflowEventType.Created or MeetingWorkflowEventType.StateTransition), created => { Assert.Equal(MeetingWorkflowEventType.Created, created.Type); Assert.StartsWith("Meeting ", created.Title, StringComparison.Ordinal); }, transition => { Assert.Equal(MeetingWorkflowEventType.StateTransition, transition.Type); Assert.Equal("Prompted Architecture Sync", transition.Title); Assert.Equal(AssistantContextState.CollectingMetadata, transition.FromState); Assert.Equal(AssistantContextState.Transcribing, transition.ToState); }); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartFromPromptWithoutMetadataBypassesLookup() { var audioSource = new ControlledAudioSource(); var metadataProvider = new CountingMeetingMetadataProvider(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, meetingMetadataProvider: metadataProvider); await coordinator.StartFromPromptAsync(null, CancellationToken.None); await Task.Delay(100); Assert.Equal(0, metadataProvider.CallCount); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote() { 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, meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata( "Architecture Sync", ["Karl Berger ", "Berger, Karl ", "Ada"], "", null)), attendeeCanonicalizer: new FixedAttendeeCanonicalizer(["Karl Berger", "Ada"])); await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Title == "Architecture Sync" && noteStore.SavedNote.Frontmatter.Attendees.Count > 0); Assert.Equal(["Karl Berger", "Ada"], noteStore.SavedNote?.Frontmatter.Attendees); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartTransformsMetadataAttendeesBeforeWritingNote() { var audioSource = new ControlledAudioSource(); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md"); var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace"); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata( "Architecture Sync", ["Ada Lovelace (Contoso)"], "", null)), meetingWorkflowEngine: workflowEngine); await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Attendees.Count > 0); Assert.Equal(["Ada Lovelace"], noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit() { 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 { Recording = { MaxMetadataAttendeeImportCount = 2 } }), NullLogger.Instance, new FixedMeetingMetadataProvider(new MeetingMetadata( "Presentation Review", ["Ada", "Grace", "Linus"], "Large audience agenda", DateTimeOffset.Parse("2026-05-19T14:00:00+02:00")))); await coordinator.StartAsync(CancellationToken.None); await WaitUntilAsync(() => artifactStore.Agenda == "Large audience agenda"); Assert.Equal("Presentation Review", noteStore.SavedNote?.Frontmatter.Title); Assert.Empty(noteStore.SavedNote?.Frontmatter.Attendees ?? []); Assert.Equal("Presentation Review", artifactStore.ContextMeetingNote?.Frontmatter.Title); Assert.Equal("Large audience agenda", artifactStore.Agenda); Assert.Equal(DateTimeOffset.Parse("2026-05-19T14: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 StartCanCreateNewRunWhilePreviousRunIsStillFinalizing() { var audioSource = new ControlledAudioSource(); var transcriptStore = new SequencedTranscriptStore(); var noteStore = new SequencedMeetingNoteStore(); var artifactStore = new CapturingArtifactStore(); var summaryPipeline = new RecordingSummaryPipeline(); var finalizer = new BlockingFirstFinalizer(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory( new EchoStreamingTranscriptionProvider(), finalizer.FinalizeAsync), transcriptStore, noteStore, new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); var firstStarted = await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForAppendCountAsync(1); var stopFirst = coordinator.StopAsync(CancellationToken.None); await finalizer.WaitUntilFirstFinalizerIsBlockedAsync(); var secondStarted = await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([2, 0, 3, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForAppendCountAsync(2); finalizer.ReleaseFirstFinalizer(); await stopFirst.WaitAsync(TimeSpan.FromSeconds(5)); await coordinator.StopAsync(CancellationToken.None); Assert.True(secondStarted.IsRecording); Assert.NotEqual(firstStarted.TranscriptPath, secondStarted.TranscriptPath); Assert.NotEqual(firstStarted.MeetingNotePath, secondStarted.MeetingNotePath); Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == firstStarted.MeetingNotePath); Assert.Contains(summaryPipeline.ArtifactHistory, artifacts => artifacts.MeetingNotePath == secondStarted.MeetingNotePath); Assert.Contains(transcriptStore.ReplacementHistory, entry => entry.Session.TranscriptPath == firstStarted.TranscriptPath && entry.Segments.Single().Text == "final run 1"); Assert.Contains(transcriptStore.ReplacementHistory, entry => entry.Session.TranscriptPath == secondStarted.TranscriptPath && entry.Segments.Single().Text == "final run 2"); Assert.All( transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == firstStarted.TranscriptPath), entry => Assert.Equal(firstStarted.MeetingNotePath, entry.MeetingNote.Path)); Assert.All( transcriptStore.MetadataHistory.Where(entry => entry.Session.TranscriptPath == secondStarted.TranscriptPath), entry => Assert.Equal(secondStarted.MeetingNotePath, entry.MeetingNote.Path)); } [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 StopQueuesAzureBacklogAndAllowsNextMeetingWhenTranscriptionCannotDrain() { var audioSource = new ControlledAudioSource(); var provider = new BlockingBeforeTranscriptionProvider(); var transcriptStore = new InMemoryTranscriptStore(); var audioArchive = new InMemoryRecordedAudioStore(); var backlog = new InMemoryOfflineTranscriptionBacklog(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), audioArchive, new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions { Recording = new RecordingOptions { TranscriptionProvider = "azure-speech", StopProcessingTimeout = TimeSpan.FromMilliseconds(20) } }), NullLogger.Instance, offlineTranscriptionBacklog: backlog); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await provider.WaitUntilWaitingForBackendAsync(); var stopped = await coordinator.StopAsync(CancellationToken.None); var restarted = await coordinator.StartAsync(CancellationToken.None); Assert.False(stopped.IsRecording); Assert.True(restarted.IsRecording); var item = Assert.Single(backlog.Items); Assert.Equal("memory-recording.wav", item.AudioPath); Assert.Equal(transcriptStore.CreatedSessions[0].TranscriptPath, item.TranscriptPath); Assert.True(audioArchive.Completed); Assert.False(audioArchive.Deleted); } [Fact] public async Task OfflineBacklogReplaysQueuedRecordingAndCompletesMeetingArtifacts() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var audioPath = Path.Combine(root, "queued.wav"); Directory.CreateDirectory(root); using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1))) { writer.Write([1, 0, 2, 0], 0, 4); } var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "transcript.md")); var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "meeting.md")); var artifactStore = new InMemoryMeetingArtifactStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var workflowEngine = new CapturingMeetingWorkflowEngine(); var backlog = new InMemoryOfflineTranscriptionBacklog(); var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory(); var startedAt = DateTimeOffset.Now.AddMinutes(-5); var stoppedAt = DateTimeOffset.Now.AddMinutes(-1); var note = new MeetingNote( Path.Combine(root, "meeting.md"), new MeetingNoteFrontmatter { Title = "Queued meeting", StartTime = startedAt, Attendees = ["Ada", "Grace"], Transcript = Path.Combine(root, "transcript.md"), AssistantContext = Path.Combine(root, "context.md"), Summary = Path.Combine(root, "summary.md") }, ""); noteStore.UpdateSavedNote(note); await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem( "queued", audioPath, Path.Combine(root, "transcript.md"), Path.Combine(root, "meeting.md"), Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"), startedAt, stoppedAt, "english"), CancellationToken.None); var launchProfiles = CreateLaunchProfiles(root, root); var processor = new OfflineTranscriptionBacklogProcessor( backlog, pipelineFactory, transcriptStore, noteStore, artifactStore, summaryPipeline, workflowEngine, CreateRecordingDictationWordProvider(new FixedDictationWordStore(["codex"])), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, launchProfiles); var processed = await processor.ProcessPendingAsync(CancellationToken.None); Assert.Equal(1, processed); Assert.Empty(backlog.Items); Assert.Equal(["english"], pipelineFactory.ProfileNames); var segment = Assert.Single(transcriptStore.ReplacedSegments); Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal); Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime); Assert.True(summaryPipeline.WasRun); Assert.Equal( new[] { AssistantContextState.Summarizing, AssistantContextState.Finished }, artifactStore.States); Assert.Contains(workflowEngine.Events, workflowEvent => workflowEvent.Type == MeetingWorkflowEventType.TranscriptLine && workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true); } [Fact] public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var options = new MeetingAssistantOptions { Vault = new VaultOptions { BaseFolder = root, TranscriptsFolder = "Transcripts", MeetingNotesFolder = "Notes", AssistantContextFolder = "Context", SummariesFolder = "Summaries" }, Recording = new RecordingOptions { StopProcessingTimeout = TimeSpan.FromSeconds(5) } }; var audioSource = new ControlledAudioSource(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata("", [], "")); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new VaultTranscriptStore(Options.Create(options), NullLogger.Instance), new MarkdownMeetingNoteStore(Options.Create(options), NullLogger.Instance), new CapturingMeetingNoteOpener(), new MarkdownMeetingArtifactStore(NullLogger.Instance), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(options), NullLogger.Instance, meetingMetadataProvider: metadataProvider); try { var started = await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await WaitUntilAsync(() => File.Exists(started.AssistantContextPath!)); var attachmentPath = Path.Combine( Path.GetDirectoryName(started.AssistantContextPath!)!, "Attachments", "20260527-093135-123-000010-cropped.png"); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); await File.WriteAllTextAsync(attachmentPath, "image"); await AppendTextWithRetryAsync( started.AssistantContextPath!, Environment.NewLine + "![Cropped screenshot](Attachments/20260527-093135-123-000010-cropped.png)" + Environment.NewLine); Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!); await File.WriteAllTextAsync(started.SummaryPath!, "draft summary"); var aborted = await coordinator.AbortAsync(CancellationToken.None); Assert.False(aborted.IsRecording); Assert.Null(aborted.MeetingNotePath); Assert.False(File.Exists(started.MeetingNotePath)); Assert.False(File.Exists(started.TranscriptPath)); Assert.False(File.Exists(started.AssistantContextPath)); Assert.False(File.Exists(started.SummaryPath)); Assert.False(File.Exists(attachmentPath)); Assert.Null(summaryPipeline.Artifacts); await Task.Delay(250); Assert.False(File.Exists(started.AssistantContextPath)); } finally { metadataProvider.Release(); } } [Fact] public async Task AbortDoesNothingWhenRecordingIsIdle() { var coordinator = new MeetingRecordingCoordinator( new ControlledAudioSource(), new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance); var status = await coordinator.AbortAsync(CancellationToken.None); Assert.False(status.IsRecording); Assert.Null(status.MeetingNotePath); Assert.Null(status.TranscriptPath); Assert.Null(status.AssistantContextPath); Assert.Null(status.SummaryPath); } [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 StartPassesLaunchProfileToSpeechPipelineFactory() { var audioSource = new ControlledAudioSource(); var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()); var launchProfiles = CreateLaunchProfiles( Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"), Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english")); var coordinator = new MeetingRecordingCoordinator( audioSource, pipelineFactory, new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(launchProfiles.GetRequiredProfile(null).Options), NullLogger.Instance, launchProfiles: launchProfiles); await coordinator.StartAsync("english", CancellationToken.None); Assert.Equal("english", pipelineFactory.LastProfileName); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task ToggleToDifferentLaunchProfileWhileRecordingSwitchesPipelineWithoutNewArtifactsOrSummary() { var audioSource = new ControlledAudioSource(); var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory(); var transcriptStore = new InMemoryTranscriptStore(); var metadataProvider = new CountingMeetingMetadataProvider(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var launchProfiles = CreateLaunchProfiles( Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"), Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english")); var coordinator = new MeetingRecordingCoordinator( audioSource, pipelineFactory, transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(launchProfiles.GetRequiredProfile(null).Options), NullLogger.Instance, meetingMetadataProvider: metadataProvider, launchProfiles: launchProfiles); var started = await coordinator.ToggleAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("default chunk:2"); var switched = await coordinator.ToggleAsync("english", CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("english chunk:4"); Assert.True(switched.IsRecording); Assert.Equal(started.TranscriptPath, switched.TranscriptPath); Assert.Equal(started.MeetingNotePath, switched.MeetingNotePath); Assert.Equal(started.AssistantContextPath, switched.AssistantContextPath); Assert.Equal(started.SummaryPath, switched.SummaryPath); Assert.Equal([null, "english"], pipelineFactory.ProfileNames); Assert.Contains(transcriptStore.Segments, segment => segment.Speaker == "System" && segment.Text.Contains( "Transcription profile changed to english. Speaker recognition and identities reset.", StringComparison.Ordinal)); Assert.Null(summaryPipeline.Artifacts); await WaitUntilAsync(() => metadataProvider.CallCount == 1); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task ToggleToDifferentLaunchProfileBuffersAudioCapturedWhilePreviousPipelineDrains() { var audioSource = new ControlledAudioSource(); var pipelineFactory = new BlockingProfileSwitchSpeechRecognitionPipelineFactory(); var transcriptStore = new InMemoryTranscriptStore(); var launchProfiles = CreateLaunchProfiles( Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"), Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english")); var coordinator = new MeetingRecordingCoordinator( audioSource, pipelineFactory, transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(launchProfiles.GetRequiredProfile(null).Options), NullLogger.Instance, launchProfiles: launchProfiles); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await transcriptStore.WaitForTextAsync("default chunk:2"); var switchTask = coordinator.ToggleAsync("english", CancellationToken.None); await pipelineFactory.WaitUntilFirstPipelineDrainIsBlockedAsync(); await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0, 3, 0], 16000, 1), CancellationToken.None); Assert.False(switchTask.IsCompleted); pipelineFactory.ReleaseFirstPipelineDrain(); await switchTask.WaitAsync(TimeSpan.FromSeconds(5)); await transcriptStore.WaitForTextAsync("english chunk:6"); Assert.Equal([null, "english"], pipelineFactory.ProfileNames); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task ToggleToDifferentLaunchProfileClearsFutureSpeakerMappings() { var audioSource = new ControlledAudioSource(); var transcriptStore = new InMemoryTranscriptStore(); var speakerIdentification = new SingleMappingSpeakerIdentificationService("Guest03", "Chris"); var launchProfiles = CreateLaunchProfiles( Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"), Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"), enableSpeakerIdentification: true); var options = launchProfiles.GetRequiredProfile(null).Options; var coordinator = new MeetingRecordingCoordinator( audioSource, new ProfileSwitchSpeechRecognitionPipelineFactory("Guest03"), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(options), NullLogger.Instance, speakerIdentificationService: speakerIdentification, launchProfiles: launchProfiles); await coordinator.StartAsync(CancellationToken.None); await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None); await transcriptStore.WaitForTextAsync("default chunk:"); await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Speaker == "Chris")); await coordinator.ToggleAsync("english", CancellationToken.None); await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None); await transcriptStore.WaitForTextAsync("english chunk:"); var afterMarker = transcriptStore.Segments .SkipWhile(segment => !segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal)) .Skip(1) .ToList(); Assert.Contains(afterMarker, segment => segment.Speaker == "Guest03"); Assert.DoesNotContain(afterMarker, segment => segment.Speaker == "Chris"); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task StartUsesLaunchProfileStorageAndSummaryOptions() { var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"); var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"); var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot); var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var coordinator = new MeetingRecordingCoordinator( new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)), new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new ProfileAwareTranscriptStore(), new ProfileAwareMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new ProfileAwareRecordedAudioStore(), summaryPipeline, Options.Create(launchProfiles.GetRequiredProfile(null).Options), NullLogger.Instance, launchProfiles: launchProfiles, inactivityClock: clock); var started = await coordinator.StartAsync("english", CancellationToken.None); await coordinator.StopAsync(CancellationToken.None); Assert.Equal(Path.Combine(englishRoot, "Transcripts", "20260519-1003-transcript.md"), started.TranscriptPath); Assert.Equal(Path.Combine(englishRoot, "Notes", "20260519-1003-note.md"), started.MeetingNotePath); Assert.Equal(Path.Combine(englishRoot, "Context", "20260519-1003-context.md"), started.AssistantContextPath); Assert.Equal(Path.Combine(englishRoot, "Summaries", "20260519-1003-summary.md"), started.SummaryPath); Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model); } [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, dictationWordProvider: CreateRecordingDictationWordProvider( 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 StartContinuesWithoutDictationWordsWhenStoreCannotBeRead() { await StartContinuesWithoutDictationWordsAsync(new ThrowingDictationWordStore()); } [Fact] public async Task StartContinuesWithoutDictationWordsWhenStoreReadTimesOut() { await StartContinuesWithoutDictationWordsAsync( new HangingDictationWordStore(), TimeSpan.FromMilliseconds(20)); } private static async Task StartContinuesWithoutDictationWordsAsync( IDictationWordStore dictationWordStore, TimeSpan? dictationWordsReadTimeout = null) { var audioSource = new ControlledAudioSource(); var provider = new CapturingPipelineOptionsProvider(); var options = new MeetingAssistantOptions(); if (dictationWordsReadTimeout is not null) { options.Recording.DictationWordsReadTimeout = dictationWordsReadTimeout.Value; } var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(provider), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(options), NullLogger.Instance, dictationWordProvider: CreateRecordingDictationWordProvider(dictationWordStore)); var status = await coordinator.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None); await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(status.IsRecording); Assert.Empty(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 StopRemovesIdentifiedSpeakerAliasDuplicatesWhenCanonicalAttendeeExists() { 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(["Ada", "Christopher", "Chris ", "Chris"]); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); var attendees = Assert.IsType>(noteStore.SavedNote?.Frontmatter.Attendees); Assert.Equal(["Ada", "Christopher"], 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), MinimumSampleSpeechDuration = TimeSpan.Zero } }), 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) == true)); await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true)); 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) == true && 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), MinimumSampleSpeechDuration = TimeSpan.Zero } }), 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), MinimumSampleSpeechDuration = TimeSpan.Zero } }), 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), MinimumSampleSpeechDuration = TimeSpan.Zero } }), 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 artifactStore = new InMemoryMeetingArtifactStore(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), transcriptStore, new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, 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); Assert.Equal(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime, artifactStore.ContextMeetingNote?.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.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Finished], artifactStore.States); } [Fact] public async Task CaptureScreenshotUsesCurrentMeetingArtifactsAndStartTime() { var audioSource = new ControlledAudioSource(); var screenshotService = new CapturingMeetingScreenshotService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance, screenshotService: screenshotService); var started = await coordinator.StartAsync(CancellationToken.None); var result = await coordinator.CaptureScreenshotAsync(CancellationToken.None); Assert.Equal(started.AssistantContextPath, screenshotService.Artifacts?.AssistantContextPath); Assert.NotNull(screenshotService.MeetingStartedAt); Assert.Equal("memory-screenshot.png", result.ScreenshotPath); await coordinator.StopAsync(CancellationToken.None); } [Fact] public async Task CaptureScreenshotFailsAfterRecordingStopHasStarted() { var audioSource = new ControlledAudioSource(); var screenshotService = new CapturingMeetingScreenshotService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance, screenshotService: screenshotService); await coordinator.StartAsync(CancellationToken.None); var stop = coordinator.StopAsync(CancellationToken.None); await Assert.ThrowsAsync( () => coordinator.CaptureScreenshotAsync(CancellationToken.None)); audioSource.Complete(); await stop; } [Fact] public async Task StopWaitsForPendingScreenshotOcrBeforeSummarizing() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var artifactStore = new InMemoryMeetingArtifactStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var screenshotService = new BlockingMeetingScreenshotService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance, screenshotService: screenshotService); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); var stop = coordinator.StopAsync(CancellationToken.None); await screenshotService.WaitUntilOcrWaitStartedAsync(); Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States); Assert.Null(summaryPipeline.Artifacts); screenshotService.ReleaseOcrWait(); await stop; Assert.Contains(AssistantContextState.Summarizing, artifactStore.States); Assert.NotNull(summaryPipeline.Artifacts); } [Fact] public async Task StopProcessesMeetingNoteImageEmbedsBeforeSummarizing() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var artifactStore = new InMemoryMeetingArtifactStore(); var summaryPipeline = new CapturingMeetingSummaryPipeline(); var screenshotService = new BlockingMeetingScreenshotService(); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()), new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), artifactStore, new InMemoryRecordedAudioStore(), summaryPipeline, Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance, screenshotService: screenshotService, meetingNoteImageOcrService: screenshotService); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); var stop = coordinator.StopAsync(CancellationToken.None); await screenshotService.WaitUntilMeetingNoteImageProcessingStartedAsync(); await screenshotService.WaitUntilOcrWaitStartedAsync(); Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States); Assert.Null(summaryPipeline.Artifacts); screenshotService.ReleaseOcrWait(); await stop; Assert.Contains(AssistantContextState.Summarizing, artifactStore.States); Assert.NotNull(summaryPipeline.Artifacts); } [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.Transcribing, 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.Transcribing, AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished], artifactStore.States); } [Fact] public async Task FinalSpeakerIdentityProcessingUsesAttendeesUpdatedBySummary() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var noteStore = new InMemoryMeetingNoteStore(); var speakerIdentification = new CapturingFinalSpeakerIdentificationService(); 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 AttendeeUpdatingSummaryPipeline(noteStore, ["Summary Attendee"]), Options.Create(new MeetingAssistantOptions()), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal(["Summary Attendee"], speakerIdentification.FinalAttendees); } [Fact] public async Task FinalSpeakerIdentityProcessingAppliesSummarySpeakerOverrides() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md")); var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md")); var speakerIdentification = new CapturingOverrideSpeakerIdentificationService(); var finalizer = new CapturingTranscriptFinalizer( [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]); var options = CreateOptionsWithoutFinalizer(); options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes"); options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts"); options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context"); options.Vault.SummariesFolder = Path.Combine(root, "Summaries"); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory( new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync), transcriptStore, noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(createAssistantContextFile: true), new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")), new SummarySpeakerOverridePipeline("Guest-01", "Sabrina"), Options.Create(options), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal(("Guest-01", "Sabrina"), speakerIdentification.Overrides.Single()); Assert.Equal("Sabrina", speakerIdentification.FinalSegments.Single().Speaker); Assert.Equal("Sabrina", transcriptStore.ReplacedSegments.Single().Speaker); Assert.Equal("Sabrina", speakerIdentification.FinalKnownSpeakerMappings["Guest-01"]); } [Fact] public async Task FinalSpeakerIdentityProcessingAppliesSummaryIdentityDeletions() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md")); var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md")); var speakerIdentification = new CapturingOverrideSpeakerIdentificationService("Guest-01", "Sabrina"); var finalizer = new CapturingTranscriptFinalizer( [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]); var options = CreateOptionsWithoutFinalizer(); options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes"); options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts"); options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context"); options.Vault.SummariesFolder = Path.Combine(root, "Summaries"); var coordinator = new MeetingRecordingCoordinator( audioSource, new TestSpeechRecognitionPipelineFactory( new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync), transcriptStore, noteStore, new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(createAssistantContextFile: true), new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")), new SummaryIdentityDeletionPipeline("Sabrina"), Options.Create(options), NullLogger.Instance, speakerIdentificationService: speakerIdentification); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Equal("Sabrina", speakerIdentification.DeletedIdentities.Single()); Assert.Equal("Removed-1", speakerIdentification.FinalSegments.Single().Speaker); Assert.Equal("Removed-1", transcriptStore.ReplacedSegments.Single().Speaker); } [Fact] public async Task RecordingLifecycleRunsMeetingWorkflowEvents() { var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1)); var workflowEngine = new CapturingMeetingWorkflowEngine(); 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(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(), new InMemoryMeetingArtifactStore(), new InMemoryRecordedAudioStore(), new CapturingMeetingSummaryPipeline(), Options.Create(CreateOptionsWithoutFinalizer()), NullLogger.Instance, speakerIdentificationService: new FixedSpeakerIdentificationService("Guest03", "Chris"), meetingWorkflowEngine: workflowEngine); await coordinator.StartAsync(CancellationToken.None); await audioSource.WaitUntilCapturedAsync(); await coordinator.StopAsync(CancellationToken.None); Assert.Contains(workflowEngine.Events, e => e.Type == MeetingWorkflowEventType.Created); Assert.Contains(workflowEngine.Events, e => e.Type == MeetingWorkflowEventType.StateTransition && e.FromState == AssistantContextState.CollectingMetadata && e.ToState == AssistantContextState.Transcribing); Assert.Contains(workflowEngine.Events, e => e.Type == MeetingWorkflowEventType.SpeakerIdentified && e.SpeakerName == "Chris"); } [Fact] public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsLines() { 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.AppendLineAsync(session, "[00:00:00] 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)); var artifacts = new MeetingSessionArtifacts( MeetingNotePath: Path.Combine(vaultFolder, "meeting.md"), TranscriptPath: session.TranscriptPath, AssistantContextPath: Path.Combine(vaultFolder, "context.md"), SummaryPath: Path.Combine(vaultFolder, "summary.md")); await store.UpdateMetadataAsync( session, artifacts, MeetingNoteTemplate.Create("Transcript Metadata", transcriptPath: session.TranscriptPath), CancellationToken.None); var content = await File.ReadAllTextAsync(session.TranscriptPath); Assert.Contains("meeting: \"[[meeting|Meeting Note]]\"", content); Assert.Contains("assistant_context: \"[[context|Assistant Context]]\"", content); Assert.Contains("summary: \"[[summary|Summary]]\"", content); Assert.DoesNotContain("transcript:", content); } [Fact] public async Task VaultTranscriptStoreRewritesOneLineWithoutRemovingFollowingLine() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var vaultFolder = Path.Combine(root, "Transcripts"); var store = new VaultTranscriptStore( Options.Create(new MeetingAssistantOptions { Vault = new VaultOptions { TranscriptsFolder = vaultFolder } }), NullLogger.Instance); var session = await store.CreateSessionAsync(CancellationToken.None); var artifacts = new MeetingSessionArtifacts( MeetingNotePath: Path.Combine(root, "Notes", "meeting.md"), TranscriptPath: session.TranscriptPath, AssistantContextPath: Path.Combine(root, "Context", "context.md"), SummaryPath: Path.Combine(root, "Summaries", "summary.md")); await store.UpdateMetadataAsync( session, artifacts, MeetingNoteTemplate.Create("Transcript Rewrite", transcriptPath: session.TranscriptPath), CancellationToken.None); var original = "[00:00:01] Guest-1: original workflow text"; var rewritten = "[00:00:01] Guest-1: rewritten workflow text"; var following = "[00:00:02] Guest-2: following transcript text"; var originalReference = await store.AppendLineAsync(session, original, CancellationToken.None); await store.AppendLineAsync(session, following, CancellationToken.None); await store.ReplaceLineAsync(session, originalReference, rewritten, CancellationToken.None); var content = await File.ReadAllTextAsync(session.TranscriptPath); Assert.Contains(rewritten, content); Assert.Contains(following, content); Assert.DoesNotContain(original, content); Assert.True( content.IndexOf(rewritten, StringComparison.Ordinal) < content.IndexOf(following, StringComparison.Ordinal)); Assert.Contains("meeting:", content); } [Fact] public async Task VaultTranscriptStoreNamesGeneratedTranscriptWithMinutePrecision() { var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var options = new MeetingAssistantOptions { Vault = new VaultOptions { TranscriptsFolder = vaultFolder } }; var store = new VaultTranscriptStore( Options.Create(options), NullLogger.Instance); var session = await store.CreateSessionAsync( options, DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"), CancellationToken.None); Assert.Equal("20260519-1003-transcript.md", Path.GetFileName(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 TemporaryRecordedAudioStoreDoesNotWriteDiagnosticSidecars() { 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([3, 0], 16000, 1), CancellationToken.None); await session.CompleteAsync(CancellationToken.None); var microphonePath = Path.Combine( recordingFolder, $"{Path.GetFileNameWithoutExtension(session.AudioPath)}-microphone.wav"); var systemPath = Path.Combine( recordingFolder, $"{Path.GetFileNameWithoutExtension(session.AudioPath)}-system.wav"); Assert.True(new FileInfo(session.AudioPath).Length > 44); Assert.False(File.Exists(microphonePath)); Assert.False(File.Exists(systemPath)); } [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)); } [Fact] public async Task TemporaryRecordedAudioStoreKeepsQueuedOfflineRecordingsOnStartup() { var recordingFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(recordingFolder); var queuedRecording = Path.Combine(recordingFolder, "queued.wav"); var staleRecording = Path.Combine(recordingFolder, "stale.wav"); await File.WriteAllTextAsync(queuedRecording, "queued"); await File.WriteAllTextAsync(staleRecording, "stale"); var backlog = new InMemoryOfflineTranscriptionBacklog(); await backlog.EnqueueAsync(new OfflineTranscriptionBacklogItem( Id: "queued", AudioPath: queuedRecording, TranscriptPath: "transcript.md", MeetingNotePath: "meeting.md", AssistantContextPath: "context.md", SummaryPath: "summary.md", StartedAt: DateTimeOffset.Parse("2026-06-15T10:00:00+02:00"), InferredEndTime: null, LaunchProfileName: "default"), CancellationToken.None); var store = new TemporaryRecordedAudioStore( Options.Create(new MeetingAssistantOptions { Recording = new RecordingOptions { TemporaryRecordingsFolder = recordingFolder } }), NullLogger.Instance, backlog); await store.DeleteStaleRecordingsAsync(CancellationToken.None); Assert.True(File.Exists(queuedRecording)); Assert.False(File.Exists(staleRecording)); } private static MeetingAssistantOptions CreateOptionsWithoutFinalizer() { return new MeetingAssistantOptions { Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" }, WhisperLocal = new WhisperLocalOptions { Diarization = new PyannoteDiarizationOptions { Enabled = false } } }; } private static AudioChunk CreateThreeSecondChunk() { return new AudioChunk(new byte[16000 * 2 * 3], 16000, 1); } private static ILaunchProfileOptionsProvider CreateLaunchProfiles( string defaultRoot, string englishRoot, bool enableSpeakerIdentification = false) { var values = new Dictionary { ["MeetingAssistant:Vault:BaseFolder"] = defaultRoot, ["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts", ["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes", ["MeetingAssistant:Vault:AssistantContextFolder"] = "Context", ["MeetingAssistant:Vault:SummariesFolder"] = "Summaries", ["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"), ["MeetingAssistant:Agent:Model"] = "default-summary-model", ["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+L", ["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot, ["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"), ["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model" }; if (enableSpeakerIdentification) { values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true"; values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00"; values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050"; values["MeetingAssistant:SpeakerIdentification:MinimumSampleSpeechDuration"] = "00:00:00"; } var configuration = new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); return new ConfigurationLaunchProfileOptionsProvider(configuration); } private sealed class InMemoryTranscriptStore : ITranscriptStore { private readonly object gate = new(); private readonly List segments = []; private readonly string transcriptPath; public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md") { this.transcriptPath = transcriptPath; } public List CreatedSessions { get; } = []; public Task CreateSessionAsync(CancellationToken cancellationToken) { var session = new TranscriptSession(transcriptPath); CreatedSessions.Add(session); return Task.FromResult(session); } public Task CreateSessionAsync( MeetingAssistantOptions options, DateTimeOffset startedAt, CancellationToken cancellationToken) { return CreateSessionAsync(cancellationToken); } public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) { int index; lock (gate) { index = segments.Count; segments.Add(ParseTranscriptLine(line)); } return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line)); } public Task ReplaceLineAsync( TranscriptSession session, TranscriptLineReference lineReference, string replacementLine, CancellationToken cancellationToken) { var replacement = ParseTranscriptLine(replacementLine); lock (gate) { if (lineReference.BodyLineIndex >= 0 && lineReference.BodyLineIndex < segments.Count) { segments[lineReference.BodyLineIndex] = replacement; } } return Task.CompletedTask; } public Task WaitForTextAsync(string text) { return WaitUntilAsync( () => Segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true), $"Segment containing '{text}' was not written."); } public IReadOnlyList ReplacedSegments { get; private set; } = []; public IReadOnlyList Segments { get { lock (gate) { return segments.ToArray(); } } } public MeetingNote? MetadataMeetingNote { get; private set; } public Task ReplaceLinesAsync( TranscriptSession session, IReadOnlyList replacementLines, CancellationToken cancellationToken) { ReplacedSegments = replacementLines.Select(ParseTranscriptLine).ToList(); return Task.CompletedTask; } public Task UpdateMetadataAsync( TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { MetadataMeetingNote = meetingNote; return Task.CompletedTask; } } private sealed class SequencedTranscriptStore : ITranscriptStore { private int created; private int appendCount; public List AppendHistory { get; } = []; public List ReplacementHistory { get; } = []; public List MetadataHistory { get; } = []; public Task CreateSessionAsync(CancellationToken cancellationToken) { var index = Interlocked.Increment(ref created); return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md")); } public Task CreateSessionAsync( MeetingAssistantOptions options, DateTimeOffset startedAt, CancellationToken cancellationToken) { return CreateSessionAsync(cancellationToken); } public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) { var index = AppendHistory.Count(entry => entry.Session.TranscriptPath == session.TranscriptPath); AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line))); Interlocked.Increment(ref appendCount); return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, index, line)); } public Task WaitForAppendCountAsync(int expectedCount) { return WaitUntilAsync( () => Volatile.Read(ref appendCount) >= expectedCount, $"Expected {expectedCount} transcript append(s)."); } public Task ReplaceLineAsync( TranscriptSession session, TranscriptLineReference lineReference, string replacementLine, CancellationToken cancellationToken) { ReplacementHistory.Add(new TranscriptReplacement(session, [ParseTranscriptLine(replacementLine)])); return Task.CompletedTask; } public Task ReplaceLinesAsync( TranscriptSession session, IReadOnlyList replacementLines, CancellationToken cancellationToken) { ReplacementHistory.Add(new TranscriptReplacement( session, replacementLines.Select(ParseTranscriptLine).ToList())); return Task.CompletedTask; } public Task UpdateMetadataAsync( TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { MetadataHistory.Add(new TranscriptMetadataUpdate(session, artifacts, meetingNote)); return Task.CompletedTask; } public sealed record TranscriptWrite(TranscriptSession Session, TranscriptionSegment Segment); public sealed record TranscriptReplacement(TranscriptSession Session, IReadOnlyList Segments); public sealed record TranscriptMetadataUpdate( TranscriptSession Session, MeetingSessionArtifacts Artifacts, MeetingNote MeetingNote); } 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 SequencedMeetingNoteStore : IMeetingNoteStore { private int saved; private readonly Dictionary notes = new(StringComparer.OrdinalIgnoreCase); public Task SaveAsync(MeetingNote note, CancellationToken cancellationToken) { var path = string.IsNullOrWhiteSpace(note.Path) ? $"memory-meeting-{Interlocked.Increment(ref saved)}.md" : note.Path; var savedNote = note with { Path = path }; notes[path] = savedNote; return Task.FromResult(savedNote); } public Task ReadAsync(string path, CancellationToken cancellationToken) { return Task.FromResult(notes.TryGetValue(path, out var note) ? note : throw new FileNotFoundException(path)); } } 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 CapturingMeetingWorkflowEngine : IMeetingWorkflowEngine { public List Events { get; } = []; public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { Events.Add(workflowEvent); return Task.CompletedTask; } public Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { Events.Add(workflowEvent); return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); } } private sealed class MetadataObservingWorkflowEngine : IMeetingWorkflowEngine { private readonly Func getMeetingNote; public MetadataObservingWorkflowEngine(Func getMeetingNote) { this.getMeetingNote = getMeetingNote; } public List ObservedEvents { get; } = []; public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { var note = getMeetingNote(); ObservedEvents.Add(new ObservedWorkflowEvent( workflowEvent.Type, note?.Frontmatter.Title, workflowEvent.FromState, workflowEvent.ToState)); return Task.CompletedTask; } public Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); } public sealed record ObservedWorkflowEvent( MeetingWorkflowEventType Type, string? Title, AssistantContextState? FromState, AssistantContextState? ToState); } private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine { public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { if (workflowEvent.TranscriptLineText?.Contains("first", StringComparison.Ordinal) == true) { throw new InvalidOperationException("Workflow rule failed."); } return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); } } private sealed class ObservingTranscriptRewriteWorkflowEngine : IMeetingWorkflowEngine { private readonly InMemoryTranscriptStore transcriptStore; public ObservingTranscriptRewriteWorkflowEngine(InMemoryTranscriptStore transcriptStore) { this.transcriptStore = transcriptStore; } public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { if (!transcriptStore.Segments.Any(segment => segment.Text.Contains("Azure returned ***** here.", StringComparison.Ordinal))) { throw new InvalidOperationException("Original transcript line was not written before workflow processing."); } return Task.FromResult( (workflowEvent.TranscriptLineText ?? "").Replace("*****", "[redacted]", StringComparison.Ordinal)); } } private sealed class DelayedFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine { private readonly TaskCompletionSource firstLineWorkflowStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource completeFirstLineWorkflow = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.CompletedTask; } public async Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { var line = workflowEvent.TranscriptLineText ?? ""; if (!line.Contains("first has enough words for identification.", StringComparison.Ordinal)) { return line; } firstLineWorkflowStarted.TrySetResult(); await completeFirstLineWorkflow.Task.WaitAsync(cancellationToken); return line.Replace("first", "rewritten first", StringComparison.Ordinal); } public Task WaitUntilFirstLineWorkflowStartedAsync() { return firstLineWorkflowStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void CompleteFirstLineWorkflow() { completeFirstLineWorkflow.TrySetResult(); } } private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService { public MeetingSessionArtifacts? Artifacts { get; private set; } public DateTimeOffset? MeetingStartedAt { get; private set; } public Task CaptureAsync( MeetingSessionArtifacts artifacts, DateTimeOffset? meetingStartedAt, DateTimeOffset capturedAt, MeetingAssistantOptions options, CancellationToken cancellationToken) { Artifacts = artifacts; MeetingStartedAt = meetingStartedAt; return Task.FromResult(new MeetingScreenshotCaptureResult( "memory-screenshot.png", TimeSpan.FromSeconds(1), OcrStarted: false)); } public Task WaitForPendingOcrAsync( MeetingSessionArtifacts artifacts, TimeSpan timeout, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task CancelPendingOcrAsync( MeetingSessionArtifacts artifacts, TimeSpan timeout, CancellationToken cancellationToken) { return Task.CompletedTask; } } private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService, IMeetingNoteImageOcrService { private readonly TaskCompletionSource waitStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource releaseWait = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource meetingNoteImageProcessingStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task CaptureAsync( MeetingSessionArtifacts artifacts, DateTimeOffset? meetingStartedAt, DateTimeOffset capturedAt, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(new MeetingScreenshotCaptureResult( "memory-screenshot.png", TimeSpan.Zero, OcrStarted: true)); } public async Task WaitForPendingOcrAsync( MeetingSessionArtifacts artifacts, TimeSpan timeout, CancellationToken cancellationToken) { waitStarted.TrySetResult(); await releaseWait.Task.WaitAsync(cancellationToken); } public Task CancelPendingOcrAsync( MeetingSessionArtifacts artifacts, TimeSpan timeout, CancellationToken cancellationToken) { releaseWait.TrySetResult(); return Task.CompletedTask; } public Task ProcessMeetingNoteImageEmbedsAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { meetingNoteImageProcessingStarted.TrySetResult(); return Task.FromResult(new MeetingNoteImageOcrQueueResult(0)); } public Task WaitUntilMeetingNoteImageProcessingStartedAsync() { return meetingNoteImageProcessingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public Task WaitUntilOcrWaitStartedAsync() { return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void ReleaseOcrWait() { releaseWait.TrySetResult(); } } private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore { private readonly bool createAssistantContextFile; public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false) { this.createAssistantContextFile = createAssistantContextFile; } public MeetingSessionArtifacts? CreatedArtifacts { get; private set; } public List States { get; } = []; public string? Agenda { get; private set; } public DateTimeOffset? ScheduledEnd { get; private set; } public MeetingNote? ContextMeetingNote { get; private set; } public Task CreateAssistantContextAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { CreatedArtifacts = artifacts; ContextMeetingNote = meetingNote; Agenda = agenda; ScheduledEnd = scheduledEnd; if (createAssistantContextFile) { Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!); return File.WriteAllTextAsync( artifacts.AssistantContextPath, """ --- state: summarizing --- # Assistant Context """, cancellationToken); } return Task.CompletedTask; } public Task UpdateAssistantContextStateAsync( MeetingSessionArtifacts artifacts, AssistantContextState state, CancellationToken cancellationToken) { States.Add(state); return Task.CompletedTask; } public Task UpdateAssistantContextMetadataAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { ContextMeetingNote = meetingNote; Agenda = agenda; ScheduledEnd = scheduledEnd; return Task.CompletedTask; } public Task UpdateAssistantContextMeetingAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { ContextMeetingNote = meetingNote; return Task.CompletedTask; } } private sealed class CapturingArtifactStore : IMeetingArtifactStore { public List CreatedArtifacts { get; } = []; public List<(MeetingSessionArtifacts Artifacts, AssistantContextState State)> States { get; } = []; public Task CreateAssistantContextAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { CreatedArtifacts.Add(artifacts); return Task.CompletedTask; } public Task UpdateAssistantContextMetadataAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, string agenda, DateTimeOffset? scheduledEnd, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateAssistantContextMeetingAsync( MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateAssistantContextStateAsync( MeetingSessionArtifacts artifacts, AssistantContextState state, CancellationToken cancellationToken) { States.Add((artifacts, 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 CountingMeetingMetadataProvider : IMeetingMetadataProvider { public int CallCount { get; private set; } public Task GetCurrentMeetingAsync( DateTimeOffset startedAt, CancellationToken cancellationToken) { CallCount++; return Task.FromResult(null); } } 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 MeetingAssistantOptions? Options { get; private set; } public bool WasRun => Artifacts is not null; 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")); } public Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { Options = options; return RunAsync(artifacts, cancellationToken); } } private sealed class CapturingMeetingRunArtifactCleaner : IMeetingRunArtifactCleaner { public MeetingSessionArtifacts? DeletedArtifacts { get; private set; } public MeetingAssistantOptions? Options { get; private set; } public Task DeleteRunArtifactsAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { DeletedArtifacts = artifacts; Options = options; return Task.CompletedTask; } } private sealed class RecordingSummaryPipeline : IMeetingSummaryPipeline { public List ArtifactHistory { get; } = []; public Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { ArtifactHistory.Add(artifacts); return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok")); } public Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { return RunAsync(artifacts, cancellationToken); } } private sealed class AttendeeUpdatingSummaryPipeline : IMeetingSummaryPipeline { private readonly InMemoryMeetingNoteStore noteStore; private readonly IReadOnlyList attendees; public AttendeeUpdatingSummaryPipeline( InMemoryMeetingNoteStore noteStore, IReadOnlyList attendees) { this.noteStore = noteStore; this.attendees = attendees; } public Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { noteStore.UpdateAttendees(attendees); return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok")); } public Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { return RunAsync(artifacts, cancellationToken); } } private sealed class SummarySpeakerOverridePipeline : IMeetingSummaryPipeline { private readonly string sourceSpeaker; private readonly string targetSpeaker; public SummarySpeakerOverridePipeline( string sourceSpeaker, string targetSpeaker) { this.sourceSpeaker = sourceSpeaker; this.targetSpeaker = targetSpeaker; } public async Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { await SpeakerOverrideArtifacts.AppendToAssistantContextAsync( artifacts.AssistantContextPath, new SpeakerOverride(sourceSpeaker, targetSpeaker), cancellationToken); return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"); } public Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { return RunAsync(artifacts, cancellationToken); } } private sealed class SummaryIdentityDeletionPipeline : IMeetingSummaryPipeline { private readonly string identity; public SummaryIdentityDeletionPipeline(string identity) { this.identity = identity; } public async Task RunAsync( MeetingSessionArtifacts artifacts, CancellationToken cancellationToken) { var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync( artifacts.TranscriptPath, identity, cancellationToken); await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync( artifacts.AssistantContextPath, deletion, cancellationToken); return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"); } public Task RunAsync( MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, CancellationToken cancellationToken) { return RunAsync(artifacts, cancellationToken); } } private sealed class BlockingFirstFinalizer { private readonly TaskCompletionSource firstFinalizerBlocked = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource releaseFirstFinalizer = new(TaskCreationOptions.RunContinuationsAsynchronously); private int calls; public Task WaitUntilFirstFinalizerIsBlockedAsync() { return firstFinalizerBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void ReleaseFirstFinalizer() { releaseFirstFinalizer.TrySetResult(); } public async Task> FinalizeAsync( string audioPath, IReadOnlyList liveSegments, SpeechRecognitionPipelineOptions options, CancellationToken cancellationToken) { var call = Interlocked.Increment(ref calls); if (call == 1) { firstFinalizerBlocked.TrySetResult(); await releaseFirstFinalizer.Task.WaitAsync(cancellationToken); } return [ new TranscriptionSegment( TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", $"final run {call}") ]; } } private sealed class ProfileAwareTranscriptStore : ITranscriptStore { public Task CreateSessionAsync(CancellationToken cancellationToken) { throw new InvalidOperationException("Profile options were not supplied."); } public Task CreateSessionAsync( MeetingAssistantOptions options, DateTimeOffset startedAt, CancellationToken cancellationToken) { var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder); return Task.FromResult(new TranscriptSession(Path.Combine( folder, MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript)))); } public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) { return Task.FromResult(new TranscriptLineReference(session.TranscriptPath, 0, line)); } public Task ReplaceLineAsync( TranscriptSession session, TranscriptLineReference lineReference, string replacementLine, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task ReplaceLinesAsync( TranscriptSession session, IReadOnlyList replacementLines, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task UpdateMetadataAsync( TranscriptSession session, MeetingSessionArtifacts artifacts, MeetingNote meetingNote, CancellationToken cancellationToken) { return Task.CompletedTask; } } private sealed class ProfileAwareMeetingNoteStore : IMeetingNoteStore { private MeetingNote? savedNote; public Task SaveAsync(MeetingNote note, CancellationToken cancellationToken) { throw new InvalidOperationException("Profile options were not supplied."); } public Task SaveAsync( MeetingNote note, MeetingAssistantOptions options, CancellationToken cancellationToken) { var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder); savedNote = note with { Path = string.IsNullOrWhiteSpace(note.Path) ? Path.Combine( folder, MeetingArtifactFileNames.Create( note.Frontmatter.StartTime ?? DateTimeOffset.Now, MeetingArtifactFileNames.Note)) : note.Path }; return Task.FromResult(savedNote); } public Task ReadAsync(string path, CancellationToken cancellationToken) { return Task.FromResult(savedNote ?? throw new FileNotFoundException(path)); } } private sealed class ProfileAwareRecordedAudioStore : IRecordedAudioStore { public Task CreateSessionAsync(CancellationToken cancellationToken) { throw new InvalidOperationException("Profile options were not supplied."); } public Task CreateSessionAsync( MeetingAssistantOptions options, CancellationToken cancellationToken) { var folder = VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder); return Task.FromResult(new Sink(Path.Combine(folder, "recording.wav"))); } public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } private sealed class Sink : IRecordedAudioSink { public Sink(string audioPath) { AudioPath = audioPath; } public string AudioPath { get; } public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task CompleteAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } public Task DeleteAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } } } 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 InMemoryOfflineTranscriptionBacklog : IOfflineTranscriptionBacklog { public List Items { get; } = []; public Task EnqueueAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken) { Items.Add(item); return Task.CompletedTask; } public Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>(Items.ToList()); } public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken) { Items.RemoveAll(existing => string.Equals(existing.Id, item.Id, StringComparison.Ordinal)); 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 CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { private readonly IStreamingTranscriptionProvider provider; public CapturingProfileSpeechRecognitionPipelineFactory(IStreamingTranscriptionProvider provider) { this.provider = provider; } public string? LastProfileName { get; private set; } public ISpeechRecognitionPipeline Create() { return Create(null); } public ISpeechRecognitionPipeline Create(string? launchProfileName) { LastProfileName = launchProfileName; return new TestSpeechRecognitionPipeline( provider, (_, _, _, _) => Task.FromResult>([])); } } private sealed class ProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { private readonly string speaker; public ProfileSwitchSpeechRecognitionPipelineFactory(string speaker = "Unknown") { this.speaker = speaker; } public List ProfileNames { get; } = []; public ISpeechRecognitionPipeline Create() { return Create(null); } public ISpeechRecognitionPipeline Create(string? launchProfileName) { ProfileNames.Add(launchProfileName); return new TestSpeechRecognitionPipeline( new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", speaker), (_, _, _, _) => Task.FromResult>([])); } } private sealed class BlockingProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory { private readonly TaskCompletionSource firstPipelineDrainBlocked = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource releaseFirstPipelineDrain = new(TaskCreationOptions.RunContinuationsAsynchronously); private int created; public List ProfileNames { get; } = []; public ISpeechRecognitionPipeline Create() { return Create(null); } public ISpeechRecognitionPipeline Create(string? launchProfileName) { ProfileNames.Add(launchProfileName); var createIndex = Interlocked.Increment(ref created); var provider = createIndex == 1 ? new BlockingOnCompletionProfileSwitchTranscriptionProvider( launchProfileName ?? "default", firstPipelineDrainBlocked, releaseFirstPipelineDrain) : new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", "Unknown"); return new TestSpeechRecognitionPipeline( provider, (_, _, _, _) => Task.FromResult>([])); } public Task WaitUntilFirstPipelineDrainIsBlockedAsync() { return firstPipelineDrainBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5)); } public void ReleaseFirstPipelineDrain() { releaseFirstPipelineDrain.TrySetResult(); } } private class ProfileSwitchTranscriptionProvider : IStreamingTranscriptionProvider { private readonly string profileName; private readonly string speaker; public ProfileSwitchTranscriptionProvider(string profileName, string speaker) { this.profileName = profileName; this.speaker = speaker; } public virtual async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var chunk in audio.WithCancellation(cancellationToken)) { yield return new TranscriptionSegment( TimeSpan.Zero, TimeSpan.FromSeconds(3), speaker, $"{profileName} chunk:{chunk.Pcm.Length} has enough words for identification."); } } } private sealed class BlockingOnCompletionProfileSwitchTranscriptionProvider : ProfileSwitchTranscriptionProvider { private readonly TaskCompletionSource drainBlocked; private readonly TaskCompletionSource releaseDrain; public BlockingOnCompletionProfileSwitchTranscriptionProvider( string profileName, TaskCompletionSource drainBlocked, TaskCompletionSource releaseDrain) : base(profileName, "Unknown") { this.drainBlocked = drainBlocked; this.releaseDrain = releaseDrain; } public override async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var segment in base.TranscribeAsync(audio, options, cancellationToken)) { yield return segment; } drainBlocked.TrySetResult(); await releaseDrain.Task.WaitAsync(cancellationToken); } } 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 SingleMappingSpeakerIdentificationService : ISpeakerIdentificationService { private readonly string sourceSpeaker; private readonly string targetSpeaker; private int mapped; public SingleMappingSpeakerIdentificationService(string sourceSpeaker, string targetSpeaker) { this.sourceSpeaker = sourceSpeaker; this.targetSpeaker = targetSpeaker; } public TaskCompletionSource IdentificationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { IdentificationObserved.TrySetResult(); if (Interlocked.Exchange(ref mapped, 1) == 0) { return Task.FromResult(new SpeakerIdentificationResult( request.Segments, new Dictionary { [sourceSpeaker] = targetSpeaker }, [new SpeakerIdentityAttendeeMatch(targetSpeaker, [targetSpeaker])])); } 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 CapturingFinalSpeakerIdentificationService : ISpeakerIdentificationService { public IReadOnlyList FinalAttendees { get; private set; } = []; public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } public Task ProcessFinishedTranscriptAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { FinalAttendees = request.MeetingNote.Frontmatter.Attendees.ToList(); return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } } private sealed class CapturingOverrideSpeakerIdentificationService : ISpeakerIdentificationService { private readonly string? readOnlySourceSpeaker; private readonly string? readOnlyTargetSpeaker; public CapturingOverrideSpeakerIdentificationService( string? readOnlySourceSpeaker = null, string? readOnlyTargetSpeaker = null) { this.readOnlySourceSpeaker = readOnlySourceSpeaker; this.readOnlyTargetSpeaker = readOnlyTargetSpeaker; } public List<(string SourceSpeaker, string TargetSpeaker)> Overrides { get; } = []; public List DeletedIdentities { get; } = []; public IReadOnlyList FinalSegments { get; private set; } = []; public IReadOnlyDictionary FinalKnownSpeakerMappings { get; private set; } = new Dictionary(); public Task IdentifyKnownSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } public Task IdentifyFinishedSpeakersAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(readOnlySourceSpeaker) || string.IsNullOrWhiteSpace(readOnlyTargetSpeaker)) { return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary())); } return Task.FromResult(new SpeakerIdentificationResult( request.Segments.Select(segment => segment.Speaker == readOnlySourceSpeaker ? segment with { Speaker = readOnlyTargetSpeaker } : segment).ToList(), new Dictionary(StringComparer.OrdinalIgnoreCase) { [readOnlySourceSpeaker] = readOnlyTargetSpeaker })); } public Task ApplySpeakerOverrideAsync( SpeakerIdentificationRequest request, string sourceSpeaker, string targetSpeaker, CancellationToken cancellationToken) { Overrides.Add((sourceSpeaker, targetSpeaker)); return Task.CompletedTask; } public Task DeleteSpeakerIdentityAsync( string identity, CancellationToken cancellationToken) { DeletedIdentities.Add(identity); return Task.CompletedTask; } public Task ProcessFinishedTranscriptAsync( SpeakerIdentificationRequest request, CancellationToken cancellationToken) { FinalSegments = request.Segments; FinalKnownSpeakerMappings = request.KnownSpeakerMappings ?? new Dictionary(StringComparer.OrdinalIgnoreCase); 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 static RecordingDictationWordProvider CreateRecordingDictationWordProvider( IDictationWordStore dictationWordStore) { return new RecordingDictationWordProvider( dictationWordStore, NullLogger.Instance); } private sealed class ThrowingDictationWordStore : IDictationWordStore { public Task> ReadWordsAsync(CancellationToken cancellationToken) { throw new IOException("dictation words unavailable"); } public Task> AddWordAsync(string word, CancellationToken cancellationToken) { throw new IOException("dictation words unavailable"); } } private sealed class HangingDictationWordStore : IDictationWordStore { public async Task> ReadWordsAsync(CancellationToken cancellationToken) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return []; } public Task> AddWordAsync(string word, CancellationToken cancellationToken) { return Task.FromResult>([]); } } private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer { private readonly IReadOnlyList attendees; public FixedAttendeeCanonicalizer(IReadOnlyList attendees) { this.attendees = attendees; } public Task> CanonicalizeAsync( IReadOnlyList attendees, CancellationToken cancellationToken) { return Task.FromResult(this.attendees); } } 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); } public void Complete() { chunks.Writer.TryComplete(); } } private sealed class ManualMeetingInactivityClock : IMeetingInactivityClock { private readonly object gate = new(); private readonly List delays = []; public ManualMeetingInactivityClock(DateTimeOffset now) { Now = now; } public DateTimeOffset Now { get; private set; } public int PendingDelayCount { get { lock (gate) { return delays.Count; } } } public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { if (delay <= TimeSpan.Zero) { return Task.CompletedTask; } lock (gate) { if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } var scheduledDelay = new ScheduledDelay( Now + delay, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); var registration = cancellationToken.Register( static state => { var delay = (ScheduledDelay)state!; delay.Completion.TrySetCanceled(); }, scheduledDelay); scheduledDelay.CancellationRegistration = registration; delays.Add(scheduledDelay); return scheduledDelay.Completion.Task; } } public void Advance(TimeSpan duration) { List due; lock (gate) { Now += duration; due = delays.Where(delay => delay.DueAt <= Now).ToList(); foreach (var delay in due) { delays.Remove(delay); } } foreach (var delay in due) { delay.CancellationRegistration.Dispose(); delay.Completion.TrySetResult(); } } private sealed class ScheduledDelay { public ScheduledDelay(DateTimeOffset dueAt, TaskCompletionSource completion) { DueAt = dueAt; Completion = completion; } public DateTimeOffset DueAt { get; } public TaskCompletionSource Completion { get; } public CancellationTokenRegistration CancellationRegistration { get; set; } } } private sealed class CapturingMeetingInactivityPromptService : IMeetingInactivityPromptService { private readonly Queue responses; private TaskCompletionSource promptObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); public CapturingMeetingInactivityPromptService(params MeetingInactivityPromptResponse[] responses) { this.responses = new Queue(responses); } public List Requests { get; } = []; public async Task ShowStopPromptAsync( MeetingInactivityPromptRequest request, Func handleResponseAsync, CancellationToken cancellationToken) { Requests.Add(request); promptObserved.TrySetResult(); var response = responses.Count > 0 ? responses.Dequeue() : MeetingInactivityPromptResponse.Continue; await handleResponseAsync(response, cancellationToken); } public Task WaitForPromptAsync() { return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); } } private sealed class IgnoringMeetingInactivityPromptService : IMeetingInactivityPromptService { private readonly TaskCompletionSource promptObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); public List Requests { get; } = []; public Task ShowStopPromptAsync( MeetingInactivityPromptRequest request, Func handleResponseAsync, CancellationToken cancellationToken) { Requests.Add(request); promptObserved.TrySetResult(); return Task.CompletedTask; } public Task WaitForPromptAsync() { return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5)); } } 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 FixedSegmentStreamingTranscriptionProvider : IStreamingTranscriptionProvider { private readonly TranscriptionSegment segment; public FixedSegmentStreamingTranscriptionProvider(TranscriptionSegment segment) { this.segment = segment; } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var _ in audio.WithCancellation(cancellationToken)) { yield return segment; } } } 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 StaticOnAudioCompletionProvider : IStreamingTranscriptionProvider { private readonly IReadOnlyList segments; public StaticOnAudioCompletionProvider(IReadOnlyList segments) { this.segments = segments; } public async IAsyncEnumerable TranscribeAsync( IAsyncEnumerable audio, SpeechRecognitionPipelineOptions options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var _ in audio.WithCancellation(cancellationToken)) { } foreach (var segment in segments) { yield return segment; } } } 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; } }