Public Access
3335 lines
139 KiB
C#
3335 lines
139 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(5);
|
|
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;
|
|
}
|
|
}
|
|
|
|
[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 StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
|
|
{
|
|
var audioSource = new ControlledAudioSource();
|
|
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
|
|
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
|
|
var noteOpener = new CapturingMeetingNoteOpener();
|
|
var artifactStore = new InMemoryMeetingArtifactStore();
|
|
var audioArchive = new InMemoryRecordedAudioStore();
|
|
var coordinator = new MeetingRecordingCoordinator(
|
|
audioSource,
|
|
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
|
transcriptStore,
|
|
noteStore,
|
|
noteOpener,
|
|
artifactStore,
|
|
audioArchive,
|
|
new CapturingMeetingSummaryPipeline(),
|
|
Options.Create(new MeetingAssistantOptions
|
|
{
|
|
Vault = new VaultOptions
|
|
{
|
|
AssistantContextFolder = "C:\\Vault\\Meetings\\Assistant Context",
|
|
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
|
|
}
|
|
}),
|
|
NullLogger<MeetingRecordingCoordinator>.Instance);
|
|
|
|
var status = await coordinator.StartAsync(CancellationToken.None);
|
|
|
|
Assert.True(status.IsRecording);
|
|
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
|
|
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
|
|
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
|
|
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
|
|
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
|
|
Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
|
|
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
|
|
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
|
|
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
|
|
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
|
|
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
|
|
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 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 + "" + 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 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);
|
|
|
|
var started = await coordinator.StartAsync("english", CancellationToken.None);
|
|
await coordinator.StopAsync(CancellationToken.None);
|
|
|
|
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
|
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase);
|
|
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase);
|
|
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase);
|
|
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,
|
|
dictationWordStore: new FixedDictationWordStore(["PBI", "Product Backlog Item"]));
|
|
|
|
await coordinator.StartAsync(CancellationToken.None);
|
|
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
|
await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Equal(["PBI", "Product Backlog Item"], provider.Options?.DictationWords);
|
|
|
|
await coordinator.StopAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
|
|
{
|
|
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
|
|
var transcriptStore = new InMemoryTranscriptStore();
|
|
var finalizer = new CapturingTranscriptFinalizer(
|
|
[
|
|
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Speaker 0", "hello"),
|
|
new TranscriptionSegment(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), "Speaker 1", "there")
|
|
]);
|
|
var audioArchive = new InMemoryRecordedAudioStore("memory-recording.wav");
|
|
var coordinator = new MeetingRecordingCoordinator(
|
|
audioSource,
|
|
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
|
|
transcriptStore,
|
|
new InMemoryMeetingNoteStore(),
|
|
new CapturingMeetingNoteOpener(),
|
|
new InMemoryMeetingArtifactStore(),
|
|
audioArchive,
|
|
new CapturingMeetingSummaryPipeline(),
|
|
Options.Create(new MeetingAssistantOptions()),
|
|
NullLogger<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 VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
|
|
{
|
|
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
|
var store = new VaultTranscriptStore(
|
|
Options.Create(new MeetingAssistantOptions
|
|
{
|
|
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
|
|
}),
|
|
NullLogger<VaultTranscriptStore>.Instance);
|
|
|
|
var session = await store.CreateSessionAsync(CancellationToken.None);
|
|
await store.AppendAsync(
|
|
session,
|
|
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello vault"),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(Directory.Exists(vaultFolder));
|
|
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
|
|
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
|
|
|
|
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 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+E",
|
|
["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 AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
|
{
|
|
segments.Add(segment);
|
|
segmentWritten.TrySetResult();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task WaitForTextAsync(string text)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == 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 ReplaceAsync(
|
|
TranscriptSession session,
|
|
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ReplacedSegments = replacementSegments;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task UpdateMetadataAsync(
|
|
TranscriptSession session,
|
|
MeetingSessionArtifacts artifacts,
|
|
MeetingNote meetingNote,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
MetadataMeetingNote = meetingNote;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class 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 AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
|
{
|
|
AppendHistory.Add(new TranscriptWrite(session, segment));
|
|
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 ReplaceAsync(
|
|
TranscriptSession session,
|
|
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ReplacementHistory.Add(new TranscriptReplacement(session, replacementSegments));
|
|
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;
|
|
}
|
|
}
|
|
|
|
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,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
|
|
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md")));
|
|
}
|
|
|
|
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task ReplaceAsync(
|
|
TranscriptSession session,
|
|
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
|
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, "meeting.md") : 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 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 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 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;
|
|
}
|
|
}
|
|
|
|
|
|
|