Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
@@ -10,6 +11,22 @@ 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.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
|
||||
{
|
||||
@@ -116,6 +133,7 @@ public sealed class RecordingCoordinatorTests
|
||||
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);
|
||||
@@ -125,6 +143,101 @@ public sealed class RecordingCoordinatorTests
|
||||
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 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()
|
||||
{
|
||||
@@ -187,6 +300,33 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartPassesDictationWordsToSpeechPipeline()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var provider = new CapturingPipelineOptionsProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<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()
|
||||
{
|
||||
@@ -236,6 +376,272 @@ public sealed class RecordingCoordinatorTests
|
||||
});
|
||||
}
|
||||
|
||||
[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 LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var provider = new OrderedChunkProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(provider),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(10)
|
||||
}
|
||||
}),
|
||||
NullLogger<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)));
|
||||
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
|
||||
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
await Task.Delay(50);
|
||||
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
|
||||
|
||||
await transcriptStore.WaitForTextAsync("second");
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Collection(
|
||||
transcriptStore.Segments,
|
||||
first => Assert.Equal("Guest03", first.Speaker),
|
||||
second => Assert.Equal("Chris", second.Speaker));
|
||||
Assert.Contains(
|
||||
transcriptStore.ReplacedSegments,
|
||||
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveSpeakerIdentityMatchAddsIdentifiedSpeakerToMeetingAttendees()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
InitialDelay = TimeSpan.Zero,
|
||||
Interval = TimeSpan.FromMilliseconds(10)
|
||||
}
|
||||
}),
|
||||
NullLogger<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)
|
||||
}
|
||||
}),
|
||||
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)
|
||||
}
|
||||
}),
|
||||
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)]
|
||||
@@ -538,6 +944,8 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> Segments => segments;
|
||||
|
||||
public MeetingNote? MetadataMeetingNote { get; private set; }
|
||||
|
||||
public Task ReplaceAsync(
|
||||
@@ -591,6 +999,11 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
SavedNote.Frontmatter.Attendees = attendees.ToList();
|
||||
}
|
||||
|
||||
public void UpdateSavedNote(MeetingNote note)
|
||||
{
|
||||
SavedNote = note with { Path = notePath };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||
@@ -634,6 +1047,17 @@ public sealed class RecordingCoordinatorTests
|
||||
States.Add(state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
@@ -653,6 +1077,48 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -817,6 +1283,181 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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>();
|
||||
@@ -870,6 +1511,7 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
@@ -884,6 +1526,7 @@ public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var byteCount = 0;
|
||||
@@ -915,6 +1558,7 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
waitingForBackend.TrySetResult();
|
||||
@@ -925,6 +1569,14 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user