Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -4,6 +4,13 @@ public interface IRecordedAudioStore
{
Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken);
Task<IRecordedAudioSink> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken);
}
@@ -7,6 +7,13 @@ public interface ITranscriptStore
{
Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken);
Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
Task ReplaceAsync(
@@ -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)
@@ -18,7 +18,14 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
{
var folder = GetTemporaryRecordingsFolder();
return CreateSessionAsync(options, cancellationToken);
}
public Task<IRecordedAudioSink> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = GetTemporaryRecordingsFolder(options);
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav");
@@ -30,7 +37,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
{
var folder = GetTemporaryRecordingsFolder();
var folder = GetTemporaryRecordingsFolder(options);
if (!Directory.Exists(folder))
{
return Task.CompletedTask;
@@ -45,7 +52,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
return Task.CompletedTask;
}
private string GetTemporaryRecordingsFolder()
private static string GetTemporaryRecordingsFolder(MeetingAssistantOptions options)
{
return VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
}
@@ -16,11 +16,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
}
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return await CreateSessionAsync(options, cancellationToken);
}
public async Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
Directory.CreateDirectory(folder);
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-transcript.md";
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-transcript.md";
var path = Path.Combine(folder, fileName);
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);