Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s

This commit is contained in:
2026-06-26 13:15:47 +02:00
parent 7d16b0cad7
commit aecef30627
72 changed files with 2914 additions and 406 deletions
@@ -66,32 +66,22 @@ public sealed class FileOfflineTranscriptionBacklog : IOfflineTranscriptionBackl
return items;
}
public async Task CompleteAsync(string id, CancellationToken cancellationToken)
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
var folder = GetBacklogFolder(options);
var path = GetItemPath(folder, id);
var path = GetItemPath(folder, item.Id);
if (!File.Exists(path))
{
return;
}
OfflineTranscriptionBacklogItem? item = null;
try
{
item = JsonSerializer.Deserialize<OfflineTranscriptionBacklogItem>(
await File.ReadAllTextAsync(path, cancellationToken),
JsonOptions);
}
catch (JsonException exception)
{
logger.LogWarning(exception, "Could not read completed offline transcription backlog item {BacklogItemPath}", path);
return Task.CompletedTask;
}
File.Delete(path);
if (item is not null && File.Exists(item.AudioPath))
if (File.Exists(item.AudioPath))
{
DeleteCompletedAudio(item.AudioPath);
}
return Task.CompletedTask;
}
private static string GetBacklogFolder(MeetingAssistantOptions options)
@@ -0,0 +1,12 @@
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public interface IMicrophoneDeviceProvider
{
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
IWaveIn CreateCapture(MeetingAssistantOptions options);
}
@@ -28,6 +28,7 @@ public sealed class MeetingRecordingCoordinator
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly IMeetingScreenshotService screenshotService;
private readonly IMeetingNoteImageOcrService meetingNoteImageOcrService;
private readonly IMeetingRunArtifactCleaner artifactCleaner;
private readonly IMeetingInactivityPromptService inactivityPromptService;
private readonly IMeetingInactivityClock inactivityClock;
@@ -58,6 +59,7 @@ public sealed class MeetingRecordingCoordinator
ILaunchProfileOptionsProvider? launchProfiles = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
IMeetingScreenshotService? screenshotService = null,
IMeetingNoteImageOcrService? meetingNoteImageOcrService = null,
IMeetingRunArtifactCleaner? artifactCleaner = null,
IMeetingInactivityPromptService? inactivityPromptService = null,
IMeetingInactivityClock? inactivityClock = null,
@@ -78,6 +80,7 @@ public sealed class MeetingRecordingCoordinator
this.launchProfiles = launchProfiles;
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
this.screenshotService = screenshotService ?? NoopMeetingScreenshotService.Instance;
this.meetingNoteImageOcrService = meetingNoteImageOcrService ?? NoopMeetingNoteImageOcrService.Instance;
this.artifactCleaner = artifactCleaner ?? new MeetingRunArtifactCleaner();
this.inactivityPromptService = inactivityPromptService ?? new NoopMeetingInactivityPromptService();
this.inactivityClock = inactivityClock ?? new SystemMeetingInactivityClock();
@@ -142,12 +145,28 @@ public sealed class MeetingRecordingCoordinator
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
{
return await StartAsync(null, cancellationToken);
return await StartAsync(null, null, suppressMetadataLookup: false, cancellationToken);
}
public async Task<RecordingStatus> StartFromPromptAsync(
MeetingMetadata? metadata,
CancellationToken cancellationToken)
{
return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken);
}
public async Task<RecordingStatus> StartAsync(
string? launchProfileName,
CancellationToken cancellationToken)
{
return await StartAsync(launchProfileName, null, suppressMetadataLookup: false, cancellationToken);
}
private async Task<RecordingStatus> StartAsync(
string? launchProfileName,
MeetingMetadata? suppliedMetadata,
bool suppressMetadataLookup,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
@@ -164,14 +183,16 @@ public sealed class MeetingRecordingCoordinator
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
var summaryPath = GetSummaryPath(startedAt, runOptions);
var meetingNote = MeetingNoteTemplate.Create(
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
startTime: startedAt,
attendees: [],
transcriptPath: currentSession.TranscriptPath,
assistantContextPath: assistantContextPath,
summaryPath: summaryPath);
currentMeetingNote = await meetingNoteStore.SaveAsync(
MeetingNoteTemplate.Create(
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
startTime: startedAt,
attendees: [],
transcriptPath: currentSession.TranscriptPath,
assistantContextPath: assistantContextPath,
summaryPath: summaryPath),
meetingNote,
runOptions,
cancellationToken);
currentArtifacts = new MeetingSessionArtifacts(
@@ -179,12 +200,32 @@ public sealed class MeetingRecordingCoordinator
currentSession.TranscriptPath,
assistantContextPath,
summaryPath);
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
await meetingArtifactStore.CreateAssistantContextAsync(
currentArtifacts,
currentMeetingNote,
"",
null,
cancellationToken);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.Created(currentArtifacts),
runOptions,
cancellationToken);
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
if (suppliedMetadata is not null)
{
await ApplyMeetingMetadataAsync(currentMeetingNote, suppliedMetadata, runOptions, cancellationToken);
currentMeetingNote = await meetingNoteStore.SaveAsync(
currentMeetingNote,
runOptions,
cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
currentArtifacts,
currentMeetingNote,
suppliedMetadata.Agenda,
suppliedMetadata.ScheduledEnd,
cancellationToken);
}
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
currentArtifacts,
currentMeetingNote,
@@ -226,9 +267,20 @@ public sealed class MeetingRecordingCoordinator
}
currentRun = run;
_ = Task.Run(
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
CancellationToken.None);
if (suppliedMetadata is null && !suppressMetadataLookup)
{
_ = Task.Run(
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
CancellationToken.None);
}
else
{
await TransitionMeetingAsync(
run,
AssistantContextState.Transcribing,
cancellationToken);
}
logger.LogInformation("Meeting recording started");
return CurrentStatus;
@@ -445,7 +497,7 @@ public sealed class MeetingRecordingCoordinator
TimeSpan.Zero,
TimeSpan.Zero,
"System",
$"Transcription profile changed to {targetProfile.Name}.");
$"Transcription profile changed to {targetProfile.Name}. Speaker recognition and identities reset.");
run.AddLiveSegment(marker);
await AppendTranscriptSegmentAsync(run, marker, cancellationToken);
@@ -642,14 +694,23 @@ public sealed class MeetingRecordingCoordinator
CancellationToken cancellationToken)
{
var formatted = TranscriptLineFormatter.Format(segment);
var lineReference = segment.ReplacesMarkerId is { } replacesMarkerId &&
run.TryTakeTranscriptMarker(replacesMarkerId, out var markerReference)
? await ReplaceTranscriptMarkerAsync(run, markerReference, formatted, cancellationToken)
: segment.Kind == TranscriptionSegmentKind.Marker &&
segment.MarkerId is { } existingMarkerId &&
run.TryTakeTranscriptMarker(existingMarkerId, out var existingMarkerReference)
? await ReplaceTranscriptMarkerAsync(run, existingMarkerReference, formatted, cancellationToken)
: await transcriptStore.AppendLineAsync(run.Session, formatted.Line, cancellationToken);
TranscriptLineReference lineReference;
if (segment.ReplacesMarkerId is { } replacesMarkerId &&
run.TryTakeTranscriptMarker(replacesMarkerId, out var markerReference))
{
lineReference = await ReplaceTranscriptMarkerAsync(run, markerReference, formatted, cancellationToken);
}
else if (segment.Kind == TranscriptionSegmentKind.Marker &&
segment.MarkerId is { } existingMarkerId &&
run.TryTakeTranscriptMarker(existingMarkerId, out var existingMarkerReference))
{
lineReference = await ReplaceTranscriptMarkerAsync(run, existingMarkerReference, formatted, cancellationToken);
}
else
{
lineReference = await transcriptStore.AppendLineAsync(run.Session, formatted.Line, cancellationToken);
}
if (segment.Kind == TranscriptionSegmentKind.Marker && segment.MarkerId is { } markerId)
{
run.SetTranscriptMarker(markerId, lineReference);
@@ -1505,6 +1566,10 @@ public sealed class MeetingRecordingCoordinator
try
{
await meetingNoteImageOcrService.ProcessMeetingNoteImageEmbedsAsync(
run.Artifacts,
run.Options,
cancellationToken);
await screenshotService.WaitForPendingOcrAsync(
run.Artifacts,
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
@@ -0,0 +1,9 @@
namespace MeetingAssistant.Recording;
public sealed record MicrophoneDevice(
string Id,
string Name);
public sealed record MicrophoneDeviceSnapshot(
IReadOnlyList<MicrophoneDevice> Available,
MicrophoneDevice? Current);
@@ -0,0 +1,54 @@
namespace MeetingAssistant.Recording;
public sealed class MicrophoneDeviceSelection
{
private readonly object sync = new();
private string? selectedDeviceId;
public string? SelectedDeviceId
{
get
{
lock (sync)
{
return selectedDeviceId;
}
}
}
public void Select(string deviceId)
{
lock (sync)
{
selectedDeviceId = string.IsNullOrWhiteSpace(deviceId)
? null
: deviceId.Trim();
}
}
public MicrophoneDevice? Resolve(
string? configuredDeviceId,
MicrophoneDevice? defaultDevice,
IReadOnlyList<MicrophoneDevice> availableDevices)
{
var selected = SelectedDeviceId;
return FindById(availableDevices, selected) ??
FindById(availableDevices, configuredDeviceId) ??
defaultDevice;
}
private static MicrophoneDevice? FindById(
IReadOnlyList<MicrophoneDevice> devices,
string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
return devices.FirstOrDefault(device => string.Equals(
device.Id,
id.Trim(),
StringComparison.OrdinalIgnoreCase));
}
}
@@ -6,10 +6,14 @@ namespace MeetingAssistant.Recording;
public sealed class MicrophoneAudioSource : IMeetingAudioSource
{
private readonly IMicrophoneDeviceProvider microphones;
private readonly ILogger<MicrophoneAudioSource> logger;
public MicrophoneAudioSource(ILogger<MicrophoneAudioSource> logger)
public MicrophoneAudioSource(
IMicrophoneDeviceProvider microphones,
ILogger<MicrophoneAudioSource> logger)
{
this.microphones = microphones;
this.logger = logger;
}
@@ -22,7 +26,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CaptureAsync(new WasapiCapture(), options, cancellationToken);
return CaptureAsync(microphones.CreateCapture(options), options, cancellationToken);
}
private IAsyncEnumerable<AudioChunk> CaptureAsync(
@@ -6,7 +6,7 @@ public interface IOfflineTranscriptionBacklog
Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken);
Task CompleteAsync(string id, CancellationToken cancellationToken);
Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken);
}
public sealed record OfflineTranscriptionBacklogItem(
@@ -38,7 +38,7 @@ public sealed class NoopOfflineTranscriptionBacklog : IOfflineTranscriptionBackl
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>([]);
}
public Task CompleteAsync(string id, CancellationToken cancellationToken)
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
@@ -4,14 +4,11 @@ using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public sealed class OfflineTranscriptionBacklogProcessor
{
private const int MaxChunkDurationMilliseconds = 1000;
private readonly IOfflineTranscriptionBacklog backlog;
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
private readonly ITranscriptStore transcriptStore;
@@ -72,7 +69,7 @@ public sealed class OfflineTranscriptionBacklogProcessor
try
{
await ProcessAsync(item, cancellationToken);
await backlog.CompleteAsync(item.Id, cancellationToken);
await backlog.CompleteAsync(item, cancellationToken);
logger.LogInformation(
"Completed offline transcription backlog item {BacklogItemId} for recording {RecordingPath}",
item.Id,
@@ -215,29 +212,9 @@ public sealed class OfflineTranscriptionBacklogProcessor
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken)
{
using var reader = new WaveFileReader(audioPath);
var bytesPerSecond = reader.WaveFormat.AverageBytesPerSecond;
var chunkSize = Math.Max(
reader.WaveFormat.BlockAlign,
bytesPerSecond * MaxChunkDurationMilliseconds / 1000);
chunkSize -= chunkSize % reader.WaveFormat.BlockAlign;
var buffer = new byte[chunkSize];
while (true)
await foreach (var chunk in PcmWavAudioChunkReader.ReadChunksAsync(audioPath, cancellationToken: cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
var read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (read == 0)
{
break;
}
await pipeline.WriteAsync(
new AudioChunk(
buffer[..read],
reader.WaveFormat.SampleRate,
reader.WaveFormat.Channels),
cancellationToken);
await pipeline.WriteAsync(chunk, cancellationToken);
}
}
}
@@ -0,0 +1,48 @@
using System.Runtime.CompilerServices;
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public static class PcmWavAudioChunkReader
{
private const int MaxChunkDurationMilliseconds = 1000;
public static async IAsyncEnumerable<AudioChunk> ReadChunksAsync(
string path,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var reader = new WaveFileReader(path);
try
{
var format = reader.WaveFormat;
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
{
throw new InvalidDataException(
$"Only 16-bit PCM WAV files are supported. Found {format.Encoding} with {format.BitsPerSample} bits per sample.");
}
var chunkSize = Math.Max(
format.BlockAlign,
format.AverageBytesPerSecond * MaxChunkDurationMilliseconds / 1000);
chunkSize -= chunkSize % format.BlockAlign;
var buffer = new byte[chunkSize];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
}
}
finally
{
try
{
reader.Dispose();
}
catch (NotSupportedException)
{
// NAudio can throw while disposing reader streams from async iterators after all chunks were read.
}
}
}
}
@@ -0,0 +1,79 @@
using NAudio.CoreAudioApi;
using NAudio.Wave;
namespace MeetingAssistant.Recording;
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
{
private readonly MicrophoneDeviceSelection selection;
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
public WindowsMicrophoneDeviceProvider(
MicrophoneDeviceSelection selection,
ILogger<WindowsMicrophoneDeviceProvider> logger)
{
this.selection = selection;
this.logger = logger;
}
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
{
try
{
using var enumerator = new MMDeviceEnumerator();
return enumerator
.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active)
.Select(ToMicrophoneDevice)
.OrderBy(device => device.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not enumerate microphone capture endpoints");
return [];
}
}
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
{
var devices = GetAvailableMicrophones();
return new MicrophoneDeviceSnapshot(
devices,
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
}
public IWaveIn CreateCapture(MeetingAssistantOptions options)
{
var current = GetMicrophoneSnapshot(options).Current;
if (current is null)
{
logger.LogInformation("Starting microphone capture from Windows default capture endpoint");
return new WasapiCapture();
}
logger.LogInformation(
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
current.Name,
current.Id);
using var enumerator = new MMDeviceEnumerator();
return new WasapiCapture(enumerator.GetDevice(current.Id));
}
private static MicrophoneDevice? GetDefaultMicrophone()
{
try
{
using var enumerator = new MMDeviceEnumerator();
return ToMicrophoneDevice(enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console));
}
catch
{
return null;
}
}
private static MicrophoneDevice ToMicrophoneDevice(MMDevice device)
{
return new MicrophoneDevice(device.ID, device.FriendlyName);
}
}