Add meeting assistant speech and summary automation

This commit is contained in:
2026-05-21 09:55:39 +02:00
parent 1e97d2b9ff
commit 48d98d9cbb
63 changed files with 5143 additions and 151 deletions
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
+54 -9
View File
@@ -14,6 +14,8 @@ public sealed class MeetingAssistantOptions
public FunAsrOptions FunAsr { get; set; } = new();
public SpeakerIdentificationOptions SpeakerIdentification { get; set; } = new();
public AgentOptions Agent { get; set; } = new();
public ApiOptions Api { get; set; } = new();
@@ -26,22 +28,24 @@ public sealed class HotkeyOptions
public sealed class VaultOptions
{
public string TranscriptsFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Meetings\Transcripts";
public string BaseFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Exxeta";
public string MeetingNotesFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Meetings\Notes";
public string TranscriptsFolder { get; set; } = @"Meetings\Transcripts";
public string AssistantContextFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Meetings\Assistant Context";
public string MeetingNotesFolder { get; set; } = @"Meetings\Notes";
public string SummariesFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Meetings\Summaries";
public string AssistantContextFolder { get; set; } = @"Meetings\Assistant Context";
public string ProjectsFolder { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Projects";
public string SummariesFolder { get; set; } = @"Meetings\Summaries";
public string DictationWordsPath { get; set; } = @"%USERPROFILE%\OpenCloud\Persönlich\Vault\Meetings\dictation-words.md";
public string ProjectsFolder { get; set; } = @"Projects";
public string DictationWordsPath { get; set; } = @"Meetings\dictation-words.md";
}
public sealed class RecordingOptions
{
public string TranscriptionProvider { get; set; } = "funasr";
public string TranscriptionProvider { get; set; } = "azure-speech";
public int SampleRate { get; set; } = 16000;
@@ -49,6 +53,12 @@ public sealed class RecordingOptions
public TimeSpan StopProcessingTimeout { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan MetadataLookupTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan BackgroundMetadataLookupTimeout { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan OpenMeetingNoteDelay { get; set; } = TimeSpan.FromSeconds(1);
public string TemporaryRecordingsFolder { get; set; } = @"%LOCALAPPDATA%\MeetingAssistant\Recordings";
}
@@ -92,11 +102,15 @@ public sealed class PyannoteDiarizationOptions
public sealed class AzureSpeechOptions
{
public string Endpoint { get; set; } = "https://germanywestcentral.api.cognitive.microsoft.com/";
public string Endpoint { get; set; } = "";
public string Region { get; set; } = "germanywestcentral";
public string Language { get; set; } = "en-US";
public string Language { get; set; } = "auto";
public string[] AutoDetectLanguages { get; set; } = ["de-DE", "en-US"];
public string LanguageIdMode { get; set; } = "Continuous";
public string? Key { get; set; }
@@ -105,6 +119,8 @@ public sealed class AzureSpeechOptions
public TimeSpan RecognitionStopTimeout { get; set; } = TimeSpan.FromSeconds(10);
public bool DiarizeIntermediateResults { get; set; } = true;
public double PhraseListWeight { get; set; } = 1.5;
}
public enum PyannoteAnnotationSource
@@ -192,6 +208,33 @@ public sealed class FunAsrDiarizationOptions
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromMinutes(30);
}
public sealed class SpeakerIdentificationOptions
{
public bool Enabled { get; set; } = true;
public string DatabasePath { get; set; } = @"%LOCALAPPDATA%\MeetingAssistant\SpeakerIdentity\speaker-identities.db";
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(2);
public int MatchBatchSize { get; set; } = 6;
public int MaxMatchCandidates { get; set; } = 100;
public TimeSpan MatchIdentityActiveAge { get; set; } = TimeSpan.FromDays(365);
public int MaxSnippetsPerSpeaker { get; set; } = 3;
public double SilenceBetweenSnippetsSeconds { get; set; } = 1;
public TimeSpan LiveSampleBufferDuration { get; set; } = TimeSpan.FromMinutes(10);
public TimeSpan MergeRecentIdentityAge { get; set; } = TimeSpan.FromDays(14);
public AzureSpeechOptions AzureSpeech { get; set; } = new();
}
public sealed class AgentOptions
{
public string Endpoint { get; set; } = "https://litellm.schweigert.cloud";
@@ -219,6 +262,8 @@ public sealed class AgentOptions
public double CompactionRemainingRatio { get; set; } = 0.10;
public string ResponsesCompactPath { get; set; } = "responses/compact";
public string? InitialPrompt { get; set; }
}
public sealed class ApiOptions
@@ -8,6 +8,12 @@ public interface IMeetingArtifactStore
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken);
Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken);
Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
@@ -7,12 +7,29 @@ public interface IMeetingMetadataProvider
CancellationToken cancellationToken);
}
public interface IMeetingMetadataDiagnosticProvider
{
Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
DateTimeOffset startedAt,
TimeSpan timeout,
CancellationToken cancellationToken);
}
public sealed record MeetingMetadata(
string Title,
IReadOnlyList<string> Attendees,
string Agenda,
DateTimeOffset? ScheduledEnd = null);
public sealed record MeetingMetadataDiagnosticResult(
string Provider,
DateTimeOffset StartedAt,
bool Succeeded,
bool TimedOut,
long ElapsedMilliseconds,
MeetingMetadata? Metadata,
string? Error);
public sealed class NoopMeetingMetadataProvider : IMeetingMetadataProvider
{
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
@@ -58,6 +58,28 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
ToYamlState(state));
}
public async Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? MarkdownDocumentParser.SplitOptional(await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken))
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
var state = ParseState(existingFrontmatter.State);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
Render(artifacts, state, agenda, scheduledEnd, existing.Body),
cancellationToken);
logger.LogInformation(
"Updated assistant context note {AssistantContextPath} calendar metadata",
artifacts.AssistantContextPath);
}
private static string Render(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
@@ -100,6 +122,9 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
private sealed class AssistantContextFrontmatterYaml
{
[YamlMember(Alias = "state")]
public string? State { get; set; }
[YamlMember(Alias = "agenda")]
public string? Agenda { get; set; }
@@ -107,6 +132,19 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
public string? ScheduledEnd { get; set; }
}
private static AssistantContextState ParseState(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"transcribing" => AssistantContextState.Transcribing,
"speaker recognition" => AssistantContextState.SpeakerRecognition,
"summarizing" => AssistantContextState.Summarizing,
"finished" => AssistantContextState.Finished,
"error" => AssistantContextState.Error,
_ => AssistantContextState.Transcribing
};
}
private static string ToYamlState(AssistantContextState state)
{
return state switch
@@ -23,7 +23,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
@@ -1,18 +1,28 @@
using System.Diagnostics;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.MeetingNotes;
public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
{
private readonly MeetingAssistantOptions options;
private readonly ILogger<ObsidianMeetingNoteOpener> logger;
public ObsidianMeetingNoteOpener(ILogger<ObsidianMeetingNoteOpener> logger)
public ObsidianMeetingNoteOpener(
IOptions<MeetingAssistantOptions> options,
ILogger<ObsidianMeetingNoteOpener> logger)
{
this.options = options.Value;
this.logger = logger;
}
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
public async Task OpenAsync(string notePath, CancellationToken cancellationToken)
{
if (options.Recording.OpenMeetingNoteDelay > TimeSpan.Zero)
{
await Task.Delay(options.Recording.OpenMeetingNoteDelay, cancellationToken);
}
var uri = $"obsidian://open?path={Uri.EscapeDataString(Path.GetFullPath(notePath))}";
Process.Start(new ProcessStartInfo
{
@@ -20,7 +30,5 @@ public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
UseShellExecute = true
});
logger.LogInformation("Opened meeting note in Obsidian via {ObsidianUri}", uri);
return Task.CompletedTask;
}
}
@@ -1,8 +1,10 @@
using System.Runtime.InteropServices;
using System.Diagnostics;
using System.Collections;
namespace MeetingAssistant.MeetingNotes;
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
{
private const int OutlookCalendarFolder = 9;
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
@@ -19,7 +21,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
cancellationToken.ThrowIfCancellationRequested();
try
{
return Task.FromResult(GetCurrentMeeting(startedAt));
return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@@ -28,13 +30,66 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
}
public async Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
DateTimeOffset startedAt,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
var metadata = timeout > TimeSpan.Zero
? await lookup.WaitAsync(timeout, cancellationToken)
: await lookup.WaitAsync(cancellationToken);
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? "No matching Teams appointment was found." : null);
}
catch (TimeoutException exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
true,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
stopwatch.Stop();
return new MeetingMetadataDiagnosticResult(
nameof(OutlookClassicMeetingMetadataProvider),
startedAt,
false,
false,
stopwatch.ElapsedMilliseconds,
null,
exception.Message);
}
}
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
{
EnsureOutlookRunning();
object? application = null;
object? session = null;
object? calendar = null;
object? items = null;
object? restrictedItems = null;
try
{
@@ -53,31 +108,50 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
session = GetProperty(application, "Session");
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
items = GetProperty(calendar!, "Items");
SetProperty(items!, "IncludeRecurrences", true);
Invoke(items!, "Sort", "[Start]");
SetProperty(items!, "IncludeRecurrences", true);
var startedLocal = startedAt.LocalDateTime;
var windowStart = startedLocal.AddMinutes(-1);
var windowStart = startedLocal.AddHours(-4);
var windowEnd = startedLocal.AddMinutes(5);
var filter =
$"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'";
restrictedItems = Invoke(items!, "Restrict", filter);
var candidates = new List<OutlookAppointmentCandidate>();
var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count"));
for (var index = 1; index <= count; index++)
foreach (var item in EnumerateItems(items!))
{
var appointment = Invoke(restrictedItems!, "Item", index);
if (appointment is not null && IsTeamsAppointment(appointment))
if (item is null)
{
candidates.Add(new OutlookAppointmentCandidate(
appointment,
Convert.ToDateTime(GetProperty(appointment, "Start")),
Convert.ToDateTime(GetProperty(appointment, "End"))));
continue;
}
else
var keepAppointment = false;
try
{
ReleaseComObject(appointment);
if (!IsAppointment(item))
{
continue;
}
var start = Convert.ToDateTime(GetProperty(item, "Start"));
if (start > windowEnd)
{
break;
}
var end = Convert.ToDateTime(GetProperty(item, "End"));
if (end < windowStart || !IsTeamsAppointment(item))
{
continue;
}
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
keepAppointment = true;
}
finally
{
if (!keepAppointment)
{
ReleaseComObject(item);
}
}
}
@@ -118,7 +192,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
finally
{
ReleaseComObject(restrictedItems);
ReleaseComObject(items);
ReleaseComObject(calendar);
ReleaseComObject(session);
@@ -126,6 +199,83 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
}
}
private static IEnumerable<object?> EnumerateItems(object items)
{
if (items is IEnumerable enumerable)
{
foreach (var item in enumerable)
{
yield return item;
}
}
}
private static bool IsAppointment(object item)
{
try
{
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
}
catch
{
return false;
}
}
private void EnsureOutlookRunning()
{
if (Process.GetProcessesByName("OUTLOOK").Length > 0)
{
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = "outlook.exe",
UseShellExecute = true
});
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not start Outlook Classic before metadata lookup");
}
}
private static Task<T> RunStaAsync<T>(
Func<T> action,
CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
completion.TrySetResult(action());
}
catch (Exception exception)
{
completion.TrySetException(exception);
}
})
{
IsBackground = true,
Name = "Meeting Assistant Outlook COM Lookup"
};
thread.SetApartmentState(ApartmentState.STA);
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
_ = completion.Task.ContinueWith(
_ => registration.Dispose(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
thread.Start();
return completion.Task;
}
internal static string ExtractAgenda(string body)
{
if (string.IsNullOrWhiteSpace(body))
@@ -215,10 +365,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
ReleaseComObject(recipients);
}
return attendees
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return NormalizeAttendees(attendees);
}
private static DateTimeOffset ToLocalOffset(DateTime value)
@@ -226,6 +373,58 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
}
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
.ThenBy(attendee => attendee.Length)
.First())
.ToList();
}
private static string NormalizeAttendee(string attendee)
{
var normalized = attendee.Trim();
var mailtoPrefix = "mailto:";
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
while (normalized.Contains(" ", StringComparison.Ordinal))
{
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
}
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
var emailEnd = normalized.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
var name = normalized[..emailStart].Trim();
var email = normalized[(emailStart + 1)..emailEnd].Trim();
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
{
return email;
}
return $"{name} <{email}>";
}
return normalized;
}
private static string GetAttendeeDeduplicationKey(string attendee)
{
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
var emailEnd = attendee.LastIndexOf('>');
if (emailStart > 0 && emailEnd > emailStart)
{
return attendee[(emailStart + 1)..emailEnd].Trim();
}
return attendee.Trim();
}
private static string FormatRecipient(object recipient)
{
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
+61
View File
@@ -2,8 +2,10 @@ using MeetingAssistant;
using MeetingAssistant.Hotkeys;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -25,8 +27,22 @@ builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
builder.Services.AddSingleton<IMeetingNoteOpener, ObsidianMeetingNoteOpener>();
builder.Services.AddSingleton<IMeetingArtifactStore, MarkdownMeetingArtifactStore>();
builder.Services.AddSingleton<IRecordedAudioStore, TemporaryRecordedAudioStore>();
builder.Services.AddSingleton<IDictationWordStore, MarkdownDictationWordStore>();
builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOptions) =>
{
var appOptions = services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value;
var databasePath = VaultPath.Resolve(appOptions.SpeakerIdentification.DatabasePath);
Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
dbOptions.UseSqlite($"Data Source={databasePath}");
});
builder.Services.AddSingleton<ISpeakerSnippetExtractor, WavSpeakerSnippetExtractor>();
builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpeakerIdentityDiarizationClient>();
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArtifactResolver>();
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
builder.Services.AddSingleton<AsrDiagnosticService>();
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
@@ -41,6 +57,7 @@ builder.Services.AddTransient<PyannoteTranscriptFinalizer>();
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
#if WINDOWS
builder.Services.AddHostedService<GlobalHotkeyService>();
@@ -55,6 +72,48 @@ app.MapGet("/health", () => Results.Ok(new
}));
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
app.MapGet("/diagnostics/outlook/current-meeting", async (
IMeetingMetadataProvider metadataProvider,
IOptions<MeetingAssistantOptions> options,
DateTimeOffset? startedAt,
double? timeoutSeconds,
CancellationToken cancellationToken) =>
{
var lookupStartedAt = startedAt ?? DateTimeOffset.Now;
var timeout = timeoutSeconds is > 0
? TimeSpan.FromSeconds(timeoutSeconds.Value)
: options.Value.Recording.MetadataLookupTimeout;
if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
{
return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync(
lookupStartedAt,
timeout,
cancellationToken));
}
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var metadata = await metadataProvider.GetCurrentMeetingAsync(lookupStartedAt, cancellationToken);
stopwatch.Stop();
return Results.Ok(new MeetingMetadataDiagnosticResult(
metadataProvider.GetType().Name,
lookupStartedAt,
metadata is not null,
false,
stopwatch.ElapsedMilliseconds,
metadata,
metadata is null ? "No meeting metadata provider result was available." : null));
});
app.MapPost("/diagnostics/speaker-identities/merge", async (
SpeakerIdentityMergeDiagnosticRequest request,
ISpeakerIdentityMergeService mergeService,
IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) =>
{
var recentAge = request.RecentDays is > 0
? TimeSpan.FromDays(request.RecentDays.Value)
: options.Value.SpeakerIdentification.MergeRecentIdentityAge;
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
});
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
Results.Ok(await coordinator.ToggleAsync(cancellationToken)));
app.MapPost("/recording/start", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
@@ -189,3 +248,5 @@ public partial class Program;
public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null);
public sealed record SummaryRetryRequest(string SummaryPath);
public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null);
@@ -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";
@@ -0,0 +1,127 @@
using System.Threading.Channels;
using MeetingAssistant.Transcription;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
using Microsoft.CognitiveServices.Speech.Transcription;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Speakers;
public sealed class AzureSpeechSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
{
private readonly AzureSpeechOptions options;
private readonly ILogger<AzureSpeechSpeakerIdentityDiarizationClient> logger;
public AzureSpeechSpeakerIdentityDiarizationClient(
IOptions<MeetingAssistantOptions> options,
ILogger<AzureSpeechSpeakerIdentityDiarizationClient> logger)
{
this.options = options.Value.SpeakerIdentification.AzureSpeech;
this.logger = logger;
}
public async Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
string wavPath,
CancellationToken cancellationToken)
{
var key = AzureSpeechStreamingTranscriptionProvider.ResolveKey(options);
if (string.IsNullOrWhiteSpace(key))
{
logger.LogWarning(
"Speaker identity Azure Speech matching is enabled but no key is configured directly or via {KeyEnv}",
options.KeyEnv);
return [];
}
var speechConfig = string.IsNullOrWhiteSpace(options.Region)
? SpeechConfig.FromEndpoint(new Uri(options.Endpoint), key)
: SpeechConfig.FromSubscription(key, options.Region);
speechConfig.SpeechRecognitionLanguage = ResolveRecognitionLanguage(options);
speechConfig.OutputFormat = OutputFormat.Detailed;
speechConfig.SetProperty(
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
options.DiarizeIntermediateResults ? "true" : "false");
using var audioConfig = AudioConfig.FromWavFileInput(wavPath);
using var recognizer = new ConversationTranscriber(speechConfig, audioConfig);
var channel = Channel.CreateUnbounded<TranscriptionSegment>();
recognizer.Transcribed += (_, args) =>
{
if (args.Result.Reason != ResultReason.RecognizedSpeech)
{
return;
}
var start = TimeSpan.FromTicks(args.Result.OffsetInTicks);
channel.Writer.TryWrite(new TranscriptionSegment(
start,
start + args.Result.Duration,
NormalizeSpeakerId(args.Result.SpeakerId),
args.Result.Text.Trim()));
};
recognizer.Canceled += (_, args) =>
{
if (args.Reason == CancellationReason.Error)
{
channel.Writer.TryComplete(new InvalidOperationException(
$"Azure Speech identity matching canceled: {args.ErrorCode} {args.ErrorDetails}"));
return;
}
channel.Writer.TryComplete();
};
recognizer.SessionStopped += (_, _) => channel.Writer.TryComplete();
await recognizer.StartTranscribingAsync();
var segments = new List<TranscriptionSegment>();
try
{
await foreach (var segment in channel.Reader.ReadAllAsync(cancellationToken))
{
segments.Add(segment);
}
}
finally
{
try
{
var stopTask = recognizer.StopTranscribingAsync();
if (options.RecognitionStopTimeout > TimeSpan.Zero)
{
await stopTask.WaitAsync(options.RecognitionStopTimeout, CancellationToken.None);
}
else
{
await stopTask.WaitAsync(CancellationToken.None);
}
}
catch (TimeoutException)
{
logger.LogWarning("Timed out while stopping Azure Speech identity diarization recognizer");
}
}
return segments;
}
internal static string ResolveRecognitionLanguage(AzureSpeechOptions options)
{
if (!options.Language.Equals("auto", StringComparison.OrdinalIgnoreCase))
{
return options.Language;
}
return options.AutoDetectLanguages
.Select(language => language.Trim())
.FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
?? "de-DE";
}
private static string NormalizeSpeakerId(string? speakerId)
{
return string.IsNullOrWhiteSpace(speakerId) || speakerId.Equals("Unknown", StringComparison.OrdinalIgnoreCase)
? "Unknown"
: speakerId;
}
}
@@ -0,0 +1,229 @@
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
public interface ISpeakerIdentityDiarizationClient
{
Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
string wavPath,
CancellationToken cancellationToken);
}
public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
private readonly ISpeakerIdentityDiarizationClient diarizationClient;
private readonly SpeakerIdentificationOptions options;
private readonly ILogger<AzureSpeechSpeakerIdentityMatcher> logger;
public AzureSpeechSpeakerIdentityMatcher(
ISpeakerIdentityDiarizationClient diarizationClient,
Microsoft.Extensions.Options.IOptions<MeetingAssistantOptions> options,
ILogger<AzureSpeechSpeakerIdentityMatcher> logger)
{
this.diarizationClient = diarizationClient;
this.options = options.Value.SpeakerIdentification;
this.logger = logger;
}
public async Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken)
{
if (!options.Enabled || request.Candidates.Count == 0)
{
return null;
}
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)",
request.DiarizedSpeaker,
request.Candidates.Count);
var tempPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-speaker-match", $"{Guid.NewGuid():N}.wav");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
var layout = WriteCompositeWav(tempPath, request);
if (layout.UnknownSegment is null || layout.KnownSegments.Count == 0)
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} skipped because no readable snippets were available",
request.DiarizedSpeaker);
return null;
}
var segments = await diarizationClient.DiarizeAsync(tempPath, cancellationToken);
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} received {SegmentCount} diarized segment(s)",
request.DiarizedSpeaker,
segments.Count);
var unknownSpeaker = FindBestSpeaker(layout.UnknownSegment.Value, segments);
if (string.IsNullOrWhiteSpace(unknownSpeaker))
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} found no speaker for the unknown snippet",
request.DiarizedSpeaker);
return null;
}
foreach (var known in layout.KnownSegments.GroupBy(segment => segment.IdentityId))
{
var matchingSnippetCount = known.Count(segment =>
string.Equals(
FindBestSpeaker(segment.Segment, segments),
unknownSpeaker,
StringComparison.Ordinal));
var requiredMatches = known.Count() > 1 ? 2 : 1;
if (matchingSnippetCount >= requiredMatches)
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}",
request.DiarizedSpeaker,
known.Key);
return new SpeakerIdentityMatch(known.Key);
}
}
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} found no matching identity",
request.DiarizedSpeaker);
return null;
}
finally
{
TryDelete(tempPath);
}
}
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
{
using var firstReader = OpenFirstReadableWave(request);
if (firstReader is null)
{
return new CompositeLayout(null, []);
}
var silence = TimeSpan.FromSeconds(Math.Max(0, options.SilenceBetweenSnippetsSeconds));
using var writer = new WaveFileWriter(path, firstReader.WaveFormat);
var current = TimeSpan.Zero;
var knownSegments = new List<KnownCompositeSegment>();
foreach (var candidate in request.Candidates)
{
foreach (var snippet in candidate.Snippets.Where(storedSnippet => storedSnippet.Length > 0))
{
var segment = AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
if (segment is null)
{
continue;
}
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value));
current = segment.Value.End + silence;
WriteSilence(writer, firstReader.WaveFormat, silence);
}
}
var unknownSegment = AppendSnippet(writer, firstReader.WaveFormat, request.UnknownSnippet, current);
return new CompositeLayout(unknownSegment, knownSegments);
}
private static WaveFileReader? OpenFirstReadableWave(SpeakerIdentityMatchRequest request)
{
foreach (var bytes in request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet))
{
if (bytes.Length == 0)
{
continue;
}
try
{
return new WaveFileReader(new MemoryStream(bytes));
}
catch (FormatException)
{
}
}
return null;
}
private static TimeSegment? AppendSnippet(
WaveFileWriter writer,
WaveFormat targetFormat,
byte[] snippet,
TimeSpan start)
{
if (snippet.Length == 0)
{
return null;
}
using var reader = new WaveFileReader(new MemoryStream(snippet));
if (!WaveFormatsMatch(reader.WaveFormat, targetFormat))
{
throw new InvalidDataException("Speaker identity snippets must use the same WAV format.");
}
reader.CopyTo(writer);
return new TimeSegment(start, start + reader.TotalTime);
}
private static void WriteSilence(WaveFileWriter writer, WaveFormat format, TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var bytes = new byte[(int)(format.AverageBytesPerSecond * duration.TotalSeconds)];
writer.Write(bytes, 0, bytes.Length);
}
private static string? FindBestSpeaker(TimeSegment segment, IReadOnlyList<TranscriptionSegment> segments)
{
var bestOverlap = 0d;
string? bestSpeaker = null;
foreach (var transcriptSegment in segments)
{
var overlap = Math.Min(segment.End.TotalSeconds, transcriptSegment.End.TotalSeconds)
- Math.Max(segment.Start.TotalSeconds, transcriptSegment.Start.TotalSeconds);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
bestSpeaker = transcriptSegment.Speaker;
}
}
return bestSpeaker;
}
private static bool WaveFormatsMatch(WaveFormat left, WaveFormat right)
{
return left.Encoding == right.Encoding
&& left.SampleRate == right.SampleRate
&& left.Channels == right.Channels
&& left.BitsPerSample == right.BitsPerSample;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
}
private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<KnownCompositeSegment> KnownSegments);
private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment);
private readonly record struct TimeSegment(TimeSpan Start, TimeSpan End);
}
@@ -0,0 +1,11 @@
namespace MeetingAssistant.Speakers;
public sealed class NoopSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
public Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult<SpeakerIdentityMatch?>(null);
}
}
@@ -0,0 +1,64 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Speakers;
public sealed record SpeakerIdentificationRequest(
string AudioPath,
MeetingNote MeetingNote,
IReadOnlyList<TranscriptionSegment> Segments,
IReadOnlyList<SpeakerAudioSample>? Samples = null);
public sealed record SpeakerAudioSample(
string Speaker,
TranscriptionSegment Segment,
byte[] WavBytes,
double Score);
public sealed record SpeakerIdentificationResult(
IReadOnlyList<TranscriptionSegment> Segments,
IReadOnlyDictionary<string, string> SpeakerMappings,
IReadOnlyList<SpeakerIdentityAttendeeMatch>? AttendeeMatches = null);
public sealed record SpeakerIdentityAttendeeMatch(
string DisplayName,
IReadOnlyList<string> AcceptedNames);
public sealed record SpeakerIdentityMatchRequest(
string DiarizedSpeaker,
byte[] UnknownSnippet,
IReadOnlyList<SpeakerIdentityMatchCandidate> Candidates);
public sealed record SpeakerIdentityMatchCandidate(
int IdentityId,
string? CanonicalName,
int TranscriptionCount,
IReadOnlyList<byte[]> Snippets);
public sealed record SpeakerIdentityMatch(int IdentityId);
public interface ISpeakerIdentityMatcher
{
Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken);
}
public interface ISpeakerSnippetExtractor
{
Task<byte[]> ExtractSnippetAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken);
}
public interface ISpeakerIdentificationService
{
Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken);
Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,116 @@
using Microsoft.EntityFrameworkCore;
namespace MeetingAssistant.Speakers;
public sealed class SpeakerIdentity
{
public int Id { get; set; }
public string? CanonicalName { get; set; }
public int TranscriptionCount { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public List<SpeakerCandidateName> CandidateNames { get; set; } = [];
public List<SpeakerAlias> Aliases { get; set; } = [];
public List<SpeakerSnippet> Snippets { get; set; } = [];
public string? GetDisplayName()
{
if (!string.IsNullOrWhiteSpace(CanonicalName))
{
return CanonicalName;
}
return Aliases
.Select(alias => alias.Name)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Order(StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
}
}
public sealed class SpeakerCandidateName
{
public int Id { get; set; }
public int SpeakerIdentityId { get; set; }
public SpeakerIdentity? SpeakerIdentity { get; set; }
public string Name { get; set; } = "";
}
public sealed class SpeakerSnippet
{
public int Id { get; set; }
public int SpeakerIdentityId { get; set; }
public SpeakerIdentity? SpeakerIdentity { get; set; }
public byte[] WavBytes { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; }
}
public sealed class SpeakerAlias
{
public int Id { get; set; }
public int SpeakerIdentityId { get; set; }
public SpeakerIdentity? SpeakerIdentity { get; set; }
public string Name { get; set; } = "";
}
public sealed class SpeakerIdentityDbContext : DbContext
{
public SpeakerIdentityDbContext(DbContextOptions<SpeakerIdentityDbContext> options)
: base(options)
{
}
public DbSet<SpeakerIdentity> SpeakerIdentities => Set<SpeakerIdentity>();
public DbSet<SpeakerCandidateName> SpeakerCandidateNames => Set<SpeakerCandidateName>();
public DbSet<SpeakerAlias> SpeakerAliases => Set<SpeakerAlias>();
public DbSet<SpeakerSnippet> SpeakerSnippets => Set<SpeakerSnippet>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<SpeakerIdentity>(entity =>
{
entity.HasMany(identity => identity.CandidateNames)
.WithOne(candidate => candidate.SpeakerIdentity)
.HasForeignKey(candidate => candidate.SpeakerIdentityId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(identity => identity.Aliases)
.WithOne(alias => alias.SpeakerIdentity)
.HasForeignKey(alias => alias.SpeakerIdentityId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(identity => identity.Snippets)
.WithOne(snippet => snippet.SpeakerIdentity)
.HasForeignKey(snippet => snippet.SpeakerIdentityId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SpeakerCandidateName>()
.HasIndex(candidate => new { candidate.SpeakerIdentityId, candidate.Name })
.IsUnique();
modelBuilder.Entity<SpeakerAlias>()
.HasIndex(alias => new { alias.SpeakerIdentityId, alias.Name })
.IsUnique();
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
namespace MeetingAssistant.Speakers;
public sealed class SpeakerIdentityDatabaseInitializer : IHostedService
{
private static readonly SemaphoreSlim InitializeLock = new(1, 1);
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
public SpeakerIdentityDatabaseInitializer(IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory)
{
this.dbContextFactory = dbContextFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await InitializeLock.WaitAsync(cancellationToken);
try
{
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
}
finally
{
InitializeLock.Release();
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -0,0 +1,208 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Speakers;
public interface ISpeakerIdentityMergeService
{
Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
TimeSpan? recentIdentityAge,
CancellationToken cancellationToken);
}
public sealed record SpeakerIdentityMergeResult(
int RecentIdentityCount,
int CandidateIdentityCount,
int MatchAttempts,
int MergedPairs);
public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
{
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
private readonly ISpeakerIdentityMatcher matcher;
private readonly SpeakerIdentificationOptions options;
private readonly ILogger<SpeakerIdentityMergeService> logger;
public SpeakerIdentityMergeService(
IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory,
ISpeakerIdentityMatcher matcher,
IOptions<MeetingAssistantOptions> options,
ILogger<SpeakerIdentityMergeService> logger)
{
this.dbContextFactory = dbContextFactory;
this.matcher = matcher;
this.options = options.Value.SpeakerIdentification;
this.logger = logger;
}
public async Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
TimeSpan? recentIdentityAge,
CancellationToken cancellationToken)
{
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
var cutoff = DateTimeOffset.UtcNow - (recentIdentityAge ?? options.MergeRecentIdentityAge);
var identities = await context.SpeakerIdentities
.Include(identity => identity.Aliases)
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Snippets)
.OrderByDescending(identity => identity.TranscriptionCount)
.ThenBy(identity => identity.Id)
.ToListAsync(cancellationToken);
var recentIds = identities
.Where(identity => identity.CreatedAt >= cutoff)
.Select(identity => identity.Id)
.ToHashSet();
var mergedPairs = 0;
var attempts = 0;
foreach (var sourceId in recentIds.ToList())
{
var source = identities.SingleOrDefault(identity => identity.Id == sourceId);
if (source is null || source.Snippets.Count == 0)
{
continue;
}
var targets = identities
.Where(identity => identity.Id != source.Id && identity.Snippets.Count > 0)
.ToList();
foreach (var batch in targets.Chunk(Math.Max(1, options.MatchBatchSize)))
{
var firstSnippet = SelectSnippet(source, excludedSnippet: null);
if (firstSnippet is null)
{
break;
}
attempts++;
var firstMatch = await matcher.MatchAsync(
CreateRequest(source, firstSnippet, batch),
cancellationToken);
if (firstMatch is null || firstMatch.IdentityId == source.Id)
{
continue;
}
var target = batch.SingleOrDefault(identity => identity.Id == firstMatch.IdentityId);
if (target is null)
{
continue;
}
var secondSnippet = SelectSnippet(source, excludedSnippet: firstSnippet);
if (secondSnippet is null)
{
continue;
}
attempts++;
var secondMatch = await matcher.MatchAsync(
CreateRequest(source, secondSnippet, batch),
cancellationToken);
if (secondMatch?.IdentityId != target.Id)
{
continue;
}
MergeIdentities(target, source);
context.SpeakerIdentities.Remove(source);
identities.Remove(source);
mergedPairs++;
break;
}
}
await context.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Speaker identity merge diagnostics completed: {MergedPairs} merge(s), {Attempts} match attempt(s)",
mergedPairs,
attempts);
return new SpeakerIdentityMergeResult(
recentIds.Count,
identities.Count,
attempts,
mergedPairs);
}
private static SpeakerIdentityMatchRequest CreateRequest(
SpeakerIdentity source,
SpeakerSnippet unknownSnippet,
IReadOnlyList<SpeakerIdentity> candidates)
{
return new SpeakerIdentityMatchRequest(
source.GetDisplayName() ?? $"identity-{source.Id}",
unknownSnippet.WavBytes,
candidates.Select(identity => new SpeakerIdentityMatchCandidate(
identity.Id,
identity.GetDisplayName(),
identity.TranscriptionCount,
identity.Snippets
.OrderBy(snippet => snippet.CreatedAt)
.Select(snippet => snippet.WavBytes)
.ToList()))
.ToList());
}
private static SpeakerSnippet? SelectSnippet(SpeakerIdentity identity, SpeakerSnippet? excludedSnippet)
{
return identity.Snippets
.OrderBy(snippet => snippet.CreatedAt)
.FirstOrDefault(snippet => excludedSnippet is null || snippet.Id != excludedSnippet.Id);
}
private void MergeIdentities(SpeakerIdentity target, SpeakerIdentity source)
{
AddAlias(target, source.CanonicalName);
foreach (var alias in source.Aliases)
{
AddAlias(target, alias.Name);
}
foreach (var candidate in source.CandidateNames)
{
AddAlias(target, candidate.Name);
}
target.TranscriptionCount += source.TranscriptionCount;
target.UpdatedAt = DateTimeOffset.UtcNow;
var retainedSnippets = target.Snippets
.Concat(source.Snippets)
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
.Take(Math.Max(1, options.MaxSnippetsPerSpeaker))
.Select(snippet => new SpeakerSnippet
{
WavBytes = snippet.WavBytes,
CreatedAt = snippet.CreatedAt
})
.ToList();
target.Snippets.Clear();
target.Snippets.AddRange(retainedSnippets);
}
private static void AddAlias(SpeakerIdentity identity, string? alias)
{
if (string.IsNullOrWhiteSpace(alias) ||
string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) ||
identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() });
}
private static int StableSnippetKey(byte[] snippet)
{
var hash = new HashCode();
foreach (var value in snippet)
{
hash.Add(value);
}
return hash.ToHashCode();
}
}
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
namespace MeetingAssistant.Speakers;
internal static class SpeakerIdentitySchema
{
public static async Task EnsureCreatedOrUpdatedAsync(
SpeakerIdentityDbContext context,
CancellationToken cancellationToken)
{
await context.Database.EnsureCreatedAsync(cancellationToken);
await EnsureSpeakerIdentityTimestampColumnsAsync(context, cancellationToken);
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "SpeakerAliases" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerAliases" PRIMARY KEY AUTOINCREMENT,
"SpeakerIdentityId" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
CONSTRAINT "FK_SpeakerAliases_SpeakerIdentities_SpeakerIdentityId"
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
);
""",
cancellationToken);
await context.Database.ExecuteSqlRawAsync(
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SpeakerAliases_SpeakerIdentityId_Name"
ON "SpeakerAliases" ("SpeakerIdentityId", "Name");
""",
cancellationToken);
}
private static async Task EnsureSpeakerIdentityTimestampColumnsAsync(
SpeakerIdentityDbContext context,
CancellationToken cancellationToken)
{
const string defaultTimestamp = "1970-01-01T00:00:00.0000000+00:00";
var hasCreatedAt = await HasColumnAsync(context, "SpeakerIdentities", "CreatedAt", cancellationToken);
if (!hasCreatedAt)
{
await context.Database.ExecuteSqlRawAsync(
$"""
ALTER TABLE "SpeakerIdentities"
ADD COLUMN "CreatedAt" TEXT NOT NULL DEFAULT '{defaultTimestamp}';
""",
cancellationToken);
}
var hasUpdatedAt = await HasColumnAsync(context, "SpeakerIdentities", "UpdatedAt", cancellationToken);
if (!hasUpdatedAt)
{
await context.Database.ExecuteSqlRawAsync(
$"""
ALTER TABLE "SpeakerIdentities"
ADD COLUMN "UpdatedAt" TEXT NOT NULL DEFAULT '{defaultTimestamp}';
""",
cancellationToken);
await context.Database.ExecuteSqlRawAsync(
$"""
UPDATE "SpeakerIdentities"
SET "UpdatedAt" = "CreatedAt"
WHERE "UpdatedAt" = '{defaultTimestamp}';
""",
cancellationToken);
}
}
private static async Task<bool> HasColumnAsync(
SpeakerIdentityDbContext context,
string tableName,
string columnName,
CancellationToken cancellationToken)
{
var connection = context.Database.GetDbConnection();
var shouldClose = connection.State == System.Data.ConnectionState.Closed;
if (shouldClose)
{
await connection.OpenAsync(cancellationToken);
}
try
{
await using var command = connection.CreateCommand();
command.CommandText = $"""PRAGMA table_info("{tableName}")""";
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
finally
{
if (shouldClose)
{
await connection.CloseAsync();
}
}
}
}
@@ -0,0 +1,379 @@
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Speakers;
public sealed class SpeakerIdentityService : ISpeakerIdentificationService
{
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
private readonly ISpeakerSnippetExtractor snippetExtractor;
private readonly ISpeakerIdentityMatcher matcher;
private readonly SpeakerIdentificationOptions options;
private readonly ILogger<SpeakerIdentityService> logger;
public SpeakerIdentityService(
IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory,
ISpeakerSnippetExtractor snippetExtractor,
ISpeakerIdentityMatcher matcher,
IOptions<MeetingAssistantOptions> options,
ILogger<SpeakerIdentityService> logger)
{
this.dbContextFactory = dbContextFactory;
this.snippetExtractor = snippetExtractor;
this.matcher = matcher;
this.options = options.Value.SpeakerIdentification;
this.logger = logger;
}
public async Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.Final, cancellationToken);
}
public async Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken);
}
private async Task<SpeakerIdentificationResult> ProcessTranscriptAsync(
SpeakerIdentificationRequest request,
SpeakerIdentityProcessingMode mode,
CancellationToken cancellationToken)
{
if (!options.Enabled || request.Segments.Count == 0)
{
return new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>());
}
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
var samplesBySpeaker = request.Samples?
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
.GroupBy(sample => sample.Speaker, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(sample => sample.Score).Select(sample => sample.WavBytes).First(),
StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
foreach (var group in request.Segments
.Where(segment => !string.IsNullOrWhiteSpace(segment.Speaker))
.GroupBy(segment => segment.Speaker)
.OrderBy(group => group.Min(segment => segment.Start)))
{
var speaker = group.Key;
if (speakerMappings.ContainsKey(speaker))
{
continue;
}
var snippet = samplesBySpeaker.TryGetValue(speaker, out var suppliedSnippet)
? suppliedSnippet
: await snippetExtractor.ExtractSnippetAsync(
request.AudioPath,
group.ToList(),
cancellationToken);
if (snippet.Length == 0)
{
unmatchedSpeakers.Add((speaker, snippet));
continue;
}
var match = await FindMatchAsync(context, attendees, speaker, snippet, cancellationToken);
if (match is null)
{
unmatchedSpeakers.Add((speaker, snippet));
continue;
}
var identity = await LoadIdentityAsync(context, match.IdentityId, cancellationToken);
if (identity is null)
{
unmatchedSpeakers.Add((speaker, snippet));
continue;
}
if (mode == SpeakerIdentityProcessingMode.Final)
{
UpdateMatchedIdentity(identity, attendees, snippet);
foreach (var acceptedName in GetAcceptedNames(identity))
{
matchedAcceptedNames.Add(acceptedName);
}
}
var speakerName = identity.GetDisplayName();
if (!string.IsNullOrWhiteSpace(speakerName))
{
speakerMappings[speaker] = speakerName;
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
speakerName,
GetAcceptedNames(identity).ToList()));
}
}
if (mode == SpeakerIdentityProcessingMode.Final)
{
await LearnUnmatchedSpeakersAsync(context, attendees, matchedAcceptedNames, unmatchedSpeakers, cancellationToken);
}
await context.SaveChangesAsync(cancellationToken);
var relabeledSegments = request.Segments
.Select(segment => speakerMappings.TryGetValue(segment.Speaker, out var speakerName)
? segment with { Speaker = speakerName }
: segment)
.ToList();
return new SpeakerIdentificationResult(relabeledSegments, speakerMappings, attendeeMatches);
}
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
SpeakerIdentityDbContext context,
IReadOnlyList<string> attendees,
string speaker,
byte[] snippet,
CancellationToken cancellationToken)
{
var activeCutoff = DateTimeOffset.UtcNow - options.MatchIdentityActiveAge;
var maxCandidates = Math.Max(1, options.MaxMatchCandidates);
var identities = await context.SpeakerIdentities
.Include(identity => identity.Snippets)
.Include(identity => identity.Aliases)
.OrderByDescending(identity => identity.TranscriptionCount)
.ThenBy(identity => identity.Id)
.ToListAsync(cancellationToken);
identities = identities
.Select(identity => new
{
Identity = identity,
IsAttendee = MatchesAttendees(identity, attendees),
IsActive = identity.UpdatedAt >= activeCutoff
})
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
.OrderByDescending(candidate => candidate.IsAttendee)
.ThenByDescending(candidate => candidate.Identity.TranscriptionCount)
.ThenBy(candidate => candidate.Identity.Id)
.Take(maxCandidates)
.Select(candidate => candidate.Identity)
.ToList();
foreach (var batch in identities.Chunk(Math.Max(1, options.MatchBatchSize)))
{
var request = new SpeakerIdentityMatchRequest(
speaker,
snippet,
batch.Select(identity => new SpeakerIdentityMatchCandidate(
identity.Id,
identity.CanonicalName,
identity.TranscriptionCount,
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
.ToList());
var match = await matcher.MatchAsync(request, cancellationToken);
if (match is not null)
{
return match;
}
}
return null;
}
private static bool MatchesAttendees(
SpeakerIdentity identity,
IReadOnlyList<string> attendees)
{
var attendeeSet = attendees.ToHashSet(StringComparer.OrdinalIgnoreCase);
return GetAcceptedNames(identity).Any(attendeeSet.Contains);
}
private static Task<SpeakerIdentity?> LoadIdentityAsync(
SpeakerIdentityDbContext context,
int identityId,
CancellationToken cancellationToken)
{
return context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
}
private void UpdateMatchedIdentity(
SpeakerIdentity identity,
IReadOnlyList<string> attendees,
byte[] snippet)
{
identity.TranscriptionCount++;
identity.UpdatedAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
{
var currentCandidates = identity.CandidateNames
.Select(candidate => candidate.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var fallbackAliasCandidate = currentCandidates.Count == 1 ? currentCandidates.Single() : null;
var aliasToCandidate = identity.Aliases
.Where(alias => !string.IsNullOrWhiteSpace(alias.Name))
.SelectMany(alias => currentCandidates.Select(candidate => new { Alias = alias.Name, Candidate = candidate }))
.Where(pair => string.Equals(pair.Alias, pair.Candidate, StringComparison.OrdinalIgnoreCase) ||
pair.Alias.Contains(pair.Candidate, StringComparison.OrdinalIgnoreCase) ||
pair.Candidate.Contains(pair.Alias, StringComparison.OrdinalIgnoreCase))
.ToDictionary(pair => pair.Alias, pair => pair.Candidate, StringComparer.OrdinalIgnoreCase);
var intersection = attendees
.Select(attendee => currentCandidates.Contains(attendee)
? attendee
: aliasToCandidate.GetValueOrDefault(attendee) ??
(identity.Aliases.Any(alias => string.Equals(alias.Name, attendee, StringComparison.OrdinalIgnoreCase))
? fallbackAliasCandidate
: null))
.Where(candidate => !string.IsNullOrWhiteSpace(candidate))
.Select(candidate => candidate!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
if (intersection.Count == 0)
{
ResetCandidates(identity, attendees);
ReplaceOldestSnippet(identity, snippet);
return;
}
ResetCandidates(identity, intersection);
if (intersection.Count == 1)
{
identity.CanonicalName = intersection[0];
}
}
AddSnippetIfNeeded(identity, snippet);
}
private async Task LearnUnmatchedSpeakersAsync(
SpeakerIdentityDbContext context,
IReadOnlyList<string> attendees,
IEnumerable<string> matchedCanonicalNames,
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
CancellationToken cancellationToken)
{
var remainingCandidates = attendees
.Except(matchedCanonicalNames, StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
if (remainingCandidates.Count == 0)
{
return;
}
foreach (var (_, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
{
var now = DateTimeOffset.UtcNow;
context.SpeakerIdentities.Add(new SpeakerIdentity
{
CreatedAt = now,
UpdatedAt = now,
CandidateNames = remainingCandidates
.Select(candidate => new SpeakerCandidateName { Name = candidate })
.ToList(),
Snippets =
[
new SpeakerSnippet
{
WavBytes = snippet,
CreatedAt = now
}
]
});
}
await Task.CompletedTask;
}
private void AddSnippetIfNeeded(SpeakerIdentity identity, byte[] snippet)
{
if (snippet.Length == 0 || identity.Snippets.Count >= options.MaxSnippetsPerSpeaker)
{
return;
}
identity.Snippets.Add(new SpeakerSnippet
{
WavBytes = snippet,
CreatedAt = DateTimeOffset.UtcNow
});
}
private static void ReplaceOldestSnippet(SpeakerIdentity identity, byte[] snippet)
{
var oldest = identity.Snippets.OrderBy(storedSnippet => storedSnippet.CreatedAt).FirstOrDefault();
if (oldest is not null)
{
identity.Snippets.Remove(oldest);
}
if (snippet.Length > 0)
{
identity.Snippets.Add(new SpeakerSnippet
{
WavBytes = snippet,
CreatedAt = DateTimeOffset.UtcNow
});
}
}
private static void ResetCandidates(SpeakerIdentity identity, IReadOnlyList<string> candidates)
{
identity.CandidateNames.Clear();
identity.CandidateNames.AddRange(candidates
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.Select(candidate => new SpeakerCandidateName { Name = candidate }));
}
private static IReadOnlySet<string> GetAcceptedNames(SpeakerIdentity identity)
{
return new[]
{
identity.CanonicalName
}
.Concat(identity.Aliases.Select(alias => alias.Name))
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name!.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
{
return attendees
.Select(NormalizeAttendee)
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeAttendee(string attendee)
{
var trimmed = attendee.Trim();
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
}
private enum SpeakerIdentityProcessingMode
{
LiveReadOnly,
Final
}
}
@@ -0,0 +1,49 @@
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
public sealed class WavSpeakerSnippetExtractor : ISpeakerSnippetExtractor
{
public Task<byte[]> ExtractSnippetAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
if (speakerSegments.Count == 0 || !File.Exists(audioPath))
{
return Task.FromResult<byte[]>([]);
}
var start = speakerSegments.Min(segment => segment.Start);
var end = speakerSegments.Max(segment => segment.End);
if (end <= start)
{
return Task.FromResult<byte[]>([]);
}
using var reader = new WaveFileReader(audioPath);
using var output = new MemoryStream();
using (var writer = new WaveFileWriter(output, reader.WaveFormat))
{
reader.CurrentTime = start;
var bytesRemaining = reader.WaveFormat.AverageBytesPerSecond * (end - start).TotalSeconds;
var buffer = new byte[reader.WaveFormat.AverageBytesPerSecond];
while (bytesRemaining > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var bytesToRead = (int)Math.Min(buffer.Length, bytesRemaining);
var bytesRead = reader.Read(buffer, 0, bytesToRead);
if (bytesRead == 0)
{
break;
}
writer.Write(buffer, 0, bytesRead);
bytesRemaining -= bytesRead;
}
}
return Task.FromResult(output.ToArray());
}
}
@@ -0,0 +1,10 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryInstructionBuilder
{
Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken);
}
@@ -33,7 +33,7 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
return null;
}
var meetingNotesFolder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
if (!Directory.Exists(meetingNotesFolder))
{
return null;
@@ -65,7 +65,7 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
return null;
}
var summariesFolder = VaultPath.Resolve(options.Vault.SummariesFolder);
var summariesFolder = VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
var requested = Environment.ExpandEnvironmentVariables(summaryPath);
var fullPath = Path.IsPathRooted(requested)
? Path.GetFullPath(requested)
@@ -0,0 +1,135 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructionBuilder
{
public const string DefaultInitialPrompt = """
You are the Meeting Assistant summary agent.
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
Then write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
Keep the output grounded in the source material and explicitly say when a section has no known items.
""";
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingAssistantOptions options;
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
{
this.options = options.Value;
}
public async Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
? DefaultInitialPrompt
: options.Agent.InitialPrompt.Trim();
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, cancellationToken);
return string.IsNullOrWhiteSpace(projectInstructions)
? instructions
: instructions.TrimEnd() + "\n\n" + projectInstructions;
}
private async Task<string> BuildProjectInstructionsAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, cancellationToken);
if (projects.Count == 0)
{
return "";
}
var blocks = projects.Select(project =>
$"# {project.Name}\n\n{project.Instructions.Trim()}");
return "---\nprojects:\n\n" + string.Join("\n\n", blocks);
}
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
if (projectNames.Count == 0)
{
return [];
}
var projectsRoot = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
if (!Directory.Exists(projectsRoot))
{
return [];
}
var projects = new List<ProjectInstructions>();
foreach (var projectDirectory in Directory.EnumerateDirectories(projectsRoot).Order(StringComparer.OrdinalIgnoreCase))
{
var projectName = Path.GetFileName(projectDirectory);
if (!projectNames.Contains(projectName))
{
continue;
}
var agentsPath = Path.Combine(projectDirectory, "AGENTS.md");
if (!File.Exists(agentsPath))
{
continue;
}
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(content))
{
projects.Add(new ProjectInstructions(projectName, content));
}
}
return projects;
}
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
string meetingNotePath,
CancellationToken cancellationToken)
{
if (!File.Exists(meetingNotePath))
{
return [];
}
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return [];
}
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
?? new ProjectFrontmatter();
return (frontmatter.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private sealed record ProjectInstructions(string Name, string Instructions);
private sealed class ProjectFrontmatter
{
[YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
}
@@ -1,4 +1,5 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Transcription;
using System.Diagnostics;
using System.Text.Json;
using YamlDotNet.Serialization;
@@ -13,16 +14,21 @@ public sealed class MeetingSummaryTools
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
private readonly IDictationWordStore? dictationWordStore;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions())
: this(artifacts, new MeetingAssistantOptions(), null)
{
}
public MeetingSummaryTools(MeetingSessionArtifacts artifacts, MeetingAssistantOptions options)
public MeetingSummaryTools(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
IDictationWordStore? dictationWordStore = null)
{
this.artifacts = artifacts;
this.options = options;
this.dictationWordStore = dictationWordStore;
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
@@ -55,7 +61,23 @@ public sealed class MeetingSummaryTools
public Task<string> ReadGlossary(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault.DictationWordsPath), @from, to);
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
}
public async Task<string> AddDictationWord(string word)
{
if (dictationWordStore is null)
{
return "Dictation word store is not available.";
}
if (string.IsNullOrWhiteSpace(word))
{
return "Refused: word must not be empty.";
}
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
}
public async Task<string> WriteSummary(string markdown, string? title = null)
@@ -436,7 +458,7 @@ public sealed class MeetingSummaryTools
private string GetProjectsRoot()
{
return VaultPath.Resolve(options.Vault.ProjectsFolder);
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
}
private static async Task<string?> RunRipgrepAsync(
@@ -1,4 +1,5 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Transcription;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
@@ -9,38 +10,30 @@ namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
{
private const string Instructions = """
You are the Meeting Assistant summary agent.
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
Then write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
Keep the output grounded in the source material and explicitly say when a section has no known items.
""";
private readonly MeetingAssistantOptions options;
private readonly ILoggerFactory loggerFactory;
private readonly ILogger<OpenAiMeetingSummaryAgentPipeline> logger;
private readonly IServiceProvider services;
private readonly IMeetingSummaryFailureWriter failureWriter;
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
private readonly IDictationWordStore dictationWordStore;
public OpenAiMeetingSummaryAgentPipeline(
IOptions<MeetingAssistantOptions> options,
ILoggerFactory loggerFactory,
ILogger<OpenAiMeetingSummaryAgentPipeline> logger,
IServiceProvider services,
IMeetingSummaryFailureWriter failureWriter)
IMeetingSummaryFailureWriter failureWriter,
IMeetingSummaryInstructionBuilder instructionBuilder,
IDictationWordStore dictationWordStore)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
this.logger = logger;
this.services = services;
this.failureWriter = failureWriter;
this.instructionBuilder = instructionBuilder;
this.dictationWordStore = dictationWordStore;
}
public async Task<MeetingSummaryRunResult> RunAsync(
@@ -49,7 +42,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
{
var agentOptions = options.Agent;
var key = ResolveApiKey(agentOptions);
var tools = CreateTools(new MeetingSummaryTools(artifacts, options));
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore));
var instructions = await instructionBuilder.BuildAsync(artifacts, cancellationToken);
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
key,
@@ -76,7 +70,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
.UseFunctionInvocation()
.Build()
.AsAIAgent(
Instructions,
instructions,
"meeting_summary",
"Writes the markdown summary note for a captured meeting.",
tools,
@@ -135,6 +129,10 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
tools.ReadGlossary,
"read_glossary",
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."),
AIFunctionFactory.Create(
tools.AddDictationWord,
"add_dictation_word",
"Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."),
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
@@ -22,6 +22,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions pipelineOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await using var enumerator = audio.GetAsyncEnumerator(cancellationToken);
@@ -38,7 +39,8 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
(byte)firstChunk.Channels));
using var audioConfig = AudioConfig.FromStreamInput(pushStream);
var speechConfig = CreateSpeechConfig();
using var recognizer = new ConversationTranscriber(speechConfig, audioConfig);
using var recognizer = CreateTranscriber(speechConfig, audioConfig);
AddPhraseList(recognizer, pipelineOptions.DictationWords);
var segments = Channel.CreateUnbounded<TranscriptionSegment>();
recognizer.Transcribed += (_, args) =>
@@ -94,7 +96,51 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
await pumpTask.WaitAsync(cancellationToken);
}
private SpeechConfig CreateSpeechConfig()
internal ConversationTranscriber CreateTranscriber(SpeechConfig speechConfig, AudioConfig audioConfig)
{
var languages = GetAutoDetectLanguages(options.AzureSpeech);
if (languages.Count == 0)
{
return new ConversationTranscriber(speechConfig, audioConfig);
}
logger.LogInformation(
"Azure Speech auto language detection enabled for {Languages}",
string.Join(", ", languages));
return new ConversationTranscriber(
speechConfig,
AutoDetectSourceLanguageConfig.FromLanguages(languages.ToArray()),
audioConfig);
}
internal void AddPhraseList(ConversationTranscriber recognizer, IReadOnlyList<string>? words)
{
var phrases = NormalizeDictationWords(words);
if (phrases.Count == 0)
{
return;
}
var phraseList = PhraseListGrammar.FromRecognizer(recognizer);
foreach (var phrase in phrases)
{
phraseList.AddPhrase(phrase);
}
phraseList.SetWeight(options.AzureSpeech.PhraseListWeight);
logger.LogInformation("Configured Azure Speech phrase list with {PhraseCount} phrase(s)", phrases.Count);
}
internal static IReadOnlyList<string> NormalizeDictationWords(IReadOnlyList<string>? words)
{
return (words ?? [])
.Select(word => word.Trim())
.Where(word => !string.IsNullOrWhiteSpace(word))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
internal SpeechConfig CreateSpeechConfig()
{
var azure = options.AzureSpeech;
var key = ResolveKey(azure);
@@ -104,10 +150,19 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
$"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}.");
}
var speechConfig = string.IsNullOrWhiteSpace(azure.Region)
? SpeechConfig.FromEndpoint(new Uri(azure.Endpoint), key)
: SpeechConfig.FromSubscription(key, azure.Region);
speechConfig.SpeechRecognitionLanguage = azure.Language;
var speechConfig = SpeechConfig.FromEndpoint(GetEndpoint(azure), key);
var autoDetectLanguages = GetAutoDetectLanguages(azure);
if (autoDetectLanguages.Count == 0)
{
speechConfig.SpeechRecognitionLanguage = azure.Language;
}
else
{
speechConfig.SetProperty(
PropertyId.SpeechServiceConnection_LanguageIdMode,
NormalizeLanguageIdMode(azure.LanguageIdMode));
}
speechConfig.OutputFormat = OutputFormat.Detailed;
speechConfig.SetProperty(
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
@@ -115,6 +170,42 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
return speechConfig;
}
internal static IReadOnlyList<string> GetAutoDetectLanguages(AzureSpeechOptions options)
{
if (!options.Language.Equals("auto", StringComparison.OrdinalIgnoreCase))
{
return [];
}
return options.AutoDetectLanguages
.Select(language => language.Trim())
.Where(language => !string.IsNullOrWhiteSpace(language))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
internal static Uri GetEndpoint(AzureSpeechOptions options)
{
if (!string.IsNullOrWhiteSpace(options.Endpoint))
{
return new Uri(options.Endpoint);
}
if (string.IsNullOrWhiteSpace(options.Region))
{
throw new InvalidOperationException("Azure Speech region or endpoint must be configured.");
}
return new Uri($"wss://{options.Region}.stt.speech.microsoft.com/speech/universal/v2");
}
internal static string NormalizeLanguageIdMode(string? value)
{
return value?.Trim().Equals("AtStart", StringComparison.OrdinalIgnoreCase) == true
? "AtStart"
: "Continuous";
}
private static async Task PumpAudioAsync(
AudioChunk firstChunk,
IAsyncEnumerator<AudioChunk> audio,
@@ -176,15 +267,21 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
: speakerId;
}
private static string ResolveKey(AzureSpeechOptions options)
internal static string ResolveKey(AzureSpeechOptions options)
{
if (!string.IsNullOrWhiteSpace(options.Key))
{
return options.Key;
}
return string.IsNullOrWhiteSpace(options.KeyEnv)
? ""
: Environment.GetEnvironmentVariable(options.KeyEnv) ?? "";
if (string.IsNullOrWhiteSpace(options.KeyEnv))
{
return "";
}
return Environment.GetEnvironmentVariable(options.KeyEnv) ??
Environment.GetEnvironmentVariable(options.KeyEnv, EnvironmentVariableTarget.User) ??
Environment.GetEnvironmentVariable(options.KeyEnv, EnvironmentVariableTarget.Machine) ??
"";
}
}
@@ -26,10 +26,11 @@ public sealed class FunAsrStreamingTranscriptionProvider : IStreamingTranscripti
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var segments = Channel.CreateUnbounded<TranscriptionSegment>();
var session = Task.Run(() => RunSessionAsync(audio, segments.Writer, cancellationToken), CancellationToken.None);
var session = Task.Run(() => RunSessionAsync(audio, options, segments.Writer, cancellationToken), CancellationToken.None);
await foreach (var segment in segments.Reader.ReadAllAsync(cancellationToken))
{
@@ -41,6 +42,7 @@ public sealed class FunAsrStreamingTranscriptionProvider : IStreamingTranscripti
private async Task RunSessionAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions pipelineOptions,
ChannelWriter<TranscriptionSegment> segments,
CancellationToken cancellationToken)
{
@@ -61,7 +63,7 @@ public sealed class FunAsrStreamingTranscriptionProvider : IStreamingTranscripti
var finalReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var receiveTask = ReceiveAsync(connection, parser, segments, finalReceived, cancellationToken);
await SendStartAsync(connection, firstChunk, cancellationToken);
await SendStartAsync(connection, firstChunk, pipelineOptions.DictationWords, cancellationToken);
await connection.SendBinaryAsync(firstChunk.Pcm, cancellationToken);
while (await enumerator.MoveNextAsync())
@@ -139,6 +141,7 @@ public sealed class FunAsrStreamingTranscriptionProvider : IStreamingTranscripti
private async Task SendStartAsync(
IFunAsrWebSocketConnection connection,
AudioChunk firstChunk,
IReadOnlyList<string>? dictationWords,
CancellationToken cancellationToken)
{
var message = new Dictionary<string, object?>
@@ -152,24 +155,17 @@ public sealed class FunAsrStreamingTranscriptionProvider : IStreamingTranscripti
["wav_name"] = options.FunAsr.WavName,
["wav_format"] = "pcm",
["is_speaking"] = true,
["hotwords"] = await LoadHotwordsAsync(cancellationToken),
["hotwords"] = BuildHotwords(dictationWords),
["itn"] = options.FunAsr.Itn
};
await connection.SendTextAsync(JsonSerializer.Serialize(message), cancellationToken);
}
private async Task<string> LoadHotwordsAsync(CancellationToken cancellationToken)
internal static string BuildHotwords(IReadOnlyList<string>? dictationWords)
{
var path = VaultPath.Resolve(options.Vault.DictationWordsPath);
if (!File.Exists(path))
{
return "";
}
var words = await File.ReadAllLinesAsync(path, cancellationToken);
var hotwords = words
.Select(line => line.Trim().TrimStart('-', '*').Trim())
var hotwords = (dictationWords ?? [])
.Select(word => word.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToDictionary(word => word, _ => 20);
@@ -0,0 +1,8 @@
namespace MeetingAssistant.Transcription;
public interface IDictationWordStore
{
Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken);
}
@@ -6,6 +6,10 @@ public interface ISpeechRecognitionPipeline : IAsyncDisposable
{
Task InitializeAsync(CancellationToken cancellationToken);
Task InitializeAsync(
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken);
Task WaitUntilReadyAsync(CancellationToken cancellationToken);
ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken);
@@ -25,7 +29,9 @@ public interface ISpeechRecognitionPipelineFactory
ISpeechRecognitionPipeline Create();
}
public sealed record SpeechRecognitionPipelineOptions(int? NumSpeakers = null)
public sealed record SpeechRecognitionPipelineOptions(
int? NumSpeakers = null,
IReadOnlyList<string>? DictationWords = null)
{
public static SpeechRecognitionPipelineOptions Default { get; } = new();
}
@@ -6,5 +6,6 @@ public interface IStreamingTranscriptionProvider
{
IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken);
}
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Transcription;
public sealed class MarkdownDictationWordStore : IDictationWordStore
{
private readonly MeetingAssistantOptions options;
public MarkdownDictationWordStore(IOptions<MeetingAssistantOptions> options)
{
this.options = options.Value;
}
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
var path = GetPath();
if (!File.Exists(path))
{
return [];
}
var lines = await File.ReadAllLinesAsync(path, cancellationToken);
return Normalize(lines);
}
public async Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
var path = GetPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var existing = File.Exists(path)
? await File.ReadAllLinesAsync(path, cancellationToken)
: [];
var words = Normalize(existing.Append(word));
await File.WriteAllTextAsync(
path,
string.Join(Environment.NewLine, words.Select(value => $"- {value}")) + Environment.NewLine,
cancellationToken);
return words;
}
private string GetPath()
{
return VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath);
}
internal static IReadOnlyList<string> Normalize(IEnumerable<string> lines)
{
return lines
.Select(ParseLine)
.Where(word => !string.IsNullOrWhiteSpace(word))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string ParseLine(string line)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("- ", StringComparison.Ordinal) ||
trimmed.StartsWith("* ", StringComparison.Ordinal))
{
trimmed = trimmed[2..].Trim();
}
return trimmed.Trim('`', '"', '\'').Trim();
}
}
@@ -9,6 +9,7 @@ public abstract class StreamingSpeechRecognitionPipeline : ISpeechRecognitionPip
private readonly Channel<AudioChunk> audio = Channel.CreateUnbounded<AudioChunk>();
private readonly Channel<TranscriptionSegment> liveTranscript = Channel.CreateUnbounded<TranscriptionSegment>();
private readonly List<TranscriptionSegment> liveSegments = [];
private SpeechRecognitionPipelineOptions options = SpeechRecognitionPipelineOptions.Default;
private Task? transcriptionTask;
private bool initialized;
@@ -18,6 +19,13 @@ public abstract class StreamingSpeechRecognitionPipeline : ISpeechRecognitionPip
}
public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
return InitializeAsync(SpeechRecognitionPipelineOptions.Default, cancellationToken);
}
public virtual Task InitializeAsync(
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
if (initialized)
{
@@ -25,6 +33,7 @@ public abstract class StreamingSpeechRecognitionPipeline : ISpeechRecognitionPip
}
initialized = true;
this.options = options;
transcriptionTask = Task.Run(() => TranscribeAsync(cancellationToken), CancellationToken.None);
return Task.CompletedTask;
}
@@ -90,6 +99,7 @@ public abstract class StreamingSpeechRecognitionPipeline : ISpeechRecognitionPip
{
await foreach (var segment in transcriptionProvider.TranscribeAsync(
audio.Reader.ReadAllAsync(cancellationToken),
options,
cancellationToken))
{
liveSegments.Add(segment);
@@ -20,6 +20,7 @@ public sealed class WhisperLocalStreamingTranscriptionProvider : IStreamingTrans
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions pipelineOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var modelPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(options.WhisperLocal.ModelPath));
@@ -29,9 +30,7 @@ public sealed class WhisperLocalStreamingTranscriptionProvider : IStreamingTrans
}
using var factory = WhisperFactory.FromPath(modelPath);
using var processor = factory.CreateBuilder()
.WithLanguage(options.WhisperLocal.Language)
.Build();
using var processor = CreateProcessor(factory, pipelineOptions.DictationWords);
var window = new List<AudioChunk>();
var windowDuration = TimeSpan.Zero;
@@ -119,6 +118,37 @@ public sealed class WhisperLocalStreamingTranscriptionProvider : IStreamingTrans
}
}
private WhisperProcessor CreateProcessor(
WhisperFactory factory,
IReadOnlyList<string>? dictationWords)
{
var builder = factory.CreateBuilder()
.WithLanguage(options.WhisperLocal.Language);
var prompt = BuildPrompt(dictationWords);
if (!string.IsNullOrWhiteSpace(prompt))
{
builder.WithPrompt(prompt)
.WithCarryInitialPrompt(true);
logger.LogInformation(
"Configured Whisper prompt with {WordCount} dictation word(s)",
dictationWords?.Count ?? 0);
}
return builder.Build();
}
internal static string BuildPrompt(IReadOnlyList<string>? dictationWords)
{
var words = (dictationWords ?? [])
.Select(word => word.Trim())
.Where(word => !string.IsNullOrWhiteSpace(word))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return words.Count == 0
? ""
: "Prefer these domain terms and acronyms: " + string.Join(", ", words) + ".";
}
private static bool IsControlMarker(string text)
{
return text is "[BLANK_AUDIO]" or "[MUSIC]" or "[NO_SPEECH]" or "[SILENCE]";
+11
View File
@@ -14,4 +14,15 @@ public static class VaultPath
return Path.GetFullPath(expanded);
}
public static string Resolve(VaultOptions vault, string path)
{
var expanded = Environment.ExpandEnvironmentVariables(path);
if (Path.IsPathRooted(expanded) || expanded.StartsWith("~", StringComparison.Ordinal))
{
return Resolve(expanded);
}
return Path.GetFullPath(Path.Combine(Resolve(vault.BaseFolder), expanded));
}
}
+40 -13
View File
@@ -4,24 +4,32 @@
"Toggle": "Ctrl+Alt+M"
},
"Vault": {
"TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts",
"MeetingNotesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Notes",
"AssistantContextFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Assistant Context",
"SummariesFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Summaries",
"ProjectsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Projects",
"DictationWordsPath": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\dictation-words.md"
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
"TranscriptsFolder": "Meetings\\Transcripts",
"MeetingNotesFolder": "Meetings\\Notes",
"AssistantContextFolder": "Meetings\\Assistant Context",
"SummariesFolder": "Meetings\\Summaries",
"ProjectsFolder": "Projects",
"DictationWordsPath": "C:\\Users\\masc3\\OpenCloud\\Persönlich\\Vault\\Exxeta\\Meetings\\Dictionary.md"
},
"Recording": {
"TranscriptionProvider": "funasr",
"TranscriptionProvider": "azure-speech",
"SampleRate": 16000,
"Channels": 1,
"StopProcessingTimeout": "00:10:00",
"MetadataLookupTimeout": "00:00:10",
"BackgroundMetadataLookupTimeout": "00:01:00",
"OpenMeetingNoteDelay": "00:00:01",
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
},
"FunAsr": {
"Endpoint": "ws://127.0.0.1:10095",
"Mode": "2pass",
"ChunkSize": [5, 10, 5],
"ChunkSize": [
5,
10,
5
],
"ChunkInterval": 10,
"EncoderChunkLookBack": 4,
"DecoderChunkLookBack": 1,
@@ -72,12 +80,30 @@
}
},
"AzureSpeech": {
"Endpoint": "https://germanywestcentral.api.cognitive.microsoft.com/",
"Endpoint": "wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2",
"Region": "germanywestcentral",
"Language": "en-US",
"Language": "de-DE",
"AutoDetectLanguages": [
"de-DE"
],
"LanguageIdMode": "Continuous",
"KeyEnv": "AZURE_SPEECH_KEY",
"RecognitionStopTimeout": "00:00:10",
"DiarizeIntermediateResults": true
"DiarizeIntermediateResults": true,
"PhraseListWeight": 1.5
},
"SpeakerIdentification": {
"Enabled": true,
"DatabasePath": "%LOCALAPPDATA%\\MeetingAssistant\\SpeakerIdentity\\speaker-identities.db",
"InitialDelay": "00:02:00",
"Interval": "00:02:00",
"MatchBatchSize": 6,
"MaxMatchCandidates": 100,
"MatchIdentityActiveAge": "365.00:00:00",
"MaxSnippetsPerSpeaker": 3,
"SilenceBetweenSnippetsSeconds": 1,
"LiveSampleBufferDuration": "00:10:00",
"MergeRecentIdentityAge": "14.00:00:00"
},
"Agent": {
"Endpoint": "https://litellm.schweigert.cloud",
@@ -90,8 +116,9 @@
"ContextWindowTokens": 128000,
"MaxOutputTokens": 8192,
"EnableCompaction": true,
"CompactionRemainingRatio": 0.10,
"ResponsesCompactPath": "responses/compact"
"CompactionRemainingRatio": 0.1,
"ResponsesCompactPath": "responses/compact",
"InitialPrompt": "You are the Meeting Assistant summary agent.\n\nUse the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.\nAll read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.\nThen write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.\nUse read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.\nUse read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.\nUse add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.\nAfter writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.\nUse list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.\nThe summary note should contain concise sections for summary, decisions, open questions, and next steps.\nKeep the output grounded in the source material and explicitly say when a section has no known items."
},
"Api": {
"PublicBaseUrl": "http://localhost:5090"