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,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
@@ -68,6 +69,36 @@ public sealed class AsrDiagnosticEndpointTests
Assert.StartsWith("diarized:endpoint-bytes:", segment.Text, StringComparison.Ordinal);
}
[Fact]
public async Task SpeakerIdentityMergeDiagnosticEndpointUsesRecentDaysParameter()
{
var mergeService = new CapturingSpeakerIdentityMergeService();
await using var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<ISpeakerIdentityMergeService>();
services.AddSingleton<ISpeakerIdentityMergeService>(mergeService);
});
});
using var client = factory.CreateClient();
using var response = await client.PostAsJsonAsync(
"/diagnostics/speaker-identities/merge",
new { recentDays = 7 });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(TimeSpan.FromDays(7), mergeService.RecentIdentityAge);
}
private static WebApplicationFactory<Program> CreateFactory<TProvider>()
where TProvider : class, IStreamingTranscriptionProvider
{
@@ -101,6 +132,7 @@ public sealed class AsrDiagnosticEndpointTests
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var bytes = 0;
@@ -117,6 +149,7 @@ public sealed class AsrDiagnosticEndpointTests
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
@@ -171,4 +204,18 @@ public sealed class AsrDiagnosticEndpointTests
private sealed record AsrDiagnosticResponse(string Path, IReadOnlyList<AsrDiagnosticSegment> Segments);
private sealed record AsrDiagnosticSegment(string Speaker, string Text);
private sealed class CapturingSpeakerIdentityMergeService : ISpeakerIdentityMergeService
{
public TimeSpan? RecentIdentityAge { get; private set; }
public Task<SpeakerIdentityMergeResult> MergeRecentIdentitiesAsync(
TimeSpan? recentIdentityAge,
CancellationToken cancellationToken)
{
RecentIdentityAge = recentIdentityAge;
return Task.FromResult(new SpeakerIdentityMergeResult(0, 0, 0, 0));
}
}
}
@@ -30,6 +30,7 @@ public sealed class AsrDiagnosticTests
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in audio.WithCancellation(cancellationToken))
@@ -77,3 +78,4 @@ public sealed class AsrDiagnosticTests
}
}
}
@@ -47,6 +47,7 @@ public sealed class AzureSpeechRecognitionPipelineTests
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var _ in audio.WithCancellation(cancellationToken))
@@ -60,3 +61,4 @@ public sealed class AzureSpeechRecognitionPipelineTests
}
}
}
@@ -0,0 +1,148 @@
using MeetingAssistant;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class AzureSpeechSpeakerIdentityMatcherTests
{
[Fact]
public void IdentityDiarizationLanguageUsesFirstAutoDetectLanguageWhenConfiguredLanguageIsAuto()
{
Assert.Equal(
"de-DE",
AzureSpeechSpeakerIdentityDiarizationClient.ResolveRecognitionLanguage(new AzureSpeechOptions
{
Language = "auto",
AutoDetectLanguages = ["de-DE", "en-US"]
}));
}
[Fact]
public void IdentityDiarizationLanguageUsesConfiguredConcreteLanguage()
{
Assert.Equal(
"en-US",
AzureSpeechSpeakerIdentityDiarizationClient.ResolveRecognitionLanguage(new AzureSpeechOptions
{
Language = "en-US",
AutoDetectLanguages = ["de-DE"]
}));
}
[Fact]
public async Task MatcherReturnsIdentityWhenAzureDiarizationAssignsUnknownSnippetToKnownSpeaker()
{
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-1", "unknown")
]);
var matcher = CreateMatcher(diarizationClient);
var snippet = CreateWavSnippet();
var match = await matcher.MatchAsync(
new SpeakerIdentityMatchRequest(
"Guest03",
snippet,
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
CancellationToken.None);
Assert.NotNull(match);
Assert.Equal(42, match.IdentityId);
Assert.NotNull(diarizationClient.WavPath);
Assert.False(File.Exists(diarizationClient.WavPath));
}
[Fact]
public async Task MatcherRequiresTwoMatchingKnownSnippetsWhenIdentityHasMultipleSnippets()
{
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known one"),
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-2", "known two"),
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(5), "Guest-1", "unknown")
]);
var matcher = CreateMatcher(diarizationClient);
var snippet = CreateWavSnippet();
var match = await matcher.MatchAsync(
new SpeakerIdentityMatchRequest(
"Guest03",
snippet,
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet, snippet])]),
CancellationToken.None);
Assert.Null(match);
}
[Fact]
public async Task MatcherReturnsNullWhenAzureDiarizationAssignsDifferentSpeakers()
{
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-2", "unknown")
]);
var matcher = CreateMatcher(diarizationClient);
var snippet = CreateWavSnippet();
var match = await matcher.MatchAsync(
new SpeakerIdentityMatchRequest(
"Guest03",
snippet,
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
CancellationToken.None);
Assert.Null(match);
}
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(ISpeakerIdentityDiarizationClient diarizationClient)
{
return new AzureSpeechSpeakerIdentityMatcher(
diarizationClient,
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
SilenceBetweenSnippetsSeconds = 1
}
}),
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
}
private static byte[] CreateWavSnippet()
{
using var stream = new MemoryStream();
using (var writer = new WaveFileWriter(stream, new WaveFormat(16000, 16, 1)))
{
writer.Write(new byte[16000 * 2], 0, 16000 * 2);
}
return stream.ToArray();
}
private sealed class FakeSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
{
private readonly IReadOnlyList<TranscriptionSegment> segments;
public FakeSpeakerIdentityDiarizationClient(IReadOnlyList<TranscriptionSegment> segments)
{
this.segments = segments;
}
public string? WavPath { get; private set; }
public Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
string wavPath,
CancellationToken cancellationToken)
{
WavPath = wavPath;
Assert.True(File.Exists(wavPath));
return Task.FromResult(segments);
}
}
}
@@ -23,7 +23,7 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), CancellationToken.None))
await foreach (var _ in provider.TranscribeAsync(ReadSingleChunk(), SpeechRecognitionPipelineOptions.Default, CancellationToken.None))
{
}
});
@@ -31,6 +31,78 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
Assert.Contains("Azure Speech key is not configured", exception.Message);
}
[Fact]
public void AutoDetectLanguagesAreUsedOnlyWhenLanguageIsAuto()
{
Assert.Equal(
["de-DE", "en-US"],
AzureSpeechStreamingTranscriptionProvider.GetAutoDetectLanguages(new AzureSpeechOptions
{
Language = "auto",
AutoDetectLanguages = ["de-DE", "en-US", "de-DE", " "]
}));
Assert.Empty(AzureSpeechStreamingTranscriptionProvider.GetAutoDetectLanguages(new AzureSpeechOptions
{
Language = "de-DE",
AutoDetectLanguages = ["de-DE", "en-US"]
}));
}
[Fact]
public void NormalizeDictationWordsTrimsAndDeduplicatesPhrases()
{
Assert.Equal(
["PBI", "Product Backlog Item"],
AzureSpeechStreamingTranscriptionProvider.NormalizeDictationWords([
" PBI ",
"pbi",
"",
"Product Backlog Item"
]));
}
[Fact]
public void ResolveKeyFallsBackToUserEnvironment()
{
var keyEnv = $"MEETING_ASSISTANT_AZURE_KEY_{Guid.NewGuid():N}";
try
{
Environment.SetEnvironmentVariable(keyEnv, "user-key", EnvironmentVariableTarget.User);
Assert.Equal("user-key", AzureSpeechStreamingTranscriptionProvider.ResolveKey(new AzureSpeechOptions
{
KeyEnv = keyEnv
}));
}
finally
{
Environment.SetEnvironmentVariable(keyEnv, null, EnvironmentVariableTarget.User);
}
}
[Fact]
public void EndpointDefaultsToUniversalV2SpeechEndpointForRegion()
{
Assert.Equal(
new Uri("wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2"),
AzureSpeechStreamingTranscriptionProvider.GetEndpoint(new AzureSpeechOptions
{
Endpoint = "",
Region = "germanywestcentral"
}));
}
[Theory]
[InlineData(null, "Continuous")]
[InlineData("", "Continuous")]
[InlineData("continuous", "Continuous")]
[InlineData("AtStart", "AtStart")]
public void LanguageIdModeDefaultsToContinuous(string? configured, string expected)
{
Assert.Equal(expected, AzureSpeechStreamingTranscriptionProvider.NormalizeLanguageIdMode(configured));
}
private static async IAsyncEnumerable<AudioChunk> ReadSingleChunk()
{
await Task.Yield();
@@ -0,0 +1,26 @@
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Tests;
public sealed class DictationWordStoreTests
{
[Fact]
public void NormalizeParsesMarkdownBulletsAndDeduplicatesWords()
{
var words = MarkdownDictationWordStore.Normalize([
"- PBI",
"* Product Backlog Item",
"pbi",
"`Velocity Vanguards`",
"",
" \"SEW\" "
]);
Assert.Equal([
"PBI",
"Product Backlog Item",
"SEW",
"Velocity Vanguards"
], words);
}
}
@@ -36,7 +36,7 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
new AudioChunk([3, 0, 4, 0], 16000, 1)
};
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), CancellationToken.None));
var segments = await CollectAsync(provider.TranscribeAsync(ToAsyncEnumerable(chunks), SpeechRecognitionPipelineOptions.Default, CancellationToken.None));
Assert.Equal(new Uri("ws://localhost:10095"), connection.ConnectedEndpoint);
Assert.Equal(2, connection.BinaryMessages.Count);
@@ -75,6 +75,7 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
var segments = await CollectAsync(provider.TranscribeAsync(
ToAsyncEnumerable([new AudioChunk([1, 0], 16000, 1)]),
SpeechRecognitionPipelineOptions.Default,
CancellationToken.None));
Assert.Collection(
@@ -95,6 +96,22 @@ public sealed class FunAsrStreamingTranscriptionProviderTests
});
}
[Fact]
public void BuildHotwordsSerializesDictationWordsWithDeduplication()
{
var hotwords = FunAsrStreamingTranscriptionProvider.BuildHotwords([
"PBI",
"pbi",
"Product Backlog Item",
" "
]);
using var document = JsonDocument.Parse(hotwords);
Assert.Equal(20, document.RootElement.GetProperty("PBI").GetInt32());
Assert.Equal(20, document.RootElement.GetProperty("Product Backlog Item").GetInt32());
Assert.Equal(2, document.RootElement.EnumerateObject().Count());
}
private static async IAsyncEnumerable<AudioChunk> ToAsyncEnumerable(IEnumerable<AudioChunk> chunks)
{
foreach (var chunk in chunks)
@@ -0,0 +1,111 @@
using MeetingAssistant;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryInstructionBuilderTests
{
[Fact]
public async Task BuilderUsesDefaultPromptWhenConfiguredPromptIsBlank()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = CreateArtifacts(root);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
{
Agent = new AgentOptions { InitialPrompt = " " },
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
}));
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
}
[Fact]
public async Task BuilderUsesConfiguredPrompt()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var artifacts = CreateArtifacts(root);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
{
Agent = new AgentOptions { InitialPrompt = "Custom summarizer instructions." },
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
}));
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Equal("Custom summarizer instructions.", instructions);
}
[Fact]
public async Task BuilderAppendsAgentsFilesForBoundProjects()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var artifacts = CreateArtifacts(root);
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, ["Beta", "Alpha", "Missing"]);
await WriteProjectAgentsAsync(projectsRoot, "Alpha", "Alpha instructions.");
await WriteProjectAgentsAsync(projectsRoot, "Beta", "Beta instructions.");
Directory.CreateDirectory(Path.Combine(projectsRoot, "Missing"));
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
{
Agent = new AgentOptions { InitialPrompt = "Base." },
Vault = new VaultOptions { ProjectsFolder = projectsRoot }
}));
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
Assert.Equal(
"""
Base.
---
projects:
# Alpha
Alpha instructions.
# Beta
Beta instructions.
""",
instructions);
}
private static MeetingSessionArtifacts CreateArtifacts(string root)
{
return new MeetingSessionArtifacts(
Path.Combine(root, "Meetings", "Notes", "meeting.md"),
Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
Path.Combine(root, "Meetings", "Summaries", "summary.md"));
}
private static async Task WriteMeetingNoteAsync(string path, IReadOnlyList<string> projects)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var projectLines = projects.Count == 0
? "projects: []"
: "projects:\n" + string.Join('\n', projects.Select(project => $"- {project}"));
await File.WriteAllTextAsync(
path,
$"""
---
title: Meeting
{projectLines}
---
""");
}
private static async Task WriteProjectAgentsAsync(string projectsRoot, string project, string content)
{
var folder = Path.Combine(projectsRoot, project);
Directory.CreateDirectory(folder);
await File.WriteAllTextAsync(Path.Combine(folder, "AGENTS.md"), content);
}
}
@@ -21,5 +21,21 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
Assert.Equal("Review current prototype\nDecide next backend", agenda.Replace("\r\n", "\n", StringComparison.Ordinal));
}
[Fact]
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
{
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
"Marcus.Altmann@sew-eurodrive.de",
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
"Schweigert, Manuel",
" "
]);
Assert.Equal([
"Marcus.Altmann@sew-eurodrive.de",
"Schweigert, Manuel"
], attendees);
}
}
#endif
@@ -1,4 +1,5 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
@@ -10,6 +11,22 @@ namespace MeetingAssistant.Tests;
public sealed class RecordingCoordinatorTests
{
private static async Task WaitUntilAsync(Func<bool> condition)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met.");
}
[Fact]
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
{
@@ -116,6 +133,7 @@ public sealed class RecordingCoordinatorTests
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"))));
await coordinator.StartAsync(CancellationToken.None);
await WaitUntilAsync(() => artifactStore.Agenda == "Review API shape");
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
@@ -125,6 +143,101 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var provider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
"Late Calendar Match",
["Ada"],
"Late agenda",
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
provider);
await coordinator.StartAsync(CancellationToken.None);
Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal);
provider.Release();
await WaitUntilAsync(() =>
noteStore.SavedNote?.Frontmatter.Title == "Late Calendar Match" &&
artifactStore.Agenda == "Late agenda");
Assert.Equal(["Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal("Late agenda", artifactStore.Agenda);
Assert.Equal(DateTimeOffset.Parse("2026-05-19T12:00:00+02:00"), artifactStore.ScheduledEnd);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\fallback-transcript.md"),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
new ThrowingMeetingMetadataProvider());
var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\fallback-meeting.md", status.MeetingNotePath);
Assert.NotNull(artifactStore.CreatedArtifacts);
Assert.StartsWith("Meeting ", noteStore.SavedNote?.Frontmatter.Title, StringComparison.Ordinal);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartContinuesWhenObsidianOpenFails()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\open-fails.md");
var artifactStore = new InMemoryMeetingArtifactStore();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\open-fails-transcript.md"),
noteStore,
new ThrowingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance);
var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\open-fails.md", status.MeetingNotePath);
Assert.NotNull(artifactStore.CreatedArtifacts);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopCompletesAudioCaptureAndDrainsFinalTranscriptionWindow()
{
@@ -187,6 +300,33 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartPassesDictationWordsToSpeechPipeline()
{
var audioSource = new ControlledAudioSource();
var provider = new CapturingPipelineOptionsProvider();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
dictationWordStore: new FixedDictationWordStore(["PBI", "Product Backlog Item"]));
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await provider.OptionsObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(["PBI", "Product Backlog Item"], provider.Options?.DictationWords);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StopRewritesTranscriptWithFinalDiarizedSegmentsWhenAvailable()
{
@@ -236,6 +376,272 @@ public sealed class RecordingCoordinatorTests
});
}
[Fact]
public async Task StopRelabelsFinishedTranscriptWithLearnedSpeakerIdentity()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
var transcriptStore = new InMemoryTranscriptStore();
var noteStore = new InMemoryMeetingNoteStore();
noteStore.UpdateSavedNote(MeetingNoteTemplate.Create(
"Identity Test",
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
attendees: ["Chris"],
transcriptPath: "memory-transcript.md",
assistantContextPath: "memory-context.md",
summaryPath: "memory-summary.md"));
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService("Guest03", "Chris"));
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
}
[Fact]
public async Task StopAddsIdentifiedSpeakerToMeetingAttendees()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService(
"Guest03",
"Chris",
["Chris", "Christopher"]));
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Contains("Chris", attendees);
}
[Fact]
public async Task StopDoesNotDuplicateIdentifiedSpeakerWhenAliasIsAlreadyAttendee()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService(
"Guest03",
"Christopher",
["Christopher", "Chris"]));
await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateAttendees(["Chris <chris@example.com>"]);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal(["Chris <chris@example.com>"], attendees);
}
[Fact]
public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
var provider = new OrderedChunkProvider();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(provider),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Text.Contains("first", StringComparison.Ordinal)));
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
await Task.Delay(50);
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("second");
await coordinator.StopAsync(CancellationToken.None);
Assert.Collection(
transcriptStore.Segments,
first => Assert.Equal("Guest03", first.Speaker),
second => Assert.Equal("Chris", second.Speaker));
Assert.Contains(
transcriptStore.ReplacedSegments,
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
}
[Fact]
public async Task LiveSpeakerIdentityMatchAddsIdentifiedSpeakerToMeetingAttendees()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore();
var speakerIdentification = new BlockingSpeakerIdentificationService("Guest03", "Chris");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Attendees.Contains("Chris") == true);
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Contains("Chris", attendees);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task LiveSpeakerIdentificationRetriesWhenAttendeesChange()
{
var audioSource = new ControlledAudioSource();
var noteStore = new InMemoryMeetingNoteStore();
var speakerIdentification = new CountingSpeakerIdentificationService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new OrderedChunkProvider()),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 1);
noteStore.UpdateAttendees(["Chris"]);
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2);
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(["Chris"], speakerIdentification.Requests.Last().MeetingNote.Frontmatter.Attendees);
}
[Fact]
public async Task LiveSpeakerIdentificationRetriesWhenNewUnmappedSpeakerAppears()
{
var audioSource = new ControlledAudioSource();
var speakerIdentification = new CountingSpeakerIdentificationService();
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new ChangingSpeakerOrderedChunkProvider()),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20)
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 1);
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
await WaitUntilAsync(() => speakerIdentification.Requests.Count == 2);
await coordinator.StopAsync(CancellationToken.None);
var samples = Assert.IsAssignableFrom<IReadOnlyList<SpeakerAudioSample>>(
speakerIdentification.Requests.Last().Samples);
Assert.Contains(samples, sample => sample.Speaker == "Guest04");
}
[Theory]
[InlineData(0, null)]
[InlineData(1, null)]
@@ -538,6 +944,8 @@ public sealed class RecordingCoordinatorTests
public IReadOnlyList<TranscriptionSegment> ReplacedSegments { get; private set; } = [];
public IReadOnlyList<TranscriptionSegment> Segments => segments;
public MeetingNote? MetadataMeetingNote { get; private set; }
public Task ReplaceAsync(
@@ -591,6 +999,11 @@ public sealed class RecordingCoordinatorTests
SavedNote.Frontmatter.Attendees = attendees.ToList();
}
public void UpdateSavedNote(MeetingNote note)
{
SavedNote = note with { Path = notePath };
}
}
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
@@ -634,6 +1047,17 @@ public sealed class RecordingCoordinatorTests
States.Add(state);
return Task.CompletedTask;
}
public Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
Agenda = agenda;
ScheduledEnd = scheduledEnd;
return Task.CompletedTask;
}
}
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
@@ -653,6 +1077,48 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
{
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
throw new InvalidOperationException("metadata unavailable");
}
}
private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider
{
private readonly MeetingMetadata metadata;
private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
public BlockingMeetingMetadataProvider(MeetingMetadata metadata)
{
this.metadata = metadata;
}
public void Release()
{
release.TrySetResult();
}
public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
await release.Task.WaitAsync(cancellationToken);
return metadata;
}
}
private sealed class ThrowingMeetingNoteOpener : IMeetingNoteOpener
{
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
{
throw new InvalidOperationException("obsidian unavailable");
}
}
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly bool succeeded;
@@ -817,6 +1283,181 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class FixedSpeakerIdentificationService : ISpeakerIdentificationService
{
private readonly string sourceSpeaker;
private readonly string targetSpeaker;
private readonly IReadOnlyList<string> acceptedNames;
public FixedSpeakerIdentificationService(
string sourceSpeaker,
string targetSpeaker,
IReadOnlyList<string>? acceptedNames = null)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
this.acceptedNames = acceptedNames ?? [targetSpeaker];
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments.Select(segment => segment.Speaker == sourceSpeaker
? segment with { Speaker = targetSpeaker }
: segment).ToList(),
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
}
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return ProcessFinishedTranscriptAsync(request, cancellationToken);
}
}
private sealed class BlockingSpeakerIdentificationService : ISpeakerIdentificationService
{
private readonly string sourceSpeaker;
private readonly string targetSpeaker;
private readonly IReadOnlyList<string> acceptedNames;
public BlockingSpeakerIdentificationService(
string sourceSpeaker,
string targetSpeaker,
IReadOnlyList<string>? acceptedNames = null)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
this.acceptedNames = acceptedNames ?? [targetSpeaker];
}
public TaskCompletionSource IdentificationObserved { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
IdentificationObserved.TrySetResult();
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments,
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, acceptedNames)]));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
{
public List<SpeakerIdentificationRequest> Requests { get; } = [];
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class FixedDictationWordStore : IDictationWordStore
{
private readonly IReadOnlyList<string> words;
public FixedDictationWordStore(IReadOnlyList<string> words)
{
this.words = words;
}
public Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(words);
}
public Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
{
return Task.FromResult(words);
}
}
private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider
{
public TaskCompletionSource OptionsObserved { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public SpeechRecognitionPipelineOptions? Options { get; private set; }
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
Options = options;
OptionsObserved.TrySetResult();
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
yield return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", "ok");
}
}
}
private sealed class OrderedChunkProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var index = 0;
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
var text = index++ == 0 ? "first" : "second";
yield return new TranscriptionSegment(
TimeSpan.FromSeconds(index - 1),
TimeSpan.FromSeconds(index + 1),
"Guest03",
$"{text} has enough words for identification.");
}
}
}
private sealed class ChangingSpeakerOrderedChunkProvider : IStreamingTranscriptionProvider
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var index = 0;
await foreach (var _ in audio.WithCancellation(cancellationToken))
{
var speaker = index++ == 0 ? "Guest03" : "Guest04";
yield return new TranscriptionSegment(
TimeSpan.FromSeconds(index - 1),
TimeSpan.FromSeconds(index + 1),
speaker,
"This segment has enough words for identification.");
}
}
}
private sealed class ControlledAudioSource : IMeetingAudioSource
{
private readonly Channel<AudioChunk> chunks = Channel.CreateUnbounded<AudioChunk>();
@@ -870,6 +1511,7 @@ public sealed class RecordingCoordinatorTests
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in audio.WithCancellation(cancellationToken))
@@ -884,6 +1526,7 @@ public sealed class RecordingCoordinatorTests
{
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var byteCount = 0;
@@ -915,6 +1558,7 @@ public sealed class RecordingCoordinatorTests
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
waitingForBackend.TrySetResult();
@@ -925,6 +1569,14 @@ public sealed class RecordingCoordinatorTests
}
}
}
private static byte[] Samples(params short[] samples)
{
var bytes = new byte[samples.Length * sizeof(short)];
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
return bytes;
}
}
@@ -0,0 +1,42 @@
using MeetingAssistant.Recording;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class RollingAudioBufferTests
{
[Fact]
public void TryExtractWavReturnsRequestedRangeFromBufferedPcm()
{
var buffer = new RollingAudioBuffer(TimeSpan.FromSeconds(10));
buffer.Append(new AudioChunk(Samples(0, 1, 2, 3), SampleRate: 2, Channels: 1));
buffer.Append(new AudioChunk(Samples(4, 5, 6, 7), SampleRate: 2, Channels: 1));
var wav = buffer.TryExtractWav(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3));
using var reader = new WaveFileReader(new MemoryStream(wav));
var bytes = new byte[reader.Length];
var read = reader.Read(bytes, 0, bytes.Length);
Assert.Equal(bytes.Length, read);
Assert.Equal(Samples(2, 3, 4, 5), bytes);
}
[Fact]
public void TryExtractWavDropsAudioOutsideRetentionWindow()
{
var buffer = new RollingAudioBuffer(TimeSpan.FromSeconds(1));
buffer.Append(new AudioChunk(Samples(0, 1), SampleRate: 1, Channels: 1));
buffer.Append(new AudioChunk(Samples(2, 3), SampleRate: 1, Channels: 1));
var wav = buffer.TryExtractWav(TimeSpan.Zero, TimeSpan.FromSeconds(1));
Assert.Empty(wav);
}
private static byte[] Samples(params short[] samples)
{
var bytes = new byte[samples.Length * sizeof(short)];
Buffer.BlockCopy(samples, 0, bytes, 0, bytes.Length);
return bytes;
}
}
@@ -0,0 +1,198 @@
using MeetingAssistant;
using MeetingAssistant.Speakers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class SpeakerIdentityMergeServiceTests
{
[Fact]
public async Task MergeRecentIdentitiesConfirmsMatchTwiceAndMergesAliasesAndSamples()
{
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
var older = await fixture.AddIdentityAsync(
canonicalName: "Manuel",
aliases: ["M. Schweigert"],
snippets: [[1], [2], [3]],
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
transcriptionCount: 5);
var recent = await fixture.AddIdentityAsync(
canonicalName: "Guest Manuel",
aliases: [],
snippets: [[4], [5]],
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
transcriptionCount: 2);
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
var service = fixture.CreateService();
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
Assert.Equal(1, result.MergedPairs);
Assert.Equal(2, fixture.Matcher.Requests.Count);
Assert.Equal([4], fixture.Matcher.Requests[0].UnknownSnippet);
Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet);
var savedOlder = await fixture.LoadIdentityAsync(older.Id);
Assert.Equal("Manuel", savedOlder.CanonicalName);
Assert.Equal(7, savedOlder.TranscriptionCount);
Assert.True(savedOlder.UpdatedAt > older.UpdatedAt);
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
Assert.Equal(3, savedOlder.Snippets.Count);
Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id));
}
[Fact]
public async Task MergeRecentIdentitiesDoesNotMergeWithoutSecondValidation()
{
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
var older = await fixture.AddIdentityAsync("Manuel", [], [[1]], DateTimeOffset.UtcNow.AddMonths(-2), 5);
var recent = await fixture.AddIdentityAsync("Guest Manuel", [], [[4], [5]], DateTimeOffset.UtcNow.AddDays(-1), 2);
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
var service = fixture.CreateService();
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
Assert.Equal(0, result.MergedPairs);
Assert.True(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id));
}
[Fact]
public async Task MergeRecentIdentitiesIgnoresOldSourceIdentities()
{
await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync();
await fixture.AddIdentityAsync("Older A", [], [[1], [2]], DateTimeOffset.UtcNow.AddMonths(-2), 5);
await fixture.AddIdentityAsync("Older B", [], [[4], [5]], DateTimeOffset.UtcNow.AddMonths(-1), 2);
var service = fixture.CreateService();
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
Assert.Equal(0, result.MergedPairs);
Assert.Empty(fixture.Matcher.Requests);
}
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
{
private readonly string dbPath;
private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context)
{
this.dbPath = dbPath;
Context = context;
}
public SpeakerIdentityDbContext Context { get; }
public QueueingSpeakerIdentityMatcher Matcher { get; } = new();
public static async Task<SpeakerIdentityMergeFixture> CreateAsync()
{
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
return new SpeakerIdentityMergeFixture(dbPath, context);
}
public SpeakerIdentityMergeService CreateService()
{
return new SpeakerIdentityMergeService(
new TestSpeakerIdentityDbContextFactory(dbPath),
Matcher,
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
MatchBatchSize = 6,
MaxSnippetsPerSpeaker = 3
}
}),
NullLogger<SpeakerIdentityMergeService>.Instance);
}
public async Task<SpeakerIdentity> AddIdentityAsync(
string? canonicalName,
IReadOnlyList<string> aliases,
IReadOnlyList<byte[]> snippets,
DateTimeOffset createdAt,
int transcriptionCount)
{
var identity = new SpeakerIdentity
{
CanonicalName = canonicalName,
CreatedAt = createdAt,
UpdatedAt = createdAt,
TranscriptionCount = transcriptionCount,
Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(),
Snippets = snippets
.Select((snippet, index) => new SpeakerSnippet
{
WavBytes = snippet,
CreatedAt = createdAt.AddMinutes(index)
})
.ToList()
};
Context.SpeakerIdentities.Add(identity);
await Context.SaveChangesAsync();
return identity;
}
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
{
Context.ChangeTracker.Clear();
return Context.SpeakerIdentities
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.SingleAsync(identity => identity.Id == id);
}
public async ValueTask DisposeAsync()
{
await Context.DisposeAsync();
if (File.Exists(dbPath))
{
File.Delete(dbPath);
}
}
}
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
{
private readonly string dbPath;
public TestSpeakerIdentityDbContextFactory(string dbPath)
{
this.dbPath = dbPath;
}
public SpeakerIdentityDbContext CreateDbContext()
{
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
}
}
private sealed class QueueingSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
public Queue<int> MatchIdentityIds { get; } = new();
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
public Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
if (!MatchIdentityIds.TryDequeue(out var identityId))
{
return Task.FromResult<SpeakerIdentityMatch?>(null);
}
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(identityId));
}
}
}
@@ -0,0 +1,451 @@
using MeetingAssistant;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class SpeakerIdentityServiceTests
{
[Fact]
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3]);
fixture.Matcher.MatchIdentityId = identity.Id;
var service = fixture.CreateService();
var result = await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Jane", "John", "Chris"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(identity.Id);
Assert.Equal("John", saved.CanonicalName);
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
Assert.Equal(1, saved.TranscriptionCount);
Assert.Equal("John", result.Segments.Single().Speaker);
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
}
[Fact]
public async Task MatchedIdentityUpdatesLastModifiedTimestamp()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var oldTimestamp = DateTimeOffset.UtcNow.AddDays(-30);
var identity = await fixture.AddIdentityAsync(
[],
[1, 2, 3],
"Chris",
transcriptionCount: 5,
updatedAt: oldTimestamp);
fixture.Matcher.MatchIdentityId = identity.Id;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Chris"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(identity.Id);
Assert.True(saved.UpdatedAt > oldTimestamp);
}
[Fact]
public async Task MatchedIdentityWithEmptyCandidateIntersectionResetsCandidatesAndReplacesOldestSnippet()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => options.MaxSnippetsPerSpeaker = 1);
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [9, 9, 9]);
fixture.Matcher.MatchIdentityId = identity.Id;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Jane", "Chris"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(identity.Id);
Assert.Null(saved.CanonicalName);
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
}
[Fact]
public async Task MatchedUnnamedIdentityTreatsAliasesAsCandidateMatches()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync(["Michael"], [1, 2, 3], aliases: ["Mike"]);
fixture.Matcher.MatchIdentityId = identity.Id;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Mike", "Jane"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(identity.Id);
Assert.Equal("Michael", saved.CanonicalName);
Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name));
}
[Fact]
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
fixture.Matcher.MatchIdentityId = chris.Id;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["John", "Mike", "Chris"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown one"),
new TranscriptionSegment(TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(12), "Guest02", "unknown two")
]),
CancellationToken.None);
var learned = await fixture.Context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Where(identity => identity.CanonicalName == null)
.OrderBy(identity => identity.Id)
.ToListAsync();
Assert.Equal(2, learned.Count);
Assert.All(learned, identity =>
{
Assert.NotEqual(default, identity.CreatedAt);
Assert.NotEqual(default, identity.UpdatedAt);
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
});
}
[Fact]
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", transcriptionCount: 5, aliases: ["Mike"]);
fixture.Matcher.MatchIdentityId = michael.Id;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Mike", "Jane"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest03", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest01", "unknown")
]),
CancellationToken.None);
var learned = await fixture.Context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Where(identity => identity.CanonicalName == null)
.SingleAsync();
Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name));
}
[Fact]
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
fixture.Matcher.MatchIdentityId = chris.Id;
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest03", "hello from the live buffer");
var result = await service.IdentifyKnownSpeakersAsync(
fixture.CreateRequest(
["Chris"],
[segment],
[new SpeakerAudioSample("Guest03", segment, [4, 5, 6], 90)]),
CancellationToken.None);
Assert.Equal("Chris", result.Segments.Single().Speaker);
Assert.Equal([4, 5, 6], fixture.Matcher.LastUnknownSnippet);
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
}
[Fact]
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], transcriptionCount: 5);
fixture.Matcher.MatchIdentityId = identity.Id;
var service = fixture.CreateService();
var result = await service.IdentifyKnownSpeakersAsync(
fixture.CreateRequest(
["Jane", "John", "Chris"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(identity.Id);
Assert.Null(saved.CanonicalName);
Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
Assert.Equal(5, saved.TranscriptionCount);
Assert.Single(saved.Snippets);
Assert.Equal("Guest01", result.Segments.Single().Speaker);
Assert.Empty(result.SpeakerMappings);
}
[Fact]
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", transcriptionCount: 99);
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", transcriptionCount: 1, aliases: ["Mike"]);
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", transcriptionCount: 2);
var service = fixture.CreateService();
await service.IdentifyKnownSpeakersAsync(
fixture.CreateRequest(
["Jane", "Mike"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList();
Assert.Equal([displayNameMatch.Id, aliasMatch.Id, unrelated.Id], requestedIds);
}
[Fact]
public async Task SpeakerMatchingFiltersInactiveNonAttendeeIdentitiesAndLimitsCandidates()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
{
options.MaxMatchCandidates = 2;
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
});
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", transcriptionCount: 50);
await fixture.AddIdentityAsync([], [2], "Stale High", transcriptionCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", transcriptionCount: 5);
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", transcriptionCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
var service = fixture.CreateService();
await service.IdentifyKnownSpeakersAsync(
fixture.CreateRequest(
["Former"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "hello")]),
CancellationToken.None);
var requestedIds = fixture.Matcher.Requests.Single().Candidates.Select(candidate => candidate.IdentityId).ToList();
Assert.Equal([staleAttendee.Id, activeHigh.Id], requestedIds);
Assert.DoesNotContain(activeLow.Id, requestedIds);
}
[Fact]
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
{
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var createdAt = DateTimeOffset.Parse("2026-05-01T10:00:00+00:00");
await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerIdentities" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT,
"CanonicalName" TEXT NULL,
"TranscriptionCount" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL
);
""");
await context.Database.ExecuteSqlInterpolatedAsync(
$"""
INSERT INTO "SpeakerIdentities" ("CanonicalName", "TranscriptionCount", "CreatedAt")
VALUES ('Legacy Speaker', 3, {createdAt});
""");
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
context.ChangeTracker.Clear();
var saved = await context.SpeakerIdentities.SingleAsync();
Assert.Equal(createdAt, saved.UpdatedAt);
await context.DisposeAsync();
File.Delete(dbPath);
}
private sealed class SpeakerIdentityFixture : IAsyncDisposable
{
private readonly string dbPath;
private readonly SpeakerIdentificationOptions options;
private SpeakerIdentityFixture(
string dbPath,
SpeakerIdentityDbContext context,
SpeakerIdentificationOptions options)
{
this.dbPath = dbPath;
Context = context;
this.options = options;
}
public SpeakerIdentityDbContext Context { get; }
public FakeSpeakerIdentityMatcher Matcher { get; } = new();
public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new();
public static async Task<SpeakerIdentityFixture> CreateAsync(
Action<SpeakerIdentificationOptions>? configureOptions = null)
{
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
var options = new SpeakerIdentificationOptions
{
DatabasePath = dbPath,
MaxSnippetsPerSpeaker = 3,
MatchBatchSize = 6
};
configureOptions?.Invoke(options);
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
return new SpeakerIdentityFixture(dbPath, context, options);
}
public SpeakerIdentityService CreateService()
{
return new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
SnippetExtractor,
Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance);
}
public SpeakerIdentificationRequest CreateRequest(
IReadOnlyList<string> attendees,
IReadOnlyList<TranscriptionSegment> segments,
IReadOnlyList<SpeakerAudioSample>? samples = null)
{
return new SpeakerIdentificationRequest(
"test.wav",
MeetingNoteTemplate.Create(
"Test Meeting",
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
attendees: attendees,
transcriptPath: "transcript.md",
assistantContextPath: "context.md",
summaryPath: "summary.md"),
segments,
samples);
}
public async Task<SpeakerIdentity> AddIdentityAsync(
IReadOnlyList<string> candidates,
byte[] snippet,
string? canonicalName = null,
int transcriptionCount = 0,
IReadOnlyList<string>? aliases = null,
DateTimeOffset? updatedAt = null)
{
var timestamp = updatedAt ?? DateTimeOffset.UtcNow;
var identity = new SpeakerIdentity
{
CanonicalName = canonicalName,
TranscriptionCount = transcriptionCount,
CreatedAt = timestamp,
UpdatedAt = timestamp,
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
CandidateNames = candidates.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList(),
Snippets =
[
new SpeakerSnippet
{
WavBytes = snippet,
CreatedAt = timestamp.AddMinutes(-10)
}
]
};
Context.SpeakerIdentities.Add(identity);
await Context.SaveChangesAsync();
return identity;
}
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
{
Context.ChangeTracker.Clear();
return Context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.SingleAsync(identity => identity.Id == id);
}
public async ValueTask DisposeAsync()
{
await Context.DisposeAsync();
if (File.Exists(dbPath))
{
File.Delete(dbPath);
}
}
}
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
{
private readonly string dbPath;
public TestSpeakerIdentityDbContextFactory(string dbPath)
{
this.dbPath = dbPath;
}
public SpeakerIdentityDbContext CreateDbContext()
{
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
}
}
private sealed class FakeSpeakerSnippetExtractor : ISpeakerSnippetExtractor
{
public int ExtractCalls { get; private set; }
public Task<byte[]> ExtractSnippetAsync(
string audioPath,
IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken)
{
ExtractCalls++;
return Task.FromResult<byte[]>([7, 8, 9]);
}
}
private sealed class FakeSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
private bool hasMatched;
public int? MatchIdentityId { get; set; }
public byte[]? LastUnknownSnippet { get; private set; }
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
public Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken)
{
LastUnknownSnippet = request.UnknownSnippet;
Requests.Add(request);
if (hasMatched || MatchIdentityId is null)
{
return Task.FromResult<SpeakerIdentityMatch?>(null);
}
hasMatched = true;
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
}
}
}
@@ -68,6 +68,13 @@ public sealed class SpeechRecognitionPipelineHostedServiceTests
}
public Task InitializeAsync(CancellationToken cancellationToken)
{
return InitializeAsync(SpeechRecognitionPipelineOptions.Default, cancellationToken);
}
public Task InitializeAsync(
SpeechRecognitionPipelineOptions options,
CancellationToken cancellationToken)
{
InitializeCount++;
initialized.TrySetResult();
+34
View File
@@ -0,0 +1,34 @@
using MeetingAssistant;
namespace MeetingAssistant.Tests;
public sealed class VaultPathTests
{
[Fact]
public void ResolveUsesVaultBaseFolderForRelativePaths()
{
var vault = new VaultOptions
{
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
};
var resolved = VaultPath.Resolve(vault, @"Meetings\Transcripts");
Assert.Equal(
@"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta\Meetings\Transcripts",
resolved);
}
[Fact]
public void ResolveKeepsAbsolutePathsIndependentFromVaultBaseFolder()
{
var vault = new VaultOptions
{
BaseFolder = @"C:\Users\masc3\OpenCloud\Persönlich\Vault\Exxeta"
};
var resolved = VaultPath.Resolve(vault, @"C:\Other\Path");
Assert.Equal(@"C:\Other\Path", resolved);
}
}