Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user