|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
using System.Threading.Channels;
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using MeetingAssistant.LaunchProfiles;
|
|
|
|
|
using MeetingAssistant.MeetingNotes;
|
|
|
|
|
using MeetingAssistant.Speakers;
|
|
|
|
|
using MeetingAssistant.Summary;
|
|
|
|
@@ -20,7 +21,9 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
private readonly IRecordedAudioStore recordedAudioStore;
|
|
|
|
|
private readonly IMeetingSummaryPipeline summaryPipeline;
|
|
|
|
|
private readonly ISpeakerIdentificationService? speakerIdentificationService;
|
|
|
|
|
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
|
|
|
|
|
private readonly IDictationWordStore dictationWordStore;
|
|
|
|
|
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
|
|
|
|
private readonly MeetingAssistantOptions options;
|
|
|
|
|
private readonly ILogger<MeetingRecordingCoordinator> logger;
|
|
|
|
|
private readonly SemaphoreSlim gate = new(1, 1);
|
|
|
|
@@ -42,7 +45,9 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
ILogger<MeetingRecordingCoordinator> logger,
|
|
|
|
|
IMeetingMetadataProvider? meetingMetadataProvider = null,
|
|
|
|
|
ISpeakerIdentificationService? speakerIdentificationService = null,
|
|
|
|
|
IDictationWordStore? dictationWordStore = null)
|
|
|
|
|
IDictationWordStore? dictationWordStore = null,
|
|
|
|
|
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
|
|
|
|
|
ILaunchProfileOptionsProvider? launchProfiles = null)
|
|
|
|
|
{
|
|
|
|
|
this.audioSource = audioSource;
|
|
|
|
|
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
|
|
|
|
@@ -55,6 +60,8 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
this.summaryPipeline = summaryPipeline;
|
|
|
|
|
this.dictationWordStore = dictationWordStore ?? new EmptyDictationWordStore();
|
|
|
|
|
this.speakerIdentificationService = speakerIdentificationService;
|
|
|
|
|
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
|
|
|
|
|
this.launchProfiles = launchProfiles;
|
|
|
|
|
this.options = options.Value;
|
|
|
|
|
this.logger = logger;
|
|
|
|
|
}
|
|
|
|
@@ -71,13 +78,27 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
|
|
|
|
|
|
|
|
|
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return await ToggleAsync(null, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<RecordingStatus> ToggleAsync(
|
|
|
|
|
string? launchProfileName,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return IsRecording
|
|
|
|
|
? await StopAsync(cancellationToken)
|
|
|
|
|
: await StartAsync(cancellationToken);
|
|
|
|
|
: await StartAsync(launchProfileName, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return await StartAsync(null, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<RecordingStatus> StartAsync(
|
|
|
|
|
string? launchProfileName,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
await gate.WaitAsync(cancellationToken);
|
|
|
|
|
try
|
|
|
|
@@ -87,11 +108,12 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
return CurrentStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentSession = await transcriptStore.CreateSessionAsync(cancellationToken);
|
|
|
|
|
var recordedAudio = await recordedAudioStore.CreateSessionAsync(cancellationToken);
|
|
|
|
|
var runOptions = ResolveOptions(launchProfileName);
|
|
|
|
|
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
|
|
|
|
|
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
|
|
|
|
|
var startedAt = DateTimeOffset.Now;
|
|
|
|
|
var assistantContextPath = GetAssistantContextPath(startedAt);
|
|
|
|
|
var summaryPath = GetSummaryPath(startedAt);
|
|
|
|
|
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
|
|
|
|
|
var summaryPath = GetSummaryPath(startedAt, runOptions);
|
|
|
|
|
currentMeetingNote = await meetingNoteStore.SaveAsync(
|
|
|
|
|
MeetingNoteTemplate.Create(
|
|
|
|
|
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
|
|
|
|
@@ -100,16 +122,14 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
transcriptPath: currentSession.TranscriptPath,
|
|
|
|
|
assistantContextPath: assistantContextPath,
|
|
|
|
|
summaryPath: summaryPath),
|
|
|
|
|
runOptions,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
currentArtifacts = new MeetingSessionArtifacts(
|
|
|
|
|
currentMeetingNote.Path,
|
|
|
|
|
currentSession.TranscriptPath,
|
|
|
|
|
assistantContextPath,
|
|
|
|
|
summaryPath);
|
|
|
|
|
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, "", null, cancellationToken);
|
|
|
|
|
_ = Task.Run(
|
|
|
|
|
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, currentArtifacts, currentMeetingNote.Path),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
|
|
|
|
|
await transcriptStore.UpdateMetadataAsync(
|
|
|
|
|
currentSession,
|
|
|
|
|
currentArtifacts,
|
|
|
|
@@ -118,18 +138,28 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
await OpenMeetingNoteAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var captureCancellation = new CancellationTokenSource();
|
|
|
|
|
var transcriptionCancellation = new CancellationTokenSource();
|
|
|
|
|
var pipeline = speechRecognitionPipelineFactory.Create();
|
|
|
|
|
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken);
|
|
|
|
|
var pipeline = speechRecognitionPipelineFactory.Create(launchProfileName);
|
|
|
|
|
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(
|
|
|
|
|
runOptions,
|
|
|
|
|
currentMeetingNote.Path,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
var run = new RecordingRun(
|
|
|
|
|
captureCancellation,
|
|
|
|
|
transcriptionCancellation,
|
|
|
|
|
currentSession,
|
|
|
|
|
currentMeetingNote.Path,
|
|
|
|
|
currentArtifacts,
|
|
|
|
|
recordedAudio,
|
|
|
|
|
pipeline,
|
|
|
|
|
pipelineOptions,
|
|
|
|
|
options.SpeakerIdentification.LiveSampleBufferDuration,
|
|
|
|
|
options.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
|
|
|
|
run.Task = Task.Run(() => RecordAsync(currentSession, run), CancellationToken.None);
|
|
|
|
|
runOptions,
|
|
|
|
|
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
|
|
|
|
|
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
|
|
|
|
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
|
|
|
|
currentRun = run;
|
|
|
|
|
_ = Task.Run(
|
|
|
|
|
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
logger.LogInformation("Meeting recording started");
|
|
|
|
|
|
|
|
|
|
return CurrentStatus;
|
|
|
|
@@ -170,7 +200,7 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await run.Task.WaitAsync(options.Recording.StopProcessingTimeout, cancellationToken);
|
|
|
|
|
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
@@ -187,19 +217,17 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
return CurrentStatus;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task RecordAsync(
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
RecordingRun run)
|
|
|
|
|
private async Task RecordAsync(RecordingRun run)
|
|
|
|
|
{
|
|
|
|
|
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
|
|
|
|
var liveTranscriptTask = Task.Run(
|
|
|
|
|
() => StoreLiveTranscriptAsync(session, run, run.TranscriptionCancellation),
|
|
|
|
|
() => StoreLiveTranscriptAsync(run, run.TranscriptionCancellation),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
var captureTask = Task.Run(
|
|
|
|
|
() => CaptureAudioAsync(run),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
var liveIdentificationTask = Task.Run(
|
|
|
|
|
() => IdentifyLiveSpeakersAsync(session, run, run.TranscriptionCancellation),
|
|
|
|
|
() => IdentifyLiveSpeakersAsync(run, run.TranscriptionCancellation),
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
@@ -210,30 +238,34 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
run.CancelLiveIdentification();
|
|
|
|
|
await liveIdentificationTask;
|
|
|
|
|
await run.RecordedAudio.DisposeAsync();
|
|
|
|
|
var artifacts = currentArtifacts;
|
|
|
|
|
if (artifacts is not null && HasConfiguredFinalizer())
|
|
|
|
|
var artifacts = run.Artifacts;
|
|
|
|
|
if (HasConfiguredFinalizer(run.Options))
|
|
|
|
|
{
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
await TransitionMeetingAsync(
|
|
|
|
|
run,
|
|
|
|
|
AssistantContextState.SpeakerRecognition,
|
|
|
|
|
run.TranscriptionCancellation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await RewriteTranscriptWithFinishedSegmentsAsync(session, run.Pipeline, run, run.TranscriptionCancellation);
|
|
|
|
|
var completedMeetingNote = await CompleteMeetingNoteAsync(run.TranscriptionCancellation);
|
|
|
|
|
if (artifacts is not null && completedMeetingNote is not null)
|
|
|
|
|
await RewriteTranscriptWithFinishedSegmentsAsync(run, run.TranscriptionCancellation);
|
|
|
|
|
var completedMeetingNote = await CompleteMeetingNoteAsync(
|
|
|
|
|
run.MeetingNotePath,
|
|
|
|
|
run.Options,
|
|
|
|
|
run.TranscriptionCancellation);
|
|
|
|
|
if (completedMeetingNote is not null)
|
|
|
|
|
{
|
|
|
|
|
await transcriptStore.UpdateMetadataAsync(
|
|
|
|
|
session,
|
|
|
|
|
run.Session,
|
|
|
|
|
artifacts,
|
|
|
|
|
completedMeetingNote,
|
|
|
|
|
run.TranscriptionCancellation);
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
completedMeetingNote,
|
|
|
|
|
run.TranscriptionCancellation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (artifacts is not null)
|
|
|
|
|
{
|
|
|
|
|
await RunSummaryAsync(artifacts, run.TranscriptionCancellation);
|
|
|
|
|
}
|
|
|
|
|
await RunSummaryAsync(run, run.TranscriptionCancellation);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
@@ -298,7 +330,6 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task StoreLiveTranscriptAsync(
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
@@ -307,22 +338,21 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
run.TryAddSpeakerSample(segment);
|
|
|
|
|
var relabeledSegment = run.Relabel(segment);
|
|
|
|
|
run.AddLiveSegment(relabeledSegment);
|
|
|
|
|
await transcriptStore.AppendAsync(session, relabeledSegment, cancellationToken);
|
|
|
|
|
await transcriptStore.AppendAsync(run.Session, relabeledSegment, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task IdentifyLiveSpeakersAsync(
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (speakerIdentificationService is null || !options.SpeakerIdentification.Enabled)
|
|
|
|
|
if (speakerIdentificationService is null || !run.Options.SpeakerIdentification.Enabled)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var initialDelay = options.SpeakerIdentification.InitialDelay;
|
|
|
|
|
var interval = options.SpeakerIdentification.Interval;
|
|
|
|
|
var initialDelay = run.Options.SpeakerIdentification.InitialDelay;
|
|
|
|
|
var interval = run.Options.SpeakerIdentification.Interval;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (initialDelay > TimeSpan.Zero)
|
|
|
|
@@ -332,7 +362,7 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
while (!run.LiveIdentificationCancellation.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
await IdentifyLiveSpeakersOnceAsync(session, run, run.LiveIdentificationCancellation);
|
|
|
|
|
await IdentifyLiveSpeakersOnceAsync(run, run.LiveIdentificationCancellation);
|
|
|
|
|
await Task.Delay(interval > TimeSpan.Zero ? interval : TimeSpan.FromMinutes(1), run.LiveIdentificationCancellation);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -342,11 +372,10 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task IdentifyLiveSpeakersOnceAsync(
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
|
|
|
|
if (string.IsNullOrWhiteSpace(run.MeetingNotePath))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
@@ -360,7 +389,7 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken);
|
|
|
|
|
var checkpoint = run.CreateLiveIdentificationCheckpoint(meetingNote);
|
|
|
|
|
if (checkpoint is null)
|
|
|
|
|
{
|
|
|
|
@@ -368,14 +397,23 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result = await speakerIdentificationService!.IdentifyKnownSpeakersAsync(
|
|
|
|
|
new SpeakerIdentificationRequest("", meetingNote, segments, samples),
|
|
|
|
|
new SpeakerIdentificationRequest(
|
|
|
|
|
"",
|
|
|
|
|
meetingNote,
|
|
|
|
|
segments,
|
|
|
|
|
samples,
|
|
|
|
|
run.GetSpeakerMappingsSnapshot()),
|
|
|
|
|
cancellationToken);
|
|
|
|
|
run.MarkLiveIdentificationAttempted(checkpoint);
|
|
|
|
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
|
|
|
|
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
|
|
|
|
result.AttendeeMatches,
|
|
|
|
|
run.MeetingNotePath,
|
|
|
|
|
run.Options,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
if (run.AddSpeakerMappings(result.SpeakerMappings))
|
|
|
|
|
{
|
|
|
|
|
await transcriptStore.ReplaceAsync(
|
|
|
|
|
session,
|
|
|
|
|
run.Session,
|
|
|
|
|
run.GetRelabeledLiveSegmentsSnapshot(),
|
|
|
|
|
cancellationToken);
|
|
|
|
|
}
|
|
|
|
@@ -392,52 +430,75 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
private async Task ApplyMeetingMetadataWhenAvailableAsync(
|
|
|
|
|
DateTimeOffset startedAt,
|
|
|
|
|
MeetingSessionArtifacts artifacts,
|
|
|
|
|
string meetingNotePath)
|
|
|
|
|
RecordingRun run)
|
|
|
|
|
{
|
|
|
|
|
var metadata = await GetMeetingMetadataAsync(
|
|
|
|
|
startedAt,
|
|
|
|
|
options.Recording.BackgroundMetadataLookupTimeout,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
if (metadata is null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await gate.WaitAsync(CancellationToken.None);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, CancellationToken.None);
|
|
|
|
|
ApplyMeetingMetadata(meetingNote, metadata);
|
|
|
|
|
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, CancellationToken.None);
|
|
|
|
|
if (currentMeetingNote?.Path == meetingNote.Path)
|
|
|
|
|
var metadata = await GetMeetingMetadataAsync(
|
|
|
|
|
startedAt,
|
|
|
|
|
run.Options.Recording.BackgroundMetadataLookupTimeout,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
if (metadata is null)
|
|
|
|
|
{
|
|
|
|
|
currentMeetingNote = meetingNote;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
metadata.Agenda,
|
|
|
|
|
metadata.ScheduledEnd,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
logger.LogInformation(
|
|
|
|
|
"Applied Outlook meeting metadata to {MeetingNotePath}",
|
|
|
|
|
meetingNotePath);
|
|
|
|
|
await gate.WaitAsync(CancellationToken.None);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
|
|
|
|
|
await ApplyMeetingMetadataAsync(meetingNote, metadata, CancellationToken.None);
|
|
|
|
|
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
|
|
|
|
|
if (currentMeetingNote?.Path == meetingNote.Path)
|
|
|
|
|
{
|
|
|
|
|
currentMeetingNote = meetingNote;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
|
|
|
|
run.Artifacts,
|
|
|
|
|
meetingNote,
|
|
|
|
|
metadata.Agenda,
|
|
|
|
|
metadata.ScheduledEnd,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
logger.LogInformation(
|
|
|
|
|
"Applied Outlook meeting metadata to {MeetingNotePath}",
|
|
|
|
|
run.MeetingNotePath);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
gate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
{
|
|
|
|
|
logger.LogWarning(
|
|
|
|
|
exception,
|
|
|
|
|
"Could not apply Outlook meeting metadata to {MeetingNotePath}",
|
|
|
|
|
meetingNotePath);
|
|
|
|
|
run.MeetingNotePath);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
gate.Release();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await TransitionMeetingAsync(
|
|
|
|
|
run,
|
|
|
|
|
AssistantContextState.Transcribing,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
{
|
|
|
|
|
logger.LogWarning(
|
|
|
|
|
exception,
|
|
|
|
|
"Could not update assistant context state to transcribing for {MeetingNotePath}",
|
|
|
|
|
run.MeetingNotePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void ApplyMeetingMetadata(MeetingNote meetingNote, MeetingMetadata metadata)
|
|
|
|
|
private async Task ApplyMeetingMetadataAsync(
|
|
|
|
|
MeetingNote meetingNote,
|
|
|
|
|
MeetingMetadata metadata,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
|
|
|
|
{
|
|
|
|
@@ -446,7 +507,30 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
if (metadata.Attendees.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
meetingNote.Frontmatter.Attendees = metadata.Attendees.ToList();
|
|
|
|
|
meetingNote.Frontmatter.Attendees = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<List<string>> CanonicalizeAttendeesAsync(
|
|
|
|
|
IReadOnlyList<string> attendees,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return (await attendeeCanonicalizer.CanonicalizeAsync(attendees, cancellationToken)).ToList();
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
{
|
|
|
|
|
logger.LogWarning(exception, "Could not canonicalize meeting attendees; writing raw attendee names");
|
|
|
|
|
return attendees
|
|
|
|
|
.Select(NormalizeAttendeeName)
|
|
|
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
|
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToList();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -503,14 +587,12 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task RewriteTranscriptWithFinishedSegmentsAsync(
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
ISpeechRecognitionPipeline pipeline,
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
|
|
|
|
|
var finishedSegments = await run.Pipeline.ReadFinishedTranscriptAsync(
|
|
|
|
|
run.RecordedAudio.AudioPath,
|
|
|
|
|
await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken),
|
|
|
|
|
await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken),
|
|
|
|
|
cancellationToken);
|
|
|
|
|
if (finishedSegments.Count == 0)
|
|
|
|
|
{
|
|
|
|
@@ -524,28 +606,43 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
run.RecordedAudio.AudioPath,
|
|
|
|
|
finishedSegments,
|
|
|
|
|
run.GetSpeakerSamplesSnapshot(),
|
|
|
|
|
run.GetSpeakerMappingsSnapshot(),
|
|
|
|
|
run.MeetingNotePath,
|
|
|
|
|
run.Options,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
await transcriptStore.ReplaceAsync(session, finishedSegments, cancellationToken);
|
|
|
|
|
await transcriptStore.ReplaceAsync(run.Session, finishedSegments, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync(
|
|
|
|
|
string audioPath,
|
|
|
|
|
IReadOnlyList<TranscriptionSegment> finishedSegments,
|
|
|
|
|
IReadOnlyList<SpeakerAudioSample> samples,
|
|
|
|
|
IReadOnlyDictionary<string, string> knownSpeakerMappings,
|
|
|
|
|
string meetingNotePath,
|
|
|
|
|
MeetingAssistantOptions runOptions,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
|
|
|
|
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath))
|
|
|
|
|
{
|
|
|
|
|
return finishedSegments;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
|
|
|
|
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
|
|
|
|
new SpeakerIdentificationRequest(audioPath, meetingNote, finishedSegments, samples),
|
|
|
|
|
new SpeakerIdentificationRequest(
|
|
|
|
|
audioPath,
|
|
|
|
|
meetingNote,
|
|
|
|
|
finishedSegments,
|
|
|
|
|
samples,
|
|
|
|
|
knownSpeakerMappings),
|
|
|
|
|
cancellationToken);
|
|
|
|
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
|
|
|
|
result.AttendeeMatches,
|
|
|
|
|
meetingNotePath,
|
|
|
|
|
runOptions,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
|
|
|
|
|
return result.Segments;
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
|
|
@@ -561,14 +658,16 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
|
|
|
|
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
|
|
|
|
|
string meetingNotePath,
|
|
|
|
|
MeetingAssistantOptions runOptions,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (matches is null || matches.Count == 0 || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
|
|
|
|
if (matches is null || matches.Count == 0 || string.IsNullOrWhiteSpace(meetingNotePath))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
|
|
|
|
var existingNames = meetingNote.Frontmatter.Attendees
|
|
|
|
|
.Select(NormalizeAttendeeName)
|
|
|
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
|
|
@@ -603,45 +702,56 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
|
|
|
|
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
|
|
|
|
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
currentMeetingNote = savedMeetingNote;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.LogInformation(
|
|
|
|
|
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
|
|
|
|
|
currentMeetingNote.Path);
|
|
|
|
|
savedMeetingNote.Path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<MeetingNote?> CompleteMeetingNoteAsync(CancellationToken cancellationToken)
|
|
|
|
|
private async Task<MeetingNote?> CompleteMeetingNoteAsync(
|
|
|
|
|
string meetingNotePath,
|
|
|
|
|
MeetingAssistantOptions runOptions,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
|
|
|
|
if (string.IsNullOrWhiteSpace(meetingNotePath))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
|
|
|
|
meetingNote.Frontmatter.EndTime = DateTimeOffset.Now;
|
|
|
|
|
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
|
|
|
|
return currentMeetingNote;
|
|
|
|
|
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
|
|
|
|
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
currentMeetingNote = savedMeetingNote;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return savedMeetingNote;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string NormalizeAttendeeName(string attendee)
|
|
|
|
|
{
|
|
|
|
|
var trimmed = attendee.Trim();
|
|
|
|
|
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
|
|
|
|
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
|
|
|
|
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task RunSummaryAsync(
|
|
|
|
|
MeetingSessionArtifacts artifacts,
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
await TransitionMeetingAsync(
|
|
|
|
|
run,
|
|
|
|
|
AssistantContextState.Summarizing,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
var result = await summaryPipeline.RunAsync(artifacts, cancellationToken);
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken);
|
|
|
|
|
await TransitionMeetingAsync(
|
|
|
|
|
run,
|
|
|
|
|
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
@@ -652,47 +762,82 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
catch (Exception exception)
|
|
|
|
|
{
|
|
|
|
|
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
|
|
|
|
artifacts,
|
|
|
|
|
await TransitionMeetingAsync(
|
|
|
|
|
run,
|
|
|
|
|
AssistantContextState.Error,
|
|
|
|
|
CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool HasConfiguredFinalizer()
|
|
|
|
|
private async Task TransitionMeetingAsync(
|
|
|
|
|
RecordingRun run,
|
|
|
|
|
AssistantContextState state,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
return options.Recording.TranscriptionProvider switch
|
|
|
|
|
if (!run.TryTransitionTo(state))
|
|
|
|
|
{
|
|
|
|
|
"funasr" => options.FunAsr.Diarization.Enabled,
|
|
|
|
|
"whisper-local" => options.WhisperLocal.Diarization.Enabled,
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
|
|
|
|
run.Artifacts,
|
|
|
|
|
state,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool HasConfiguredFinalizer(MeetingAssistantOptions runOptions)
|
|
|
|
|
{
|
|
|
|
|
return runOptions.Recording.TranscriptionProvider switch
|
|
|
|
|
{
|
|
|
|
|
"funasr" => runOptions.FunAsr.Diarization.Enabled,
|
|
|
|
|
"whisper-local" => runOptions.WhisperLocal.Diarization.Enabled,
|
|
|
|
|
_ => false
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<SpeechRecognitionPipelineOptions> BuildSpeechRecognitionPipelineOptionsAsync(
|
|
|
|
|
MeetingAssistantOptions runOptions,
|
|
|
|
|
string? meetingNotePath,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var dictationWords = await dictationWordStore.ReadWordsAsync(cancellationToken);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
|
|
|
|
var dictationWords = await dictationWordStore.ReadWordsAsync(runOptions, cancellationToken);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(meetingNotePath))
|
|
|
|
|
{
|
|
|
|
|
return new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
|
|
|
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
|
|
|
|
var attendeeCount = meetingNote.Frontmatter.Attendees.Count(attendee => !string.IsNullOrWhiteSpace(attendee));
|
|
|
|
|
return attendeeCount > 1
|
|
|
|
|
? new SpeechRecognitionPipelineOptions(attendeeCount, dictationWords)
|
|
|
|
|
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string GetAssistantContextPath(DateTimeOffset startedAt)
|
|
|
|
|
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
|
|
|
|
{
|
|
|
|
|
return GetMeetingArtifactPath(options.Vault, options.Vault.AssistantContextFolder, startedAt, "assistant-context");
|
|
|
|
|
if (launchProfiles is not null)
|
|
|
|
|
{
|
|
|
|
|
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
|
|
|
|
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Launch profile '{launchProfileName}' was requested, but no launch profile provider is registered.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string GetSummaryPath(DateTimeOffset startedAt)
|
|
|
|
|
private string GetAssistantContextPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
|
|
|
|
|
{
|
|
|
|
|
return GetMeetingArtifactPath(options.Vault, options.Vault.SummariesFolder, startedAt, "summary");
|
|
|
|
|
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, "assistant-context");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string GetSummaryPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
|
|
|
|
|
{
|
|
|
|
|
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, "summary");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string GetMeetingArtifactPath(
|
|
|
|
@@ -702,7 +847,7 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
string artifactName)
|
|
|
|
|
{
|
|
|
|
|
var folder = VaultPath.Resolve(vault, configuredFolder);
|
|
|
|
|
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss}-{artifactName}.md");
|
|
|
|
|
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss-fffffff}-{artifactName}.md");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task CompleteRunAsync(RecordingRun run)
|
|
|
|
@@ -718,6 +863,9 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
if (ReferenceEquals(currentRun, run))
|
|
|
|
|
{
|
|
|
|
|
currentRun = null;
|
|
|
|
|
currentSession = null;
|
|
|
|
|
currentMeetingNote = null;
|
|
|
|
|
currentArtifacts = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
@@ -730,6 +878,7 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
private sealed class RecordingRun : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private int completed;
|
|
|
|
|
private readonly object stateGate = new();
|
|
|
|
|
private readonly object liveSegmentsGate = new();
|
|
|
|
|
private readonly object liveIdentificationGate = new();
|
|
|
|
|
private readonly List<TranscriptionSegment> liveSegments = [];
|
|
|
|
@@ -740,18 +889,26 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
public RecordingRun(
|
|
|
|
|
CancellationTokenSource captureCancellation,
|
|
|
|
|
CancellationTokenSource transcriptionCancellation,
|
|
|
|
|
TranscriptSession session,
|
|
|
|
|
string meetingNotePath,
|
|
|
|
|
MeetingSessionArtifacts artifacts,
|
|
|
|
|
IRecordedAudioSink recordedAudio,
|
|
|
|
|
ISpeechRecognitionPipeline pipeline,
|
|
|
|
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
|
|
|
|
MeetingAssistantOptions options,
|
|
|
|
|
TimeSpan liveSampleBufferDuration,
|
|
|
|
|
int maxSpeakerSamples)
|
|
|
|
|
{
|
|
|
|
|
CaptureCancellationSource = captureCancellation;
|
|
|
|
|
TranscriptionCancellationSource = transcriptionCancellation;
|
|
|
|
|
LiveIdentificationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(transcriptionCancellation.Token);
|
|
|
|
|
Session = session;
|
|
|
|
|
MeetingNotePath = meetingNotePath;
|
|
|
|
|
Artifacts = artifacts;
|
|
|
|
|
RecordedAudio = recordedAudio;
|
|
|
|
|
Pipeline = pipeline;
|
|
|
|
|
PipelineOptions = pipelineOptions;
|
|
|
|
|
Options = options;
|
|
|
|
|
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -763,10 +920,18 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
public IRecordedAudioSink RecordedAudio { get; }
|
|
|
|
|
|
|
|
|
|
public TranscriptSession Session { get; }
|
|
|
|
|
|
|
|
|
|
public string MeetingNotePath { get; }
|
|
|
|
|
|
|
|
|
|
public MeetingSessionArtifacts Artifacts { get; }
|
|
|
|
|
|
|
|
|
|
public ISpeechRecognitionPipeline Pipeline { get; }
|
|
|
|
|
|
|
|
|
|
public SpeechRecognitionPipelineOptions PipelineOptions { get; }
|
|
|
|
|
|
|
|
|
|
public MeetingAssistantOptions Options { get; }
|
|
|
|
|
|
|
|
|
|
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
|
|
|
|
|
|
|
|
|
|
public CancellationToken TranscriptionCancellation => TranscriptionCancellationSource.Token;
|
|
|
|
@@ -777,6 +942,8 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
|
|
|
|
|
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
|
|
|
|
|
|
|
|
|
|
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
|
|
|
|
|
|
|
|
|
public void StopCapture()
|
|
|
|
|
{
|
|
|
|
|
CaptureCancellationSource.Cancel();
|
|
|
|
@@ -792,6 +959,20 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
LiveIdentificationCancellationSource.Cancel();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool TryTransitionTo(AssistantContextState state)
|
|
|
|
|
{
|
|
|
|
|
lock (stateGate)
|
|
|
|
|
{
|
|
|
|
|
if (StateRank(state) <= StateRank(ContextState))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ContextState = state;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void AddLiveSegment(TranscriptionSegment segment)
|
|
|
|
|
{
|
|
|
|
|
lock (liveSegmentsGate)
|
|
|
|
@@ -883,6 +1064,14 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public IReadOnlyDictionary<string, string> GetSpeakerMappingsSnapshot()
|
|
|
|
|
{
|
|
|
|
|
return speakerMappings.ToDictionary(
|
|
|
|
|
pair => pair.Key,
|
|
|
|
|
pair => pair.Value,
|
|
|
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private IReadOnlyList<string> GetUnmappedSampleSpeakers()
|
|
|
|
|
{
|
|
|
|
|
return GetSpeakerSamplesSnapshot()
|
|
|
|
@@ -917,6 +1106,20 @@ public sealed class MeetingRecordingCoordinator
|
|
|
|
|
LiveIdentificationCancellationSource.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int StateRank(AssistantContextState state)
|
|
|
|
|
{
|
|
|
|
|
return state switch
|
|
|
|
|
{
|
|
|
|
|
AssistantContextState.CollectingMetadata => 0,
|
|
|
|
|
AssistantContextState.Transcribing => 1,
|
|
|
|
|
AssistantContextState.SpeakerRecognition => 2,
|
|
|
|
|
AssistantContextState.Summarizing => 3,
|
|
|
|
|
AssistantContextState.Finished => 4,
|
|
|
|
|
AssistantContextState.Error => 5,
|
|
|
|
|
_ => 5
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed record LiveIdentificationCheckpoint(
|
|
|
|
|
IReadOnlyList<string> Speakers,
|
|
|
|
|
string AttendeeSignature)
|
|
|
|
|