Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s

This commit is contained in:
2026-05-27 23:11:21 +02:00
parent c12febe029
commit 7777b349a5
37 changed files with 3190 additions and 121 deletions
@@ -582,6 +582,139 @@ public sealed class RecordingCoordinatorTests
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()
{
@@ -1263,6 +1396,118 @@ public sealed class RecordingCoordinatorTests
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()
{
@@ -1398,25 +1643,39 @@ public sealed class RecordingCoordinatorTests
};
}
private static AudioChunk CreateThreeSecondChunk()
{
return new AudioChunk(new byte[16000 * 2 * 3], 16000, 1);
}
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
string defaultRoot,
string englishRoot)
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";
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(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"
})
.AddInMemoryCollection(values)
.Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration);
}
@@ -1737,6 +1996,13 @@ public sealed class RecordingCoordinatorTests
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; } = [];
@@ -1758,6 +2024,21 @@ public sealed class RecordingCoordinatorTests
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;
}
@@ -1855,6 +2136,19 @@ public sealed class RecordingCoordinatorTests
}
}
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(
@@ -1953,6 +2247,102 @@ public sealed class RecordingCoordinatorTests
}
}
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 =
@@ -2224,6 +2614,129 @@ public sealed class RecordingCoordinatorTests
}
}
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;
@@ -2348,6 +2861,143 @@ public sealed class RecordingCoordinatorTests
}
}
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; } = [];