Implement meeting assistant v1

This commit is contained in:
2026-05-20 02:06:16 +02:00
parent 90df1edc03
commit 0297bcc0f6
120 changed files with 11883 additions and 180 deletions
+18
View File
@@ -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);
}
}