Files
meeting-assistant/MeetingAssistant.Tests/RecordingCoordinatorTests.cs
T
codex e54b19d3a9
PR and Push Build/Test / build-and-test (push) Successful in 10m14s
Use consistent meeting artifact filenames
2026-06-10 16:56:10 +02:00

3989 lines
164 KiB
C#

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<bool> condition)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("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<MeetingRecordingCoordinator>.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 TranscriptLineWorkflowRuleTransformsLiveTranscriptBeforePersistingMarkdown()
{
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<MarkdownMeetingNoteStore>.Instance);
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.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<VaultTranscriptStore>.Instance),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingWorkflowEngine: new MeetingWorkflowEngine(
new FileMeetingWorkflowRulesProvider(
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
NullLogger<MeetingWorkflowEngine>.Instance));
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "[redacted]"));
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 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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<VaultTranscriptStore>.Instance),
new MarkdownMeetingNoteStore(
Options.Create(options),
NullLogger<MarkdownMeetingNoteStore>.Instance),
new CapturingMeetingNoteOpener(),
new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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)));
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<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
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<MeetingRecordingCoordinator>.Instance,
inactivityPromptService: promptService,
inactivityClock: clock);
await coordinator.StartAsync(CancellationToken.None);
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<MeetingRecordingCoordinator>.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)));
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)));
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<MeetingRecordingCoordinator>.Instance,
new FixedMeetingMetadataProvider(new MeetingMetadata(
"Architecture Sync",
["Ada <ada@example.com>", "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 <ada@example.com>", "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 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<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata(
"Architecture Sync",
["Karl Berger <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "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 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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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 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 coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new VaultTranscriptStore(Options.Create(options), NullLogger<VaultTranscriptStore>.Instance),
new MarkdownMeetingNoteStore(Options.Create(options), NullLogger<MarkdownMeetingNoteStore>.Instance),
new CapturingMeetingNoteOpener(),
new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance);
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() =>
FileContainsText(started.AssistantContextPath!, "state: transcribing"));
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));
}
[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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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", 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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<List<string>>(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<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService(
"Guest03",
"Christopher",
["Christopher", "Chris"]));
await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateAttendees(["Chris <chris@example.com>"]);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal(["Chris <chris@example.com>"], 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<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService(
"Guest03",
"Christopher",
["Christopher", "Chris"]));
await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateAttendees(["Ada", "Christopher", "Chris <chris@example.com>", "Chris"]);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
var attendees = Assert.IsType<List<string>>(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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<List<string>>(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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<IReadOnlyList<SpeakerAudioSample>>(
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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.Instance,
screenshotService: screenshotService);
await coordinator.StartAsync(CancellationToken.None);
var stop = coordinator.StopAsync(CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => 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<MeetingRecordingCoordinator>.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 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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<MeetingRecordingCoordinator>.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<VaultTranscriptStore>.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 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<VaultTranscriptStore>.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<TemporaryRecordedAudioStore>.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<TemporaryRecordedAudioStore>.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<TemporaryRecordedAudioStore>.Instance);
await store.DeleteStaleRecordingsAsync(CancellationToken.None);
Assert.False(File.Exists(staleRecording));
Assert.True(File.Exists(unrelatedFile));
}
private static MeetingAssistantOptions CreateOptionsWithoutFinalizer()
{
return new MeetingAssistantOptions
{
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
WhisperLocal = new WhisperLocalOptions
{
Diarization = new PyannoteDiarizationOptions { Enabled = false }
}
};
}
private 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<string, string?>
{
["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 List<TranscriptionSegment> segments = [];
private readonly TaskCompletionSource segmentWritten = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly string transcriptPath;
public InMemoryTranscriptStore(string transcriptPath = "memory-transcript.md")
{
this.transcriptPath = transcriptPath;
}
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new TranscriptSession(transcriptPath));
}
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
segments.Add(ParseTranscriptLine(line));
segmentWritten.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitForTextAsync(string text)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true))
{
return;
}
await segmentWritten.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Segment containing '{text}' was not written.");
}
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
public IReadOnlyList<TranscriptionSegment> Segments => segments;
public MeetingNote? MetadataMeetingNote { get; private set; }
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> 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;
private TaskCompletionSource appendObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<TranscriptWrite> AppendHistory { get; } = [];
public List<TranscriptReplacement> ReplacementHistory { get; } = [];
public List<TranscriptMetadataUpdate> MetadataHistory { get; } = [];
public Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
var index = Interlocked.Increment(ref created);
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
}
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
Interlocked.Increment(ref appendCount);
appendObserved.TrySetResult();
return Task.CompletedTask;
}
public async Task WaitForAppendCountAsync(int expectedCount)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (Volatile.Read(ref appendCount) >= expectedCount)
{
return;
}
var observed = appendObserved;
await observed.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
if (ReferenceEquals(observed, appendObserved))
{
appendObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
}
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
}
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> 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<TranscriptionSegment> 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<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
SavedNote = note with { Path = notePath };
return Task.FromResult(SavedNote);
}
public Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
return Task.FromResult(SavedNote ?? throw new FileNotFoundException(path));
}
public void UpdateAttendees(IEnumerable<string> 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<string, MeetingNote> notes = new(StringComparer.OrdinalIgnoreCase);
public Task<MeetingNote> 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<MeetingNote> 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<MeetingWorkflowEvent> Events { get; } = [];
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Events.Add(workflowEvent);
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Events.Add(workflowEvent);
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
}
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
{
public MeetingSessionArtifacts? Artifacts { get; private set; }
public DateTimeOffset? MeetingStartedAt { get; private set; }
public Task<MeetingScreenshotCaptureResult> 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
{
private readonly TaskCompletionSource waitStarted =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseWait =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<MeetingScreenshotCaptureResult> 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 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<AssistantContextState> 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<MeetingSessionArtifacts> 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<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return Task.FromResult(metadata);
}
}
private sealed class CountingMeetingMetadataProvider : IMeetingMetadataProvider
{
public int CallCount { get; private set; }
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult<MeetingMetadata?>(null);
}
}
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
{
public Task<MeetingMetadata?> 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<MeetingMetadata?> 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<MeetingSummaryRunResult> 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<MeetingSummaryRunResult> 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<MeetingSessionArtifacts> ArtifactHistory { get; } = [];
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
ArtifactHistory.Add(artifacts);
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return RunAsync(artifacts, cancellationToken);
}
}
private sealed class AttendeeUpdatingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly InMemoryMeetingNoteStore noteStore;
private readonly IReadOnlyList<string> attendees;
public AttendeeUpdatingSummaryPipeline(
InMemoryMeetingNoteStore noteStore,
IReadOnlyList<string> attendees)
{
this.noteStore = noteStore;
this.attendees = attendees;
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
noteStore.UpdateAttendees(attendees);
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
}
public Task<MeetingSummaryRunResult> 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<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(
artifacts.AssistantContextPath,
new SpeakerOverride(sourceSpeaker, targetSpeaker),
cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
}
public Task<MeetingSummaryRunResult> 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<MeetingSummaryRunResult> 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<MeetingSummaryRunResult> 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<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> 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<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<TranscriptSession> 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.CompletedTask;
}
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> 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<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<MeetingNote> 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<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
{
return Task.FromResult(savedNote ?? throw new FileNotFoundException(path));
}
}
private sealed class ProfileAwareRecordedAudioStore : IRecordedAudioStore
{
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
throw new InvalidOperationException("Profile options were not supplied.");
}
public Task<IRecordedAudioSink> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
return Task.FromResult<IRecordedAudioSink>(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<int> 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<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IRecordedAudioSink>(new Sink(this, audioPath));
}
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private sealed class Sink : IRecordedAudioSink
{
private readonly InMemoryRecordedAudioStore store;
public Sink(InMemoryRecordedAudioStore store, string audioPath)
{
this.store = store;
AudioPath = audioPath;
}
public string AudioPath { get; }
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
store.AppendedChunkSizes.Add(chunk.Pcm.Length);
store.AppendObserved.TrySetResult();
return Task.CompletedTask;
}
public Task CompleteAsync(CancellationToken cancellationToken)
{
store.Completed = true;
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
public Task DeleteAsync(CancellationToken cancellationToken)
{
store.Deleted = true;
return Task.CompletedTask;
}
}
}
private sealed class TestSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly IStreamingTranscriptionProvider provider;
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipelineFactory(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>>? finalize = null)
{
this.provider = provider;
this.finalize = finalize ?? ((_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
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<IReadOnlyList<TranscriptionSegment>>([]));
}
}
private sealed class ProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly string speaker;
public ProfileSwitchSpeechRecognitionPipelineFactory(string speaker = "Unknown")
{
this.speaker = speaker;
}
public List<string?> 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<IReadOnlyList<TranscriptionSegment>>([]));
}
}
private sealed class BlockingProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly TaskCompletionSource firstPipelineDrainBlocked =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseFirstPipelineDrain =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int created;
public List<string?> 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<IReadOnlyList<TranscriptionSegment>>([]));
}
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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
public TestSpeechRecognitionPipeline(
IStreamingTranscriptionProvider provider,
Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize)
: base(provider)
{
this.finalize = finalize;
}
protected override Task<IReadOnlyList<TranscriptionSegment>> BuildFinishedTranscriptAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> liveSegments,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
return finalize(audioPath, liveSegments, options, cancellationToken);
}
}
private sealed class CapturingTranscriptFinalizer
{
private readonly IReadOnlyList<TranscriptionSegment> segments;
public CapturingTranscriptFinalizer(IReadOnlyList<TranscriptionSegment> segments)
{
this.segments = segments;
}
public string? AudioPath { get; private set; }
public IReadOnlyList<TranscriptionSegment> LiveSegments { get; private set; } = [];
public SpeechRecognitionPipelineOptions? Options { get; private set; }
public Task<IReadOnlyList<TranscriptionSegment>> FinalizeAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> 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<string> acceptedNames;
public FixedSpeakerIdentificationService(
string sourceSpeaker,
string targetSpeaker,
IReadOnlyList<string>? acceptedNames = null)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
this.acceptedNames = acceptedNames ?? [targetSpeaker];
}
public Task<SpeakerIdentificationResult> 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<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
}
public Task<SpeakerIdentificationResult> 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<string> acceptedNames;
public BlockingSpeakerIdentificationService(
string sourceSpeaker,
string targetSpeaker,
IReadOnlyList<string>? acceptedNames = null)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
this.acceptedNames = acceptedNames ?? [targetSpeaker];
}
public TaskCompletionSource IdentificationObserved { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
IdentificationObserved.TrySetResult();
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments,
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
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<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
IdentificationObserved.TrySetResult();
if (Interlocked.Exchange(ref mapped, 1) == 0)
{
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments,
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, [targetSpeaker])]));
}
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CapturingFinalSpeakerIdentificationService : ISpeakerIdentificationService
{
public IReadOnlyList<string> FinalAttendees { get; private set; } = [];
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
FinalAttendees = request.MeetingNote.Frontmatter.Attendees.ToList();
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
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<string> DeletedIdentities { get; } = [];
public IReadOnlyList<TranscriptionSegment> FinalSegments { get; private set; } = [];
public IReadOnlyDictionary<string, string> FinalKnownSpeakerMappings { get; private set; } =
new Dictionary<string, string>();
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(readOnlySourceSpeaker) ||
string.IsNullOrWhiteSpace(readOnlyTargetSpeaker))
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments.Select(segment => segment.Speaker == readOnlySourceSpeaker
? segment with { Speaker = readOnlyTargetSpeaker }
: segment).ToList(),
new Dictionary<string, string>(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<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
FinalSegments = request.Segments;
FinalKnownSpeakerMappings = request.KnownSpeakerMappings ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
{
public List<SpeakerIdentificationRequest> Requests { get; } = [];
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class FixedDictationWordStore : IDictationWordStore
{
private readonly IReadOnlyList<string> words;
public FixedDictationWordStore(IReadOnlyList<string> words)
{
this.words = words;
}
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(words);
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return Task.FromResult(words);
}
}
private static RecordingDictationWordProvider CreateRecordingDictationWordProvider(
IDictationWordStore dictationWordStore)
{
return new RecordingDictationWordProvider(
dictationWordStore,
NullLogger<RecordingDictationWordProvider>.Instance);
}
private sealed class ThrowingDictationWordStore : IDictationWordStore
{
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
throw new IOException("dictation words unavailable");
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
throw new IOException("dictation words unavailable");
}
}
private sealed class HangingDictationWordStore : IDictationWordStore
{
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return [];
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
}
private sealed class FixedAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
{
private readonly IReadOnlyList<string> attendees;
public FixedAttendeeCanonicalizer(IReadOnlyList<string> attendees)
{
this.attendees = attendees;
}
public Task<IReadOnlyList<string>> CanonicalizeAsync(
IReadOnlyList<string> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<AudioChunk> chunks = Channel.CreateUnbounded<AudioChunk>();
public IAsyncEnumerable<AudioChunk> 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<ScheduledDelay> 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<ScheduledDelay> 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<MeetingInactivityPromptResponse> responses;
private TaskCompletionSource promptObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public CapturingMeetingInactivityPromptService(params MeetingInactivityPromptResponse[] responses)
{
this.responses = new Queue<MeetingInactivityPromptResponse>(responses);
}
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
public async Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> 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<MeetingInactivityPromptRequest> Requests { get; } = [];
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> 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<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var byteCount = 0;
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
byteCount += chunk.Pcm.Length;
}
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", $"final:{byteCount}");
}
}
private sealed class BlockingBeforeTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly TaskCompletionSource waitingForBackend =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource backendReady =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task WaitUntilWaitingForBackendAsync()
{
return waitingForBackend.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void MarkBackendReady()
{
backendReady.TrySetResult();
}
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> 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;
}
}