Add meeting assistant speech and summary automation

This commit is contained in:
2026-05-21 09:55:39 +02:00
parent 1e97d2b9ff
commit 48d98d9cbb
63 changed files with 5143 additions and 151 deletions
@@ -46,6 +46,10 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
await foreach (var sourceChunk in channel.Reader.ReadAllAsync(cancellationToken))
{
logger.LogDebug(
"Captured {ByteCount} byte(s) from {SourceKind}",
sourceChunk.Chunk.Pcm.Length,
sourceChunk.Kind);
if (sourceChunk.Kind == AudioSourceKind.Microphone)
{
pendingMicrophone = sourceChunk.Chunk;
@@ -106,9 +110,21 @@ public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
ChannelWriter<SourceChunk> writer,
CancellationToken cancellationToken)
{
await foreach (var chunk in source.CaptureAsync(cancellationToken))
try
{
await writer.WriteAsync(new SourceChunk(kind, chunk), cancellationToken);
await foreach (var chunk in source.CaptureAsync(cancellationToken))
{
await writer.WriteAsync(new SourceChunk(kind, chunk), cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
writer.TryComplete(new InvalidOperationException($"{kind} audio capture failed.", exception));
throw;
}
}
@@ -1,5 +1,7 @@
using System.Threading.Channels;
using System.Collections.Concurrent;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Options;
@@ -17,6 +19,8 @@ public sealed class MeetingRecordingCoordinator
private readonly IMeetingMetadataProvider meetingMetadataProvider;
private readonly IRecordedAudioStore recordedAudioStore;
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly ISpeakerIdentificationService? speakerIdentificationService;
private readonly IDictationWordStore dictationWordStore;
private readonly MeetingAssistantOptions options;
private readonly ILogger<MeetingRecordingCoordinator> logger;
private readonly SemaphoreSlim gate = new(1, 1);
@@ -36,7 +40,9 @@ public sealed class MeetingRecordingCoordinator
IMeetingSummaryPipeline summaryPipeline,
IOptions<MeetingAssistantOptions> options,
ILogger<MeetingRecordingCoordinator> logger,
IMeetingMetadataProvider? meetingMetadataProvider = null)
IMeetingMetadataProvider? meetingMetadataProvider = null,
ISpeakerIdentificationService? speakerIdentificationService = null,
IDictationWordStore? dictationWordStore = null)
{
this.audioSource = audioSource;
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
@@ -47,6 +53,8 @@ public sealed class MeetingRecordingCoordinator
this.meetingMetadataProvider = meetingMetadataProvider ?? new NoopMeetingMetadataProvider();
this.recordedAudioStore = recordedAudioStore;
this.summaryPipeline = summaryPipeline;
this.dictationWordStore = dictationWordStore ?? new EmptyDictationWordStore();
this.speakerIdentificationService = speakerIdentificationService;
this.options = options.Value;
this.logger = logger;
}
@@ -84,18 +92,11 @@ public sealed class MeetingRecordingCoordinator
var startedAt = DateTimeOffset.Now;
var assistantContextPath = GetAssistantContextPath(startedAt);
var summaryPath = GetSummaryPath(startedAt);
var meetingMetadata = await meetingMetadataProvider.GetCurrentMeetingAsync(startedAt, cancellationToken);
var title = string.IsNullOrWhiteSpace(meetingMetadata?.Title)
? $"Meeting {startedAt:yyyy-MM-dd HH:mm}"
: meetingMetadata.Title;
var attendees = meetingMetadata?.Attendees ?? [];
var agenda = meetingMetadata?.Agenda ?? "";
var scheduledEnd = meetingMetadata?.ScheduledEnd;
currentMeetingNote = await meetingNoteStore.SaveAsync(
MeetingNoteTemplate.Create(
title: title,
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
startTime: startedAt,
attendees: attendees,
attendees: [],
transcriptPath: currentSession.TranscriptPath,
assistantContextPath: assistantContextPath,
summaryPath: summaryPath),
@@ -105,23 +106,42 @@ public sealed class MeetingRecordingCoordinator
currentSession.TranscriptPath,
assistantContextPath,
summaryPath);
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, agenda, scheduledEnd, cancellationToken);
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, "", null, cancellationToken);
_ = Task.Run(
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, currentArtifacts, currentMeetingNote.Path),
CancellationToken.None);
await transcriptStore.UpdateMetadataAsync(
currentSession,
currentArtifacts,
currentMeetingNote,
cancellationToken);
await meetingNoteOpener.OpenAsync(currentMeetingNote.Path, cancellationToken);
await OpenMeetingNoteAsync(currentMeetingNote.Path, cancellationToken);
var captureCancellation = new CancellationTokenSource();
var transcriptionCancellation = new CancellationTokenSource();
var pipeline = speechRecognitionPipelineFactory.Create();
var run = new RecordingRun(captureCancellation, transcriptionCancellation, recordedAudio, pipeline);
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken);
var run = new RecordingRun(
captureCancellation,
transcriptionCancellation,
recordedAudio,
pipeline,
pipelineOptions,
options.SpeakerIdentification.LiveSampleBufferDuration,
options.SpeakerIdentification.MaxSnippetsPerSpeaker);
run.Task = Task.Run(() => RecordAsync(currentSession, run), CancellationToken.None);
currentRun = run;
logger.LogInformation("Meeting recording started");
return CurrentStatus;
}
catch
{
currentRun = null;
currentSession = null;
currentMeetingNote = null;
currentArtifacts = null;
throw;
}
finally
{
gate.Release();
@@ -171,12 +191,15 @@ public sealed class MeetingRecordingCoordinator
TranscriptSession session,
RecordingRun run)
{
await run.Pipeline.InitializeAsync(run.TranscriptionCancellation);
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
var liveTranscriptTask = Task.Run(
() => StoreLiveTranscriptAsync(session, run.Pipeline, run.TranscriptionCancellation),
() => StoreLiveTranscriptAsync(session, run, run.TranscriptionCancellation),
CancellationToken.None);
var captureTask = Task.Run(
() => CaptureAudioAsync(run.Pipeline, run.RecordedAudio, run.CaptureCancellation),
() => CaptureAudioAsync(run),
CancellationToken.None);
var liveIdentificationTask = Task.Run(
() => IdentifyLiveSpeakersAsync(session, run, run.TranscriptionCancellation),
CancellationToken.None);
try
@@ -184,6 +207,9 @@ public sealed class MeetingRecordingCoordinator
await captureTask;
await run.Pipeline.CompleteAsync(run.TranscriptionCancellation);
await liveTranscriptTask;
run.CancelLiveIdentification();
await liveIdentificationTask;
await run.RecordedAudio.DisposeAsync();
var artifacts = currentArtifacts;
if (artifacts is not null && HasConfiguredFinalizer())
{
@@ -193,7 +219,7 @@ public sealed class MeetingRecordingCoordinator
run.TranscriptionCancellation);
}
await RewriteTranscriptWithFinishedSegmentsAsync(session, run.Pipeline, run.RecordedAudio.AudioPath, 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)
{
@@ -222,54 +248,268 @@ public sealed class MeetingRecordingCoordinator
}
finally
{
run.CancelLiveIdentification();
await run.RecordedAudio.DeleteAsync(CancellationToken.None);
await run.Pipeline.DisposeAsync();
await CompleteRunAsync(run);
}
}
private async Task CaptureAudioAsync(
ISpeechRecognitionPipeline pipeline,
IRecordedAudioSink recordedAudio,
CancellationToken cancellationToken)
private async Task CaptureAudioAsync(RecordingRun run)
{
var chunkCount = 0;
long byteCount = 0;
try
{
await foreach (var chunk in audioSource.CaptureAsync(cancellationToken))
await foreach (var chunk in audioSource.CaptureAsync(run.CaptureCancellation))
{
await recordedAudio.AppendAsync(chunk, cancellationToken);
await pipeline.WriteAsync(chunk, cancellationToken);
chunkCount++;
byteCount += chunk.Pcm.Length;
if (chunkCount == 1)
{
logger.LogInformation(
"Captured first mixed audio chunk: {ByteCount} bytes at {SampleRate} Hz/{Channels} channel(s)",
chunk.Pcm.Length,
chunk.SampleRate,
chunk.Channels);
}
run.AppendAudio(chunk);
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
await run.Pipeline.WriteAsync(chunk, run.CaptureCancellation);
}
await recordedAudio.CompleteAsync(cancellationToken);
await pipeline.CompleteAsync(cancellationToken);
logger.LogInformation(
"Audio capture completed after {ChunkCount} chunk(s), {ByteCount} byte(s)",
chunkCount,
byteCount);
await run.RecordedAudio.CompleteAsync(run.CaptureCancellation);
await run.Pipeline.CompleteAsync(run.CaptureCancellation);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
catch (OperationCanceledException) when (run.CaptureCancellation.IsCancellationRequested)
{
await recordedAudio.CompleteAsync(CancellationToken.None);
await pipeline.CompleteAsync(CancellationToken.None);
logger.LogInformation(
"Audio capture stopping after {ChunkCount} chunk(s), {ByteCount} byte(s)",
chunkCount,
byteCount);
await run.RecordedAudio.CompleteAsync(CancellationToken.None);
await run.Pipeline.CompleteAsync(CancellationToken.None);
}
}
private async Task StoreLiveTranscriptAsync(
TranscriptSession session,
ISpeechRecognitionPipeline pipeline,
RecordingRun run,
CancellationToken cancellationToken)
{
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
await foreach (var segment in run.Pipeline.ReadLiveTranscriptAsync(cancellationToken))
{
await transcriptStore.AppendAsync(session, segment, cancellationToken);
run.TryAddSpeakerSample(segment);
var relabeledSegment = run.Relabel(segment);
run.AddLiveSegment(relabeledSegment);
await transcriptStore.AppendAsync(session, relabeledSegment, cancellationToken);
}
}
private async Task IdentifyLiveSpeakersAsync(
TranscriptSession session,
RecordingRun run,
CancellationToken cancellationToken)
{
if (speakerIdentificationService is null || !options.SpeakerIdentification.Enabled)
{
return;
}
var initialDelay = options.SpeakerIdentification.InitialDelay;
var interval = options.SpeakerIdentification.Interval;
try
{
if (initialDelay > TimeSpan.Zero)
{
await Task.Delay(initialDelay, run.LiveIdentificationCancellation);
}
while (!run.LiveIdentificationCancellation.IsCancellationRequested)
{
await IdentifyLiveSpeakersOnceAsync(session, run, run.LiveIdentificationCancellation);
await Task.Delay(interval > TimeSpan.Zero ? interval : TimeSpan.FromMinutes(1), run.LiveIdentificationCancellation);
}
}
catch (OperationCanceledException) when (run.LiveIdentificationCancellation.IsCancellationRequested)
{
}
}
private async Task IdentifyLiveSpeakersOnceAsync(
TranscriptSession session,
RecordingRun run,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
{
return;
}
var segments = run.GetLiveSegmentsSnapshot();
var samples = run.GetSpeakerSamplesSnapshot();
if (segments.Count == 0 || samples.Count == 0)
{
return;
}
try
{
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
var checkpoint = run.CreateLiveIdentificationCheckpoint(meetingNote);
if (checkpoint is null)
{
return;
}
var result = await speakerIdentificationService!.IdentifyKnownSpeakersAsync(
new SpeakerIdentificationRequest("", meetingNote, segments, samples),
cancellationToken);
run.MarkLiveIdentificationAttempted(checkpoint);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
if (run.AddSpeakerMappings(result.SpeakerMappings))
{
await transcriptStore.ReplaceAsync(
session,
run.GetRelabeledLiveSegmentsSnapshot(),
cancellationToken);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogDebug(exception, "Live speaker identity matching skipped for this interval");
}
}
private async Task ApplyMeetingMetadataWhenAvailableAsync(
DateTimeOffset startedAt,
MeetingSessionArtifacts artifacts,
string meetingNotePath)
{
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)
{
currentMeetingNote = meetingNote;
}
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
artifacts,
metadata.Agenda,
metadata.ScheduledEnd,
CancellationToken.None);
logger.LogInformation(
"Applied Outlook meeting metadata to {MeetingNotePath}",
meetingNotePath);
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not apply Outlook meeting metadata to {MeetingNotePath}",
meetingNotePath);
}
finally
{
gate.Release();
}
}
private static void ApplyMeetingMetadata(MeetingNote meetingNote, MeetingMetadata metadata)
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
meetingNote.Frontmatter.Title = metadata.Title;
}
if (metadata.Attendees.Count > 0)
{
meetingNote.Frontmatter.Attendees = metadata.Attendees.ToList();
}
}
private async Task<MeetingMetadata?> GetMeetingMetadataAsync(
DateTimeOffset startedAt,
TimeSpan timeout,
CancellationToken cancellationToken)
{
try
{
var lookup = meetingMetadataProvider.GetCurrentMeetingAsync(startedAt, cancellationToken);
return timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
}
catch (TimeoutException exception)
{
logger.LogWarning(
exception,
"Meeting metadata lookup timed out after {Timeout}; using a default meeting note",
timeout);
return null;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Meeting metadata lookup failed; using a default meeting note");
return null;
}
}
private async Task OpenMeetingNoteAsync(
string meetingNotePath,
CancellationToken cancellationToken)
{
try
{
await meetingNoteOpener.OpenAsync(meetingNotePath, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not open meeting note {MeetingNotePath}; recording will continue",
meetingNotePath);
}
}
private async Task RewriteTranscriptWithFinishedSegmentsAsync(
TranscriptSession session,
ISpeechRecognitionPipeline pipeline,
string audioPath,
RecordingRun run,
CancellationToken cancellationToken)
{
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
audioPath,
run.RecordedAudio.AudioPath,
await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken),
cancellationToken);
if (finishedSegments.Count == 0)
@@ -277,9 +517,98 @@ public sealed class MeetingRecordingCoordinator
return;
}
finishedSegments = finishedSegments
.Select(run.Relabel)
.ToList();
finishedSegments = await IdentifySpeakersAsync(
run.RecordedAudio.AudioPath,
finishedSegments,
run.GetSpeakerSamplesSnapshot(),
cancellationToken);
await transcriptStore.ReplaceAsync(session, finishedSegments, cancellationToken);
}
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> finishedSegments,
IReadOnlyList<SpeakerAudioSample> samples,
CancellationToken cancellationToken)
{
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
{
return finishedSegments;
}
try
{
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
new SpeakerIdentificationRequest(audioPath, meetingNote, finishedSegments, samples),
cancellationToken);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
return result.Segments;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Speaker identity learning failed; keeping ASR speaker labels");
return finishedSegments;
}
}
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
CancellationToken cancellationToken)
{
if (matches is null || matches.Count == 0 || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
{
return;
}
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
var existingNames = meetingNote.Frontmatter.Attendees
.Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var changed = false;
foreach (var match in matches)
{
if (string.IsNullOrWhiteSpace(match.DisplayName))
{
continue;
}
var acceptedNames = match.AcceptedNames
.Append(match.DisplayName)
.Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
if (acceptedNames.Any(existingNames.Contains))
{
continue;
}
var displayName = match.DisplayName.Trim();
meetingNote.Frontmatter.Attendees.Add(displayName);
existingNames.Add(NormalizeAttendeeName(displayName));
changed = true;
}
if (!changed)
{
return;
}
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
logger.LogInformation(
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
currentMeetingNote.Path);
}
private async Task<MeetingNote?> CompleteMeetingNoteAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
@@ -293,6 +622,13 @@ public sealed class MeetingRecordingCoordinator
return currentMeetingNote;
}
private static string NormalizeAttendeeName(string attendee)
{
var trimmed = attendee.Trim();
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
}
private async Task RunSummaryAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
@@ -336,31 +672,36 @@ public sealed class MeetingRecordingCoordinator
private async Task<SpeechRecognitionPipelineOptions> BuildSpeechRecognitionPipelineOptionsAsync(
CancellationToken cancellationToken)
{
var dictationWords = await dictationWordStore.ReadWordsAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
{
return SpeechRecognitionPipelineOptions.Default;
return new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
}
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
var attendeeCount = meetingNote.Frontmatter.Attendees.Count(attendee => !string.IsNullOrWhiteSpace(attendee));
return attendeeCount > 1
? new SpeechRecognitionPipelineOptions(attendeeCount)
: SpeechRecognitionPipelineOptions.Default;
? new SpeechRecognitionPipelineOptions(attendeeCount, dictationWords)
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
}
private string GetAssistantContextPath(DateTimeOffset startedAt)
{
return GetMeetingArtifactPath(options.Vault.AssistantContextFolder, startedAt, "assistant-context");
return GetMeetingArtifactPath(options.Vault, options.Vault.AssistantContextFolder, startedAt, "assistant-context");
}
private string GetSummaryPath(DateTimeOffset startedAt)
{
return GetMeetingArtifactPath(options.Vault.SummariesFolder, startedAt, "summary");
return GetMeetingArtifactPath(options.Vault, options.Vault.SummariesFolder, startedAt, "summary");
}
private static string GetMeetingArtifactPath(string configuredFolder, DateTimeOffset startedAt, string artifactName)
private static string GetMeetingArtifactPath(
VaultOptions vault,
string configuredFolder,
DateTimeOffset startedAt,
string artifactName)
{
var folder = VaultPath.Resolve(configuredFolder);
var folder = VaultPath.Resolve(vault, configuredFolder);
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss}-{artifactName}.md");
}
@@ -389,31 +730,49 @@ public sealed class MeetingRecordingCoordinator
private sealed class RecordingRun : IDisposable
{
private int completed;
private readonly object liveSegmentsGate = new();
private readonly object liveIdentificationGate = new();
private readonly List<TranscriptionSegment> liveSegments = [];
private readonly ConcurrentDictionary<string, string> speakerMappings = new(StringComparer.OrdinalIgnoreCase);
private readonly SpeakerAudioSampleCollector speakerSampleCollector;
private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint;
public RecordingRun(
CancellationTokenSource captureCancellation,
CancellationTokenSource transcriptionCancellation,
IRecordedAudioSink recordedAudio,
ISpeechRecognitionPipeline pipeline)
ISpeechRecognitionPipeline pipeline,
SpeechRecognitionPipelineOptions pipelineOptions,
TimeSpan liveSampleBufferDuration,
int maxSpeakerSamples)
{
CaptureCancellationSource = captureCancellation;
TranscriptionCancellationSource = transcriptionCancellation;
LiveIdentificationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(transcriptionCancellation.Token);
RecordedAudio = recordedAudio;
Pipeline = pipeline;
PipelineOptions = pipelineOptions;
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
}
public CancellationTokenSource CaptureCancellationSource { get; }
public CancellationTokenSource TranscriptionCancellationSource { get; }
public CancellationTokenSource LiveIdentificationCancellationSource { get; }
public IRecordedAudioSink RecordedAudio { get; }
public ISpeechRecognitionPipeline Pipeline { get; }
public SpeechRecognitionPipelineOptions PipelineOptions { get; }
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
public CancellationToken TranscriptionCancellation => TranscriptionCancellationSource.Token;
public CancellationToken LiveIdentificationCancellation => LiveIdentificationCancellationSource.Token;
public Task Task { get; set; } = Task.CompletedTask;
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
@@ -428,6 +787,124 @@ public sealed class MeetingRecordingCoordinator
TranscriptionCancellationSource.Cancel();
}
public void CancelLiveIdentification()
{
LiveIdentificationCancellationSource.Cancel();
}
public void AddLiveSegment(TranscriptionSegment segment)
{
lock (liveSegmentsGate)
{
liveSegments.Add(segment);
}
}
public void AppendAudio(AudioChunk chunk)
{
speakerSampleCollector.AppendAudio(chunk);
}
public void TryAddSpeakerSample(TranscriptionSegment segment)
{
speakerSampleCollector.TryAdd(segment);
}
public IReadOnlyList<TranscriptionSegment> GetLiveSegmentsSnapshot()
{
lock (liveSegmentsGate)
{
return liveSegments.ToList();
}
}
public IReadOnlyList<SpeakerAudioSample> GetSpeakerSamplesSnapshot()
{
return speakerSampleCollector.Snapshot();
}
public LiveIdentificationCheckpoint? CreateLiveIdentificationCheckpoint(MeetingNote meetingNote)
{
var speakers = GetUnmappedSampleSpeakers();
if (speakers.Count == 0)
{
return null;
}
var checkpoint = new LiveIdentificationCheckpoint(
speakers,
BuildAttendeeSignature(meetingNote));
lock (liveIdentificationGate)
{
return lastLiveIdentificationCheckpoint?.Matches(checkpoint) == true
? null
: checkpoint;
}
}
public void MarkLiveIdentificationAttempted(LiveIdentificationCheckpoint checkpoint)
{
lock (liveIdentificationGate)
{
lastLiveIdentificationCheckpoint = checkpoint;
}
}
public bool AddSpeakerMappings(IReadOnlyDictionary<string, string> mappings)
{
var added = false;
foreach (var (diarizedSpeaker, canonicalSpeaker) in mappings)
{
if (speakerMappings.TryGetValue(diarizedSpeaker, out var existingSpeaker) &&
string.Equals(existingSpeaker, canonicalSpeaker, StringComparison.OrdinalIgnoreCase))
{
continue;
}
speakerMappings[diarizedSpeaker] = canonicalSpeaker;
added = true;
}
return added;
}
public TranscriptionSegment Relabel(TranscriptionSegment segment)
{
return speakerMappings.TryGetValue(segment.Speaker, out var speaker)
? segment with { Speaker = speaker }
: segment;
}
public IReadOnlyList<TranscriptionSegment> GetRelabeledLiveSegmentsSnapshot()
{
lock (liveSegmentsGate)
{
return liveSegments.Select(Relabel).ToList();
}
}
private IReadOnlyList<string> GetUnmappedSampleSpeakers()
{
return GetSpeakerSamplesSnapshot()
.Select(sample => sample.Speaker)
.Where(speaker => !string.IsNullOrWhiteSpace(speaker))
.Where(speaker => !string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase))
.Where(speaker => !speakerMappings.ContainsKey(speaker))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string BuildAttendeeSignature(MeetingNote meetingNote)
{
return string.Join(
"\n",
meetingNote.Frontmatter.Attendees
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Select(attendee => attendee.Trim())
.Order(StringComparer.OrdinalIgnoreCase));
}
public bool TryComplete()
{
return Interlocked.Exchange(ref completed, 1) == 0;
@@ -437,6 +914,32 @@ public sealed class MeetingRecordingCoordinator
{
CaptureCancellationSource.Dispose();
TranscriptionCancellationSource.Dispose();
LiveIdentificationCancellationSource.Dispose();
}
public sealed record LiveIdentificationCheckpoint(
IReadOnlyList<string> Speakers,
string AttendeeSignature)
{
public bool Matches(LiveIdentificationCheckpoint other)
{
return string.Equals(AttendeeSignature, other.AttendeeSignature, StringComparison.Ordinal) &&
Speakers.Count == other.Speakers.Count &&
Speakers.SequenceEqual(other.Speakers, StringComparer.OrdinalIgnoreCase);
}
}
}
private sealed class EmptyDictationWordStore : IDictationWordStore
{
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<string>>([]);
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return ReadWordsAsync(cancellationToken);
}
}
}
@@ -8,10 +8,14 @@ namespace MeetingAssistant.Recording;
public sealed class MicrophoneAudioSource : IMeetingAudioSource
{
private readonly MeetingAssistantOptions options;
private readonly ILogger<MicrophoneAudioSource> logger;
public MicrophoneAudioSource(IOptions<MeetingAssistantOptions> options)
public MicrophoneAudioSource(
IOptions<MeetingAssistantOptions> options,
ILogger<MicrophoneAudioSource> logger)
{
this.options = options.Value;
this.logger = logger;
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
@@ -22,19 +26,37 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
private IAsyncEnumerable<AudioChunk> CaptureAsync(IWaveIn capture, CancellationToken cancellationToken)
{
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
return CaptureWith(capture, cancellationToken);
return CaptureWith(capture, "microphone", logger, cancellationToken);
}
internal static async IAsyncEnumerable<AudioChunk> CaptureWith(
IWaveIn capture,
string sourceName,
ILogger logger,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<AudioChunk>();
var chunks = 0;
capture.DataAvailable += (_, args) =>
{
if (args.BytesRecorded <= 0)
{
return;
}
var buffer = new byte[args.BytesRecorded];
Buffer.BlockCopy(args.Buffer, 0, buffer, 0, args.BytesRecorded);
if (Interlocked.Increment(ref chunks) == 1)
{
logger.LogInformation(
"Audio capture source {SourceName} produced first chunk: {ByteCount} bytes at {SampleRate} Hz/{Channels} channel(s)",
sourceName,
args.BytesRecorded,
capture.WaveFormat.SampleRate,
capture.WaveFormat.Channels);
}
channel.Writer.TryWrite(new AudioChunk(buffer, capture.WaveFormat.SampleRate, capture.WaveFormat.Channels));
};
capture.RecordingStopped += (_, args) =>
@@ -51,6 +73,11 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
try
{
using var registration = cancellationToken.Register(capture.StopRecording);
logger.LogInformation(
"Starting audio capture source {SourceName} at {SampleRate} Hz/{Channels} channel(s)",
sourceName,
capture.WaveFormat.SampleRate,
capture.WaveFormat.Channels);
capture.StartRecording();
await foreach (var chunk in channel.Reader.ReadAllAsync(cancellationToken))
@@ -68,10 +95,14 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
public sealed class SystemAudioSource : IMeetingAudioSource
{
private readonly MeetingAssistantOptions options;
private readonly ILogger<SystemAudioSource> logger;
public SystemAudioSource(IOptions<MeetingAssistantOptions> options)
public SystemAudioSource(
IOptions<MeetingAssistantOptions> options,
ILogger<SystemAudioSource> logger)
{
this.options = options.Value;
this.logger = logger;
}
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
@@ -81,6 +112,6 @@ public sealed class SystemAudioSource : IMeetingAudioSource
WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels)
};
return MicrophoneAudioSource.CaptureWith(capture, cancellationToken);
return MicrophoneAudioSource.CaptureWith(capture, "system", logger, cancellationToken);
}
}
@@ -0,0 +1,109 @@
using NAudio.Wave;
namespace MeetingAssistant.Recording;
internal sealed class RollingAudioBuffer
{
private readonly TimeSpan retention;
private readonly object gate = new();
private readonly Queue<TimedAudioChunk> chunks = new();
private TimeSpan position;
public RollingAudioBuffer(TimeSpan retention)
{
this.retention = retention > TimeSpan.Zero ? retention : TimeSpan.FromMinutes(10);
}
public void Append(AudioChunk chunk)
{
if (chunk.Pcm.Length == 0 || chunk.SampleRate <= 0 || chunk.Channels <= 0)
{
return;
}
lock (gate)
{
var start = position;
var end = start + chunk.Duration;
chunks.Enqueue(new TimedAudioChunk(start, end, chunk));
position = end;
Prune(end - retention);
}
}
public byte[] TryExtractWav(TimeSpan start, TimeSpan end)
{
if (end <= start)
{
return [];
}
List<TimedAudioChunk> matchingChunks;
lock (gate)
{
matchingChunks = chunks
.Where(chunk => chunk.End > start && chunk.Start < end)
.ToList();
}
if (matchingChunks.Count == 0)
{
return [];
}
var format = new WaveFormat(
matchingChunks[0].Chunk.SampleRate,
16,
matchingChunks[0].Chunk.Channels);
using var output = new MemoryStream();
using (var writer = new WaveFileWriter(output, format))
{
foreach (var timedChunk in matchingChunks)
{
if (timedChunk.Chunk.SampleRate != format.SampleRate || timedChunk.Chunk.Channels != format.Channels)
{
continue;
}
var blockAlign = format.BlockAlign;
var copyStart = Max(start, timedChunk.Start);
var copyEnd = Min(end, timedChunk.End);
var firstFrame = (int)Math.Floor((copyStart - timedChunk.Start).TotalSeconds * format.SampleRate);
var afterLastFrame = (int)Math.Ceiling((copyEnd - timedChunk.Start).TotalSeconds * format.SampleRate);
var byteOffset = Clamp(firstFrame * blockAlign, 0, timedChunk.Chunk.Pcm.Length);
var byteCount = Clamp(afterLastFrame * blockAlign, byteOffset, timedChunk.Chunk.Pcm.Length) - byteOffset;
if (byteCount > 0)
{
writer.Write(timedChunk.Chunk.Pcm, byteOffset, byteCount);
}
}
}
return output.ToArray();
}
private void Prune(TimeSpan cutoff)
{
while (chunks.Count > 0 && chunks.Peek().End < cutoff)
{
chunks.Dequeue();
}
}
private static TimeSpan Max(TimeSpan left, TimeSpan right)
{
return left >= right ? left : right;
}
private static TimeSpan Min(TimeSpan left, TimeSpan right)
{
return left <= right ? left : right;
}
private static int Clamp(int value, int min, int max)
{
return Math.Min(Math.Max(value, min), max);
}
private sealed record TimedAudioChunk(TimeSpan Start, TimeSpan End, AudioChunk Chunk);
}
@@ -0,0 +1,105 @@
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Recording;
internal sealed class SpeakerAudioSampleCollector
{
private readonly object gate = new();
private readonly RollingAudioBuffer audioBuffer;
private readonly Dictionary<string, List<SpeakerAudioSample>> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase);
private readonly int maxSamplesPerSpeaker;
public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker)
{
audioBuffer = new RollingAudioBuffer(bufferDuration);
this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker);
}
public void AppendAudio(AudioChunk chunk)
{
audioBuffer.Append(chunk);
}
public void TryAdd(TranscriptionSegment segment)
{
if (!IsDiarizedSpeaker(segment.Speaker))
{
return;
}
var score = Score(segment);
if (score <= 0)
{
return;
}
var wavBytes = audioBuffer.TryExtractWav(segment.Start, segment.End);
if (wavBytes.Length == 0)
{
return;
}
var sample = new SpeakerAudioSample(segment.Speaker, segment, wavBytes, score);
lock (gate)
{
if (!samplesBySpeaker.TryGetValue(segment.Speaker, out var samples))
{
samples = [];
samplesBySpeaker[segment.Speaker] = samples;
}
samples.Add(sample);
var bestSamples = samples
.OrderByDescending(candidate => candidate.Score)
.Take(maxSamplesPerSpeaker)
.ToList();
samples.Clear();
samples.AddRange(bestSamples);
}
}
public IReadOnlyList<SpeakerAudioSample> Snapshot()
{
lock (gate)
{
return samplesBySpeaker.Values
.SelectMany(samples => samples)
.OrderBy(sample => sample.Speaker, StringComparer.OrdinalIgnoreCase)
.ThenByDescending(sample => sample.Score)
.ToList();
}
}
private static bool IsDiarizedSpeaker(string speaker)
{
return !string.IsNullOrWhiteSpace(speaker) &&
!string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase);
}
private static double Score(TranscriptionSegment segment)
{
var durationSeconds = (segment.End - segment.Start).TotalSeconds;
if (durationSeconds < 2 || durationSeconds > 30)
{
return 0;
}
var words = segment.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length;
if (words < 3 || words > 80)
{
return 0;
}
var durationScore = 1 - Math.Min(Math.Abs(durationSeconds - 8) / 8, 1);
var wordScore = 1 - Math.Min(Math.Abs(words - 18) / 18.0, 1);
var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') ||
segment.Text.TrimEnd().EndsWith('?') ||
segment.Text.TrimEnd().EndsWith('!')
? 5
: 0;
return durationScore * 70 + wordScore * 30 + sentenceBonus;
}
}
@@ -17,7 +17,7 @@ public sealed class VaultTranscriptStore : ITranscriptStore
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault.TranscriptsFolder);
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
Directory.CreateDirectory(folder);
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-transcript.md";