using Microsoft.Extensions.Options; using NAudio.Wave; namespace MeetingAssistant.Recording; public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore { private readonly MeetingAssistantOptions options; private readonly ILogger logger; private readonly IOfflineTranscriptionBacklog offlineTranscriptionBacklog; public TemporaryRecordedAudioStore( IOptions options, ILogger logger, IOfflineTranscriptionBacklog? offlineTranscriptionBacklog = null) { this.options = options.Value; this.logger = logger; this.offlineTranscriptionBacklog = offlineTranscriptionBacklog ?? NoopOfflineTranscriptionBacklog.Instance; } public Task CreateSessionAsync(CancellationToken cancellationToken) { return CreateSessionAsync(options, cancellationToken); } public Task CreateSessionAsync( MeetingAssistantOptions options, CancellationToken cancellationToken) { var folder = GetTemporaryRecordingsFolder(options); Directory.CreateDirectory(folder); var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav"); var format = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels); logger.LogInformation("Created temporary meeting recording file {RecordingPath}", path); return Task.FromResult(new TemporaryRecordedAudioSink(path, format, logger)); } public async Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken) { var folder = GetTemporaryRecordingsFolder(options); if (!Directory.Exists(folder)) { return; } var queuedAudioPaths = (await offlineTranscriptionBacklog.ListAsync(cancellationToken)) .Select(item => item.AudioPath) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var path in Directory.EnumerateFiles(folder, "*.wav", SearchOption.TopDirectoryOnly)) { cancellationToken.ThrowIfCancellationRequested(); if (queuedAudioPaths.Contains(path)) { logger.LogInformation("Keeping queued offline transcription recording file {RecordingPath}", path); continue; } DeleteFile(path); } } private static string GetTemporaryRecordingsFolder(MeetingAssistantOptions options) { 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) { } } } }