Public Access
Implement meeting assistant v1
This commit is contained in:
@@ -0,0 +1,930 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new EchoStreamingTranscriptionProvider();
|
||||
var store = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
store,
|
||||
noteStore,
|
||||
noteOpener,
|
||||
artifactStore,
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<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);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
new FixedMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Architecture Sync",
|
||||
["Ada <ada@example.com>", "Grace"],
|
||||
"Review API shape",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"))));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Review API shape", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0 }, 16000, 1));
|
||||
var provider = new FinalSegmentOnAudioCompletionProvider();
|
||||
var store = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var noteOpener = new CapturingMeetingNoteOpener();
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var audioArchive = new InMemoryRecordedAudioStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
store,
|
||||
noteStore,
|
||||
noteOpener,
|
||||
artifactStore,
|
||||
audioArchive,
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<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 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 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);
|
||||
});
|
||||
}
|
||||
|
||||
[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 coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
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);
|
||||
}
|
||||
|
||||
[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.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(succeeded: false),
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksSpeakerRecognitionBeforeSummaryWhenFinalizerIsConfigured()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions { TranscriptionProvider = "whisper-local" },
|
||||
WhisperLocal = new WhisperLocalOptions
|
||||
{
|
||||
Diarization = new PyannoteDiarizationOptions { Enabled = true }
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
|
||||
{
|
||||
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var store = new VaultTranscriptStore(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
|
||||
}),
|
||||
NullLogger<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));
|
||||
}
|
||||
|
||||
[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 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 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)))
|
||||
{
|
||||
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 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 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();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
public string? OpenedPath { get; private set; }
|
||||
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
{
|
||||
OpenedPath = notePath;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
|
||||
public List<AssistantContextState> States { get; } = [];
|
||||
|
||||
public string? Agenda { get; private set; }
|
||||
|
||||
public DateTimeOffset? ScheduledEnd { get; private set; }
|
||||
|
||||
public Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CreatedArtifacts = artifacts;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
States.Add(state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
private readonly MeetingMetadata? metadata;
|
||||
|
||||
public FixedMeetingMetadataProvider(MeetingMetadata? metadata)
|
||||
{
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly bool succeeded;
|
||||
|
||||
public CapturingMeetingSummaryPipeline(bool succeeded = true)
|
||||
{
|
||||
this.succeeded = succeeded;
|
||||
}
|
||||
|
||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Artifacts = artifacts;
|
||||
return Task.FromResult(new MeetingSummaryRunResult(
|
||||
artifacts.SummaryPath,
|
||||
succeeded ? "summary ok" : "summary failed",
|
||||
succeeded,
|
||||
succeeded ? null : "error"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRecordedAudioStore : IRecordedAudioStore
|
||||
{
|
||||
private readonly string audioPath;
|
||||
|
||||
public InMemoryRecordedAudioStore(string audioPath = "memory-recording.wav")
|
||||
{
|
||||
this.audioPath = audioPath;
|
||||
}
|
||||
|
||||
public List<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 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
[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,
|
||||
[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,
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user