Public Access
Implement meeting assistant v1
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed record AudioChunk(byte[] Pcm, int SampleRate, int Channels)
|
||||
{
|
||||
public TimeSpan Duration
|
||||
{
|
||||
get
|
||||
{
|
||||
var bytesPerSampleFrame = Channels * sizeof(short);
|
||||
if (bytesPerSampleFrame <= 0 || SampleRate <= 0)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds((double)Pcm.Length / bytesPerSampleFrame / SampleRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class CompositeMeetingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly IMeetingAudioSource microphone;
|
||||
private readonly IMeetingAudioSource systemAudio;
|
||||
private readonly ILogger<CompositeMeetingAudioSource> logger;
|
||||
|
||||
public CompositeMeetingAudioSource(
|
||||
IMeetingAudioSource microphone,
|
||||
IMeetingAudioSource systemAudio,
|
||||
ILogger<CompositeMeetingAudioSource> logger)
|
||||
{
|
||||
this.microphone = microphone;
|
||||
this.systemAudio = systemAudio;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SourceChunk>();
|
||||
var microphoneTask = PumpAsync(AudioSourceKind.Microphone, microphone, channel.Writer, cancellationToken);
|
||||
var systemTask = PumpAsync(AudioSourceKind.System, systemAudio, channel.Writer, cancellationToken);
|
||||
_ = Task.WhenAll(microphoneTask, systemTask)
|
||||
.ContinueWith(
|
||||
task =>
|
||||
{
|
||||
if (task.Exception is not null)
|
||||
{
|
||||
channel.Writer.TryComplete(task.Exception);
|
||||
return;
|
||||
}
|
||||
|
||||
channel.Writer.TryComplete();
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
AudioChunk? pendingMicrophone = null;
|
||||
AudioChunk? pendingSystem = null;
|
||||
|
||||
await foreach (var sourceChunk in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
if (sourceChunk.Kind == AudioSourceKind.Microphone)
|
||||
{
|
||||
pendingMicrophone = sourceChunk.Chunk;
|
||||
}
|
||||
else
|
||||
{
|
||||
pendingSystem = sourceChunk.Chunk;
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null && pendingSystem is not null)
|
||||
{
|
||||
yield return Mix(pendingMicrophone, pendingSystem);
|
||||
pendingMicrophone = null;
|
||||
pendingSystem = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
if (await channel.Reader.WaitToReadAsync(linked.Token))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null)
|
||||
{
|
||||
yield return pendingMicrophone;
|
||||
pendingMicrophone = null;
|
||||
}
|
||||
|
||||
if (pendingSystem is not null)
|
||||
{
|
||||
yield return pendingSystem;
|
||||
pendingSystem = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMicrophone is not null)
|
||||
{
|
||||
yield return pendingMicrophone;
|
||||
}
|
||||
|
||||
if (pendingSystem is not null)
|
||||
{
|
||||
yield return pendingSystem;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(
|
||||
AudioSourceKind kind,
|
||||
IMeetingAudioSource source,
|
||||
ChannelWriter<SourceChunk> writer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in source.CaptureAsync(cancellationToken))
|
||||
{
|
||||
await writer.WriteAsync(new SourceChunk(kind, chunk), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private AudioChunk Mix(AudioChunk left, AudioChunk right)
|
||||
{
|
||||
if (left.SampleRate != right.SampleRate || left.Channels != right.Channels)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Cannot mix audio chunks with different formats. Using microphone format {MicrophoneSampleRate}/{MicrophoneChannels} and dropping system chunk format {SystemSampleRate}/{SystemChannels}.",
|
||||
left.SampleRate,
|
||||
left.Channels,
|
||||
right.SampleRate,
|
||||
right.Channels);
|
||||
return left;
|
||||
}
|
||||
|
||||
var mixed = new byte[Math.Max(left.Pcm.Length, right.Pcm.Length)];
|
||||
var sampleCount = mixed.Length / sizeof(short);
|
||||
|
||||
for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++)
|
||||
{
|
||||
var byteIndex = sampleIndex * sizeof(short);
|
||||
var leftSample = ReadSample(left.Pcm, byteIndex);
|
||||
var rightSample = ReadSample(right.Pcm, byteIndex);
|
||||
var sample = Math.Clamp(leftSample + rightSample, short.MinValue, short.MaxValue);
|
||||
BitConverter.GetBytes((short)sample).CopyTo(mixed, byteIndex);
|
||||
}
|
||||
|
||||
return new AudioChunk(mixed, left.SampleRate, left.Channels);
|
||||
}
|
||||
|
||||
private static int ReadSample(byte[] pcm, int byteIndex)
|
||||
{
|
||||
return byteIndex + 1 < pcm.Length ? BitConverter.ToInt16(pcm, byteIndex) : 0;
|
||||
}
|
||||
|
||||
private sealed record SourceChunk(AudioSourceKind Kind, AudioChunk Chunk);
|
||||
|
||||
private enum AudioSourceKind
|
||||
{
|
||||
Microphone,
|
||||
System
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMeetingAudioSource
|
||||
{
|
||||
IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IRecordedAudioStore
|
||||
{
|
||||
Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IRecordedAudioSink : IAsyncDisposable
|
||||
{
|
||||
string AudioPath { get; }
|
||||
|
||||
Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken);
|
||||
|
||||
Task CompleteAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task DeleteAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface ITranscriptStore
|
||||
{
|
||||
Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
|
||||
|
||||
Task ReplaceAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateMetadataAsync(
|
||||
TranscriptSession session,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record TranscriptSession(string TranscriptPath);
|
||||
@@ -0,0 +1,449 @@
|
||||
using System.Threading.Channels;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
private readonly IMeetingAudioSource audioSource;
|
||||
private readonly ISpeechRecognitionPipelineFactory speechRecognitionPipelineFactory;
|
||||
private readonly ITranscriptStore transcriptStore;
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
private readonly IMeetingNoteOpener meetingNoteOpener;
|
||||
private readonly IMeetingArtifactStore meetingArtifactStore;
|
||||
private readonly IMeetingMetadataProvider meetingMetadataProvider;
|
||||
private readonly IRecordedAudioStore recordedAudioStore;
|
||||
private readonly IMeetingSummaryPipeline summaryPipeline;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<MeetingRecordingCoordinator> logger;
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private RecordingRun? currentRun;
|
||||
private TranscriptSession? currentSession;
|
||||
private MeetingNote? currentMeetingNote;
|
||||
private MeetingSessionArtifacts? currentArtifacts;
|
||||
|
||||
public MeetingRecordingCoordinator(
|
||||
IMeetingAudioSource audioSource,
|
||||
ISpeechRecognitionPipelineFactory speechRecognitionPipelineFactory,
|
||||
ITranscriptStore transcriptStore,
|
||||
IMeetingNoteStore meetingNoteStore,
|
||||
IMeetingNoteOpener meetingNoteOpener,
|
||||
IMeetingArtifactStore meetingArtifactStore,
|
||||
IRecordedAudioStore recordedAudioStore,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<MeetingRecordingCoordinator> logger,
|
||||
IMeetingMetadataProvider? meetingMetadataProvider = null)
|
||||
{
|
||||
this.audioSource = audioSource;
|
||||
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
|
||||
this.transcriptStore = transcriptStore;
|
||||
this.meetingNoteStore = meetingNoteStore;
|
||||
this.meetingNoteOpener = meetingNoteOpener;
|
||||
this.meetingArtifactStore = meetingArtifactStore;
|
||||
this.meetingMetadataProvider = meetingMetadataProvider ?? new NoopMeetingMetadataProvider();
|
||||
this.recordedAudioStore = recordedAudioStore;
|
||||
this.summaryPipeline = summaryPipeline;
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public RecordingStatus CurrentStatus => new(
|
||||
IsRecording,
|
||||
currentArtifacts?.TranscriptPath ?? currentSession?.TranscriptPath,
|
||||
currentArtifacts?.MeetingNotePath ?? currentMeetingNote?.Path,
|
||||
currentArtifacts?.AssistantContextPath,
|
||||
currentArtifacts?.SummaryPath);
|
||||
|
||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||
|
||||
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
||||
|
||||
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return IsRecording
|
||||
? await StopAsync(cancellationToken)
|
||||
: await StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (IsRecording)
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
currentSession = await transcriptStore.CreateSessionAsync(cancellationToken);
|
||||
var recordedAudio = await recordedAudioStore.CreateSessionAsync(cancellationToken);
|
||||
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,
|
||||
startTime: startedAt,
|
||||
attendees: attendees,
|
||||
transcriptPath: currentSession.TranscriptPath,
|
||||
assistantContextPath: assistantContextPath,
|
||||
summaryPath: summaryPath),
|
||||
cancellationToken);
|
||||
currentArtifacts = new MeetingSessionArtifacts(
|
||||
currentMeetingNote.Path,
|
||||
currentSession.TranscriptPath,
|
||||
assistantContextPath,
|
||||
summaryPath);
|
||||
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, agenda, scheduledEnd, cancellationToken);
|
||||
await transcriptStore.UpdateMetadataAsync(
|
||||
currentSession,
|
||||
currentArtifacts,
|
||||
currentMeetingNote,
|
||||
cancellationToken);
|
||||
await meetingNoteOpener.OpenAsync(currentMeetingNote.Path, cancellationToken);
|
||||
var captureCancellation = new CancellationTokenSource();
|
||||
var transcriptionCancellation = new CancellationTokenSource();
|
||||
var pipeline = speechRecognitionPipelineFactory.Create();
|
||||
var run = new RecordingRun(captureCancellation, transcriptionCancellation, recordedAudio, pipeline);
|
||||
run.Task = Task.Run(() => RecordAsync(currentSession, run), CancellationToken.None);
|
||||
currentRun = run;
|
||||
logger.LogInformation("Meeting recording started");
|
||||
|
||||
return CurrentStatus;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RecordingRun run;
|
||||
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (currentRun is null || currentRun.IsCaptureStopping)
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
currentRun.StopCapture();
|
||||
run = currentRun;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await run.Task.WaitAsync(options.Recording.StopProcessingTimeout, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
logger.LogWarning("Timed out while draining transcription after recording stop; forcing transcription cancellation");
|
||||
run.CancelTranscription();
|
||||
await run.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await CompleteRunAsync(run);
|
||||
logger.LogInformation("Meeting recording stopped");
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
private async Task RecordAsync(
|
||||
TranscriptSession session,
|
||||
RecordingRun run)
|
||||
{
|
||||
await run.Pipeline.InitializeAsync(run.TranscriptionCancellation);
|
||||
var liveTranscriptTask = Task.Run(
|
||||
() => StoreLiveTranscriptAsync(session, run.Pipeline, run.TranscriptionCancellation),
|
||||
CancellationToken.None);
|
||||
var captureTask = Task.Run(
|
||||
() => CaptureAudioAsync(run.Pipeline, run.RecordedAudio, run.CaptureCancellation),
|
||||
CancellationToken.None);
|
||||
|
||||
try
|
||||
{
|
||||
await captureTask;
|
||||
await run.Pipeline.CompleteAsync(run.TranscriptionCancellation);
|
||||
await liveTranscriptTask;
|
||||
var artifacts = currentArtifacts;
|
||||
if (artifacts is not null && HasConfiguredFinalizer())
|
||||
{
|
||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
||||
artifacts,
|
||||
AssistantContextState.SpeakerRecognition,
|
||||
run.TranscriptionCancellation);
|
||||
}
|
||||
|
||||
await RewriteTranscriptWithFinishedSegmentsAsync(session, run.Pipeline, run.RecordedAudio.AudioPath, run.TranscriptionCancellation);
|
||||
var completedMeetingNote = await CompleteMeetingNoteAsync(run.TranscriptionCancellation);
|
||||
if (artifacts is not null && completedMeetingNote is not null)
|
||||
{
|
||||
await transcriptStore.UpdateMetadataAsync(
|
||||
session,
|
||||
artifacts,
|
||||
completedMeetingNote,
|
||||
run.TranscriptionCancellation);
|
||||
}
|
||||
|
||||
if (artifacts is not null)
|
||||
{
|
||||
await RunSummaryAsync(artifacts, run.TranscriptionCancellation);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception) when (run.TranscriptionCancellation.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(exception, "Meeting recording stopped while the speech recognition pipeline was processing audio");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Meeting recording failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await run.RecordedAudio.DeleteAsync(CancellationToken.None);
|
||||
await run.Pipeline.DisposeAsync();
|
||||
await CompleteRunAsync(run);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CaptureAudioAsync(
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
IRecordedAudioSink recordedAudio,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in audioSource.CaptureAsync(cancellationToken))
|
||||
{
|
||||
await recordedAudio.AppendAsync(chunk, cancellationToken);
|
||||
await pipeline.WriteAsync(chunk, cancellationToken);
|
||||
}
|
||||
|
||||
await recordedAudio.CompleteAsync(cancellationToken);
|
||||
await pipeline.CompleteAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await recordedAudio.CompleteAsync(CancellationToken.None);
|
||||
await pipeline.CompleteAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StoreLiveTranscriptAsync(
|
||||
TranscriptSession session,
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
|
||||
{
|
||||
await transcriptStore.AppendAsync(session, segment, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RewriteTranscriptWithFinishedSegmentsAsync(
|
||||
TranscriptSession session,
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
string audioPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
|
||||
audioPath,
|
||||
await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken),
|
||||
cancellationToken);
|
||||
if (finishedSegments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await transcriptStore.ReplaceAsync(session, finishedSegments, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<MeetingNote?> CompleteMeetingNoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
||||
meetingNote.Frontmatter.EndTime = DateTimeOffset.Now;
|
||||
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
||||
return currentMeetingNote;
|
||||
}
|
||||
|
||||
private async Task RunSummaryAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
||||
artifacts,
|
||||
AssistantContextState.Summarizing,
|
||||
cancellationToken);
|
||||
var result = await summaryPipeline.RunAsync(artifacts, cancellationToken);
|
||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
||||
artifacts,
|
||||
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
||||
artifacts,
|
||||
AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasConfiguredFinalizer()
|
||||
{
|
||||
return options.Recording.TranscriptionProvider switch
|
||||
{
|
||||
"funasr" => options.FunAsr.Diarization.Enabled,
|
||||
"whisper-local" => options.WhisperLocal.Diarization.Enabled,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SpeechRecognitionPipelineOptions> BuildSpeechRecognitionPipelineOptionsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
||||
{
|
||||
return SpeechRecognitionPipelineOptions.Default;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private string GetAssistantContextPath(DateTimeOffset startedAt)
|
||||
{
|
||||
return GetMeetingArtifactPath(options.Vault.AssistantContextFolder, startedAt, "assistant-context");
|
||||
}
|
||||
|
||||
private string GetSummaryPath(DateTimeOffset startedAt)
|
||||
{
|
||||
return GetMeetingArtifactPath(options.Vault.SummariesFolder, startedAt, "summary");
|
||||
}
|
||||
|
||||
private static string GetMeetingArtifactPath(string configuredFolder, DateTimeOffset startedAt, string artifactName)
|
||||
{
|
||||
var folder = VaultPath.Resolve(configuredFolder);
|
||||
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss}-{artifactName}.md");
|
||||
}
|
||||
|
||||
private async Task CompleteRunAsync(RecordingRun run)
|
||||
{
|
||||
if (!run.TryComplete())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (ReferenceEquals(currentRun, run))
|
||||
{
|
||||
currentRun = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
run.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingRun : IDisposable
|
||||
{
|
||||
private int completed;
|
||||
|
||||
public RecordingRun(
|
||||
CancellationTokenSource captureCancellation,
|
||||
CancellationTokenSource transcriptionCancellation,
|
||||
IRecordedAudioSink recordedAudio,
|
||||
ISpeechRecognitionPipeline pipeline)
|
||||
{
|
||||
CaptureCancellationSource = captureCancellation;
|
||||
TranscriptionCancellationSource = transcriptionCancellation;
|
||||
RecordedAudio = recordedAudio;
|
||||
Pipeline = pipeline;
|
||||
}
|
||||
|
||||
public CancellationTokenSource CaptureCancellationSource { get; }
|
||||
|
||||
public CancellationTokenSource TranscriptionCancellationSource { get; }
|
||||
|
||||
public IRecordedAudioSink RecordedAudio { get; }
|
||||
|
||||
public ISpeechRecognitionPipeline Pipeline { get; }
|
||||
|
||||
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
|
||||
|
||||
public CancellationToken TranscriptionCancellation => TranscriptionCancellationSource.Token;
|
||||
|
||||
public Task Task { get; set; } = Task.CompletedTask;
|
||||
|
||||
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
|
||||
|
||||
public void StopCapture()
|
||||
{
|
||||
CaptureCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public void CancelTranscription()
|
||||
{
|
||||
TranscriptionCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public bool TryComplete()
|
||||
{
|
||||
return Interlocked.Exchange(ref completed, 1) == 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CaptureCancellationSource.Dispose();
|
||||
TranscriptionCancellationSource.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordingStatus(
|
||||
bool IsRecording,
|
||||
string? TranscriptPath,
|
||||
string? MeetingNotePath,
|
||||
string? AssistantContextPath,
|
||||
string? SummaryPath);
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
|
||||
public MicrophoneAudioSource(IOptions<MeetingAssistantOptions> options)
|
||||
{
|
||||
this.options = options.Value;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(new WasapiCapture(), cancellationToken);
|
||||
}
|
||||
|
||||
private IAsyncEnumerable<AudioChunk> CaptureAsync(IWaveIn capture, CancellationToken cancellationToken)
|
||||
{
|
||||
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
|
||||
return CaptureWith(capture, cancellationToken);
|
||||
}
|
||||
|
||||
internal static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
IWaveIn capture,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<AudioChunk>();
|
||||
|
||||
capture.DataAvailable += (_, args) =>
|
||||
{
|
||||
var buffer = new byte[args.BytesRecorded];
|
||||
Buffer.BlockCopy(args.Buffer, 0, buffer, 0, args.BytesRecorded);
|
||||
channel.Writer.TryWrite(new AudioChunk(buffer, capture.WaveFormat.SampleRate, capture.WaveFormat.Channels));
|
||||
};
|
||||
capture.RecordingStopped += (_, args) =>
|
||||
{
|
||||
if (args.Exception is not null)
|
||||
{
|
||||
channel.Writer.TryComplete(args.Exception);
|
||||
return;
|
||||
}
|
||||
|
||||
channel.Writer.TryComplete();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var registration = cancellationToken.Register(capture.StopRecording);
|
||||
capture.StartRecording();
|
||||
|
||||
await foreach (var chunk in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
capture.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SystemAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
|
||||
public SystemAudioSource(IOptions<MeetingAssistantOptions> options)
|
||||
{
|
||||
this.options = options.Value;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var capture = new WasapiLoopbackCapture
|
||||
{
|
||||
WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels)
|
||||
};
|
||||
|
||||
return MicrophoneAudioSource.CaptureWith(capture, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<TemporaryRecordedAudioStore> logger;
|
||||
|
||||
public TemporaryRecordedAudioStore(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<TemporaryRecordedAudioStore> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetTemporaryRecordingsFolder();
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav");
|
||||
var format = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
|
||||
logger.LogInformation("Created temporary meeting recording file {RecordingPath}", path);
|
||||
|
||||
return Task.FromResult<IRecordedAudioSink>(new TemporaryRecordedAudioSink(path, format, logger));
|
||||
}
|
||||
|
||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetTemporaryRecordingsFolder();
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(folder, "*.wav", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
DeleteFile(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string GetTemporaryRecordingsFolder()
|
||||
{
|
||||
return VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
|
||||
}
|
||||
|
||||
private void DeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
logger.LogInformation("Deleted temporary meeting recording file {RecordingPath}", path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not delete temporary meeting recording file {RecordingPath}", path);
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not delete temporary meeting recording file {RecordingPath}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TemporaryRecordedAudioSink : IRecordedAudioSink
|
||||
{
|
||||
private readonly WaveFileWriter writer;
|
||||
private readonly ILogger logger;
|
||||
private bool completed;
|
||||
private bool disposed;
|
||||
|
||||
public TemporaryRecordedAudioSink(string audioPath, WaveFormat format, ILogger logger)
|
||||
{
|
||||
AudioPath = audioPath;
|
||||
this.logger = logger;
|
||||
writer = new WaveFileWriter(audioPath, format);
|
||||
}
|
||||
|
||||
public string AudioPath { get; }
|
||||
|
||||
public Task AppendAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
writer.Write(chunk.Pcm, 0, chunk.Pcm.Length);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (completed)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
writer.Flush();
|
||||
completed = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
writer.Dispose();
|
||||
disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await DisposeAsync();
|
||||
try
|
||||
{
|
||||
File.Delete(AudioPath);
|
||||
logger.LogInformation("Deleted temporary meeting recording file {RecordingPath}", AudioPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class TemporaryRecordingCleanupHostedService : IHostedService
|
||||
{
|
||||
private readonly IRecordedAudioStore recordedAudioStore;
|
||||
private readonly ILogger<TemporaryRecordingCleanupHostedService> logger;
|
||||
|
||||
public TemporaryRecordingCleanupHostedService(
|
||||
IRecordedAudioStore recordedAudioStore,
|
||||
ILogger<TemporaryRecordingCleanupHostedService> logger)
|
||||
{
|
||||
this.recordedAudioStore = recordedAudioStore;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("Deleting stale temporary meeting recordings");
|
||||
await recordedAudioStore.DeleteStaleRecordingsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class UnavailableMeetingAudioSource : IMeetingAudioSource
|
||||
{
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new PlatformNotSupportedException(
|
||||
"Meeting audio capture is only implemented for the Windows target in this version.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class VaultTranscriptStore : ITranscriptStore
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<VaultTranscriptStore> logger;
|
||||
|
||||
public VaultTranscriptStore(IOptions<MeetingAssistantOptions> options, ILogger<VaultTranscriptStore> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault.TranscriptsFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-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);
|
||||
|
||||
return new TranscriptSession(path);
|
||||
}
|
||||
|
||||
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendToBodyAsync(session.TranscriptPath, FormatSegment(segment), cancellationToken);
|
||||
}
|
||||
|
||||
public Task ReplaceAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<TranscriptionSegment> replacementSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = File.Exists(session.TranscriptPath)
|
||||
? File.ReadAllText(session.TranscriptPath)
|
||||
: "";
|
||||
var (frontmatter, _) = MeetingArtifactFrontmatterRenderer.Split(existing);
|
||||
var body = "# Meeting Transcript"
|
||||
+ Environment.NewLine
|
||||
+ Environment.NewLine
|
||||
+ string.Concat(replacementSegments.Select(FormatSegment));
|
||||
var content = string.IsNullOrWhiteSpace(frontmatter)
|
||||
? body
|
||||
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + body;
|
||||
return File.WriteAllTextAsync(session.TranscriptPath, content, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateMetadataAsync(
|
||||
TranscriptSession session,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var body = File.Exists(session.TranscriptPath)
|
||||
? MeetingArtifactFrontmatterRenderer.Split(await File.ReadAllTextAsync(session.TranscriptPath, cancellationToken)).Body
|
||||
: "# Meeting Transcript" + Environment.NewLine + Environment.NewLine;
|
||||
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
MeetingArtifactFrontmatterRenderer.DefaultTitle(meetingNote, "Meeting Transcript"),
|
||||
session.TranscriptPath);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
session.TranscriptPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, body),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static string FormatSegment(TranscriptionSegment segment)
|
||||
{
|
||||
var speaker = string.IsNullOrWhiteSpace(segment.Speaker) ? "Unknown" : segment.Speaker;
|
||||
return $"[{segment.Start:hh\\:mm\\:ss}] {speaker}: {segment.Text}{Environment.NewLine}";
|
||||
}
|
||||
|
||||
private static async Task AppendToBodyAsync(
|
||||
string path,
|
||||
string text,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await File.AppendAllTextAsync(path, text, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content);
|
||||
if (string.IsNullOrWhiteSpace(frontmatter))
|
||||
{
|
||||
await File.AppendAllTextAsync(path, text, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = "---"
|
||||
+ Environment.NewLine
|
||||
+ frontmatter
|
||||
+ Environment.NewLine
|
||||
+ "---"
|
||||
+ Environment.NewLine
|
||||
+ Environment.NewLine
|
||||
+ body
|
||||
+ text;
|
||||
await File.WriteAllTextAsync(path, updated, cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user