From a72cda0c033e618a2d7fc2d47883442fb4f6c611 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Thu, 28 May 2026 12:00:24 +0200 Subject: [PATCH] Harden speaker identity samples --- .../AzureSpeechSpeakerIdentityMatcherTests.cs | 47 +++- ...nnoteSpeakerIdentityMatchValidatorTests.cs | 137 ++++++++++ .../RecordingCoordinatorTests.cs | 15 +- .../SpeakerAudioSampleCollectorTests.cs | 76 ++++++ .../SpeakerIdentityServiceTests.cs | 167 +++++++++++- MeetingAssistant/MeetingAssistantOptions.cs | 21 ++ MeetingAssistant/Program.cs | 1 + .../Recording/MeetingRecordingCoordinator.cs | 6 +- .../Recording/SpeakerAudioSampleCollector.cs | 93 ++++++- .../AzureSpeechSpeakerIdentityMatcher.cs | 137 +++------- .../NoopSpeakerIdentityMatchValidator.cs | 18 ++ .../PyannoteSpeakerIdentityMatchValidator.cs | 237 ++++++++++++++++++ .../Speakers/SpeakerCompositeWav.cs | 107 ++++++++ .../Speakers/SpeakerIdentificationModels.cs | 17 ++ MeetingAssistant/Speakers/SpeakerIdentity.cs | 7 +- .../Speakers/SpeakerIdentityService.cs | 19 +- .../Speakers/SpeakerSampleSpanSelector.cs | 79 ++++++ MeetingAssistant/appsettings.json | 22 +- README.md | 21 ++ .../proposal.md | 20 ++ .../specs/meeting-transcription/spec.md | 52 ++++ .../tasks.md | 24 ++ openspec/specs/meeting-transcription/spec.md | 51 ++++ 23 files changed, 1251 insertions(+), 123 deletions(-) create mode 100644 MeetingAssistant.Tests/PyannoteSpeakerIdentityMatchValidatorTests.cs create mode 100644 MeetingAssistant.Tests/SpeakerAudioSampleCollectorTests.cs create mode 100644 MeetingAssistant/Speakers/NoopSpeakerIdentityMatchValidator.cs create mode 100644 MeetingAssistant/Speakers/PyannoteSpeakerIdentityMatchValidator.cs create mode 100644 MeetingAssistant/Speakers/SpeakerCompositeWav.cs create mode 100644 MeetingAssistant/Speakers/SpeakerSampleSpanSelector.cs create mode 100644 openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/proposal.md create mode 100644 openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/specs/meeting-transcription/spec.md create mode 100644 openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/tasks.md diff --git a/MeetingAssistant.Tests/AzureSpeechSpeakerIdentityMatcherTests.cs b/MeetingAssistant.Tests/AzureSpeechSpeakerIdentityMatcherTests.cs index 1364f9b..86fcb87 100644 --- a/MeetingAssistant.Tests/AzureSpeechSpeakerIdentityMatcherTests.cs +++ b/MeetingAssistant.Tests/AzureSpeechSpeakerIdentityMatcherTests.cs @@ -118,9 +118,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests Assert.Null(match); } + [Fact] + public async Task MatcherReturnsNullWhenSecondaryValidatorRejectsPrimaryMatch() + { + 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 validator = new RejectingSpeakerIdentityMatchValidator(); + var matcher = CreateMatcher(diarizationClient, validator: validator); + var snippet = CreateWavSnippet(); + + var match = await matcher.MatchAsync( + new SpeakerIdentityMatchRequest( + "Guest03", + snippet, + [new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]), + CancellationToken.None); + + Assert.Null(match); + Assert.Equal(1, validator.MatchValidationCount); + } + private static AzureSpeechSpeakerIdentityMatcher CreateMatcher( ISpeakerIdentityDiarizationClient diarizationClient, - TimeSpan? matchTimeout = null) + TimeSpan? matchTimeout = null, + ISpeakerIdentityMatchValidator? validator = null) { return new AzureSpeechSpeakerIdentityMatcher( diarizationClient, @@ -132,7 +156,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1) } }), - NullLogger.Instance); + NullLogger.Instance, + validator ?? new NoopSpeakerIdentityMatchValidator()); } private static byte[] CreateWavSnippet() @@ -177,4 +202,22 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests return []; } } + + private sealed class RejectingSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator + { + public int MatchValidationCount { get; private set; } + + public Task ValidateSampleAsync(byte[] wavBytes, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task ValidateMatchAsync( + SpeakerIdentityMatchValidationRequest request, + CancellationToken cancellationToken) + { + MatchValidationCount++; + return Task.FromResult(false); + } + } } diff --git a/MeetingAssistant.Tests/PyannoteSpeakerIdentityMatchValidatorTests.cs b/MeetingAssistant.Tests/PyannoteSpeakerIdentityMatchValidatorTests.cs new file mode 100644 index 0000000..b025d58 --- /dev/null +++ b/MeetingAssistant.Tests/PyannoteSpeakerIdentityMatchValidatorTests.cs @@ -0,0 +1,137 @@ +using MeetingAssistant; +using MeetingAssistant.Recording; +using MeetingAssistant.Speakers; +using MeetingAssistant.Transcription; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NAudio.Wave; + +namespace MeetingAssistant.Tests; + +public sealed class PyannoteSpeakerIdentityMatchValidatorTests +{ + [Fact] + public async Task ValidatorRejectsSampleWhenPyannoteReportsMultipleSpeakers() + { + var commandRunner = new CapturingCommandRunner( + """ + __MEETING_ASSISTANT_PYANNOTE_JSON_START__ + [{"start":0.0,"end":10.0,"speaker":"SPEAKER_00"},{"start":10.0,"end":20.0,"speaker":"SPEAKER_01"}] + __MEETING_ASSISTANT_PYANNOTE_JSON_END__ + """); + var validator = CreateValidator(commandRunner); + + var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(20)), CancellationToken.None); + + Assert.False(valid); + } + + [Fact] + public async Task ValidatorAcceptsMatchWhenPyannoteAssignsKnownAndUnknownSamplesToSameSpeaker() + { + var commandRunner = new CapturingCommandRunner( + """ + __MEETING_ASSISTANT_PYANNOTE_JSON_START__ + [{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"},{"start":2.0,"end":3.0,"speaker":"SPEAKER_00"}] + __MEETING_ASSISTANT_PYANNOTE_JSON_END__ + """); + var validator = CreateValidator(commandRunner); + var wav = CreateWav(TimeSpan.FromSeconds(1)); + + var valid = await validator.ValidateMatchAsync( + new SpeakerIdentityMatchValidationRequest("Guest03", 42, wav, [wav]), + CancellationToken.None); + + Assert.True(valid); + } + + [Fact] + public async Task DisabledValidatorDoesNotRunPyannote() + { + var commandRunner = new CapturingCommandRunner(""); + var validator = CreateValidator(commandRunner, enabled: false); + + var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(1)), CancellationToken.None); + + Assert.True(valid); + Assert.Empty(commandRunner.Commands); + } + + private static PyannoteSpeakerIdentityMatchValidator CreateValidator( + CapturingCommandRunner commandRunner, + bool enabled = true) + { + var finalizer = new PyannoteTranscriptFinalizer( + commandRunner, + Options.Create(new MeetingAssistantOptions()), + NullLogger.Instance); + return new PyannoteSpeakerIdentityMatchValidator( + finalizer, + Options.Create(new MeetingAssistantOptions + { + SpeakerIdentification = new SpeakerIdentificationOptions + { + PyannoteValidation = new SpeakerIdentityPyannoteValidationOptions + { + Enabled = enabled, + MinimumSingleSpeakerCoverage = 0.90, + MinimumMatchingKnownSnippetRatio = 1, + Diarization = new PyannoteDiarizationOptions + { + Enabled = true, + BuildImage = false, + DockerCommand = "docker", + Image = "meeting-assistant-pyannote:local", + ModelsFolder = Path.Combine( + Path.GetTempPath(), + "meeting-assistant-tests", + Guid.NewGuid().ToString("N"), + "models"), + Token = "hf_test", + TokenEnv = "", + CommandTimeout = TimeSpan.FromMinutes(1), + AlignmentMode = PyannoteAlignmentMode.PyannoteTurns + } + } + } + }), + NullLogger.Instance); + } + + private static byte[] CreateWav(TimeSpan duration) + { + const int sampleRate = 16000; + using var stream = new MemoryStream(); + using (var writer = new WaveFileWriter(stream, new WaveFormat(sampleRate, 16, 1))) + { + var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * sizeof(short))]; + writer.Write(bytes, 0, bytes.Length); + } + + return stream.ToArray(); + } + + private sealed class CapturingCommandRunner : ICommandRunner + { + private readonly string output; + + public CapturingCommandRunner(string output) + { + this.output = output; + } + + public IReadOnlyList Commands { get; private set; } = []; + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken, + IReadOnlyDictionary? environment = null) + { + Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList(); + return Task.FromResult(new CommandResult(0, output, "")); + } + } + + private sealed record CapturedCommand(string FileName, IReadOnlyList Arguments); +} diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index 7168e30..013388c 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -984,7 +984,8 @@ public sealed class RecordingCoordinatorTests SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, - Interval = TimeSpan.FromMilliseconds(10) + Interval = TimeSpan.FromMilliseconds(10), + MinimumSampleSpeechDuration = TimeSpan.Zero } }), NullLogger.Instance, @@ -1031,7 +1032,8 @@ public sealed class RecordingCoordinatorTests SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, - Interval = TimeSpan.FromMilliseconds(10) + Interval = TimeSpan.FromMilliseconds(10), + MinimumSampleSpeechDuration = TimeSpan.Zero } }), NullLogger.Instance, @@ -1066,7 +1068,8 @@ public sealed class RecordingCoordinatorTests SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, - Interval = TimeSpan.FromMilliseconds(20) + Interval = TimeSpan.FromMilliseconds(20), + MinimumSampleSpeechDuration = TimeSpan.Zero } }), NullLogger.Instance, @@ -1103,7 +1106,8 @@ public sealed class RecordingCoordinatorTests SpeakerIdentification = new SpeakerIdentificationOptions { InitialDelay = TimeSpan.Zero, - Interval = TimeSpan.FromMilliseconds(20) + Interval = TimeSpan.FromMilliseconds(20), + MinimumSampleSpeechDuration = TimeSpan.Zero } }), NullLogger.Instance, @@ -1672,6 +1676,7 @@ public sealed class RecordingCoordinatorTests values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true"; values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00"; values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050"; + values["MeetingAssistant:SpeakerIdentification:MinimumSampleSpeechDuration"] = "00:00:00"; } var configuration = new ConfigurationBuilder() @@ -1708,7 +1713,7 @@ public sealed class RecordingCoordinatorTests var deadline = DateTimeOffset.UtcNow.AddSeconds(5); while (DateTimeOffset.UtcNow < deadline) { - if (segments.Any(segment => segment.Text?.Contains(text, StringComparison.Ordinal) == true)) + if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true)) { return; } diff --git a/MeetingAssistant.Tests/SpeakerAudioSampleCollectorTests.cs b/MeetingAssistant.Tests/SpeakerAudioSampleCollectorTests.cs new file mode 100644 index 0000000..d80e5c2 --- /dev/null +++ b/MeetingAssistant.Tests/SpeakerAudioSampleCollectorTests.cs @@ -0,0 +1,76 @@ +using MeetingAssistant.Recording; +using MeetingAssistant.Transcription; +using NAudio.Wave; + +namespace MeetingAssistant.Tests; + +public sealed class SpeakerAudioSampleCollectorTests +{ + [Fact] + public void CollectorWaitsForConfiguredUninterruptedSpeechDuration() + { + var collector = new SpeakerAudioSampleCollector( + TimeSpan.FromMinutes(2), + maxSamplesPerSpeaker: 3, + minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30), + maximumSegmentGap: TimeSpan.FromSeconds(1)); + collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35))); + + collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five.")); + collector.TryAdd(Segment(12, 24, "Guest01", "six seven eight nine ten.")); + + Assert.Empty(collector.Snapshot()); + + collector.TryAdd(Segment(24, 31, "Guest01", "eleven twelve thirteen fourteen fifteen.")); + + var sample = Assert.Single(collector.Snapshot()); + Assert.Equal("Guest01", sample.Speaker); + Assert.Equal(TimeSpan.Zero, sample.Segment.Start); + Assert.Equal(TimeSpan.FromSeconds(31), sample.Segment.End); + Assert.True(ReadDuration(sample.WavBytes) >= TimeSpan.FromSeconds(30)); + } + + [Fact] + public void CollectorDoesNotCombineSpeechAcrossDifferentSpeakerInterruption() + { + var collector = new SpeakerAudioSampleCollector( + TimeSpan.FromMinutes(2), + maxSamplesPerSpeaker: 3, + minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30), + maximumSegmentGap: TimeSpan.FromSeconds(1)); + collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(50))); + + collector.TryAdd(Segment(0, 20, "Guest01", "one two three four five.")); + collector.TryAdd(Segment(20, 22, "Guest02", "interrupting now.")); + collector.TryAdd(Segment(22, 35, "Guest01", "six seven eight nine ten.")); + + Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01"); + } + + private static TranscriptionSegment Segment( + double start, + double end, + string speaker, + string text) + { + return new TranscriptionSegment( + TimeSpan.FromSeconds(start), + TimeSpan.FromSeconds(end), + speaker, + text); + } + + private static AudioChunk CreateAudio(TimeSpan duration) + { + const int sampleRate = 16000; + const int channels = 1; + var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * channels * sizeof(short))]; + return new AudioChunk(bytes, sampleRate, channels); + } + + private static TimeSpan ReadDuration(byte[] wavBytes) + { + using var reader = new WaveFileReader(new MemoryStream(wavBytes)); + return reader.TotalTime; + } +} diff --git a/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs b/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs index 0d38e34..4fb436f 100644 --- a/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs +++ b/MeetingAssistant.Tests/SpeakerIdentityServiceTests.cs @@ -199,6 +199,60 @@ public sealed class SpeakerIdentityServiceTests Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath)); } + [Fact] + public async Task UnmatchedSpeakerIsNotLearnedWhenSecondaryValidationRejectsSample() + { + await using var fixture = await SpeakerIdentityFixture.CreateAsync(); + fixture.MatchValidator.SampleIsValid = false; + var service = fixture.CreateService(); + + await service.ProcessFinishedTranscriptAsync( + fixture.CreateRequest( + ["Manuel"], + [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]), + CancellationToken.None); + + Assert.Empty(fixture.Context.SpeakerIdentities); + } + + [Fact] + public async Task FinishedSnippetExtractionWaitsForMinimumContinuousSpeechDuration() + { + await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => + options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30)); + var service = fixture.CreateService(); + + await service.ProcessFinishedTranscriptAsync( + fixture.CreateRequest( + ["Manuel"], + [new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "short")]), + CancellationToken.None); + + Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls); + Assert.Empty(fixture.Context.SpeakerIdentities); + } + + [Fact] + public async Task FinishedSnippetExtractionDoesNotCombineAcrossSpeakerInterruption() + { + await using var fixture = await SpeakerIdentityFixture.CreateAsync(options => + options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30)); + var service = fixture.CreateService(); + + await service.ProcessFinishedTranscriptAsync( + fixture.CreateRequest( + ["Manuel"], + [ + new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "first"), + new TranscriptionSegment(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(22), "Guest02", "interrupting"), + new TranscriptionSegment(TimeSpan.FromSeconds(22), TimeSpan.FromSeconds(35), "Guest01", "second") + ]), + CancellationToken.None); + + Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls); + Assert.Empty(fixture.Context.SpeakerIdentities); + } + [Fact] public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile() { @@ -450,6 +504,87 @@ public sealed class SpeakerIdentityServiceTests File.Delete(dbPath); } + [Fact] + public async Task SpeakerOverrideCreatesIdentityWhenLegacyTranscriptionCountHasNoDatabaseDefault() + { + var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() + .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, + "UpdatedAt" TEXT NOT NULL + ); + """); + await context.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE "SpeakerCandidateNames" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerCandidateNames" PRIMARY KEY AUTOINCREMENT, + "SpeakerIdentityId" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + CONSTRAINT "FK_SpeakerCandidateNames_SpeakerIdentities_SpeakerIdentityId" + FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE + ); + """); + await context.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE "SpeakerSnippets" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerSnippets" PRIMARY KEY AUTOINCREMENT, + "SpeakerIdentityId" INTEGER NOT NULL, + "WavBytes" BLOB NOT NULL, + "CreatedAt" TEXT NOT NULL, + CONSTRAINT "FK_SpeakerSnippets_SpeakerIdentities_SpeakerIdentityId" + FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE + ); + """); + var options = new SpeakerIdentificationOptions + { + DatabasePath = dbPath, + MinimumSampleSpeechDuration = TimeSpan.Zero + }; + var service = new SpeakerIdentityService( + new TestSpeakerIdentityDbContextFactory(dbPath), + new FakeSpeakerSnippetExtractor(), + new FakeSpeakerIdentityMatcher(), + Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }), + NullLogger.Instance, + new FakeSpeakerIdentityMatchValidator()); + var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Daniel"); + var root = Path.GetDirectoryName(dbPath)!; + var transcriptPath = Path.Combine(root, "transcript.md"); + await File.WriteAllTextAsync(transcriptPath, "Transcript"); + var request = new SpeakerIdentificationRequest( + "test.wav", + new MeetingNote( + Path.Combine(root, "meeting.md"), + new MeetingNoteFrontmatter + { + Title = "Test Meeting", + StartTime = DateTimeOffset.Parse("2026-05-28T10:00:00+02:00"), + Attendees = ["Hecht, Daniel"], + Transcript = transcriptPath, + AssistantContext = Path.Combine(root, "context.md"), + Summary = Path.Combine(root, "summary.md") + }, + ""), + [segment], + [new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]); + + await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Hecht, Daniel", CancellationToken.None); + + context.ChangeTracker.Clear(); + var saved = await context.SpeakerIdentities.SingleAsync(); + Assert.Equal("Hecht, Daniel", saved.CanonicalName); + Assert.Equal(0, saved.TranscriptionCount); + File.Delete(dbPath); + } + private sealed class SpeakerIdentityFixture : IAsyncDisposable { private readonly string tempDirectory; @@ -472,6 +607,8 @@ public sealed class SpeakerIdentityServiceTests public FakeSpeakerIdentityMatcher Matcher { get; } = new(); + public FakeSpeakerIdentityMatchValidator MatchValidator { get; } = new(); + public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new(); public static async Task CreateAsync( @@ -484,7 +621,8 @@ public sealed class SpeakerIdentityServiceTests { DatabasePath = dbPath, MaxSnippetsPerSpeaker = 3, - MatchBatchSize = 6 + MatchBatchSize = 6, + MinimumSampleSpeechDuration = TimeSpan.Zero }; configureOptions?.Invoke(options); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() @@ -501,7 +639,8 @@ public sealed class SpeakerIdentityServiceTests SnippetExtractor, Matcher, Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }), - NullLogger.Instance); + NullLogger.Instance, + MatchValidator); } public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer() @@ -627,6 +766,11 @@ public sealed class SpeakerIdentityServiceTests IReadOnlyList speakerSegments, CancellationToken cancellationToken) { + if (speakerSegments.Count == 0) + { + return Task.FromResult([]); + } + ExtractCalls++; return Task.FromResult([7, 8, 9]); } @@ -657,4 +801,23 @@ public sealed class SpeakerIdentityServiceTests return Task.FromResult(new SpeakerIdentityMatch(MatchIdentityId.Value)); } } + + private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator + { + public bool SampleIsValid { get; set; } = true; + + public Task ValidateSampleAsync( + byte[] wavBytes, + CancellationToken cancellationToken) + { + return Task.FromResult(SampleIsValid); + } + + public Task ValidateMatchAsync( + SpeakerIdentityMatchValidationRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + } } diff --git a/MeetingAssistant/MeetingAssistantOptions.cs b/MeetingAssistant/MeetingAssistantOptions.cs index 3fa8056..6bbdf3f 100644 --- a/MeetingAssistant/MeetingAssistantOptions.cs +++ b/MeetingAssistant/MeetingAssistantOptions.cs @@ -293,6 +293,10 @@ public sealed class SpeakerIdentificationOptions public int MaxSnippetsPerSpeaker { get; set; } = 3; + public TimeSpan MinimumSampleSpeechDuration { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan MaximumSampleSegmentGap { get; set; } = TimeSpan.FromSeconds(1); + public double SilenceBetweenSnippetsSeconds { get; set; } = 1; public TimeSpan LiveSampleBufferDuration { get; set; } = TimeSpan.FromMinutes(10); @@ -302,6 +306,23 @@ public sealed class SpeakerIdentificationOptions public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3); public AzureSpeechOptions AzureSpeech { get; set; } = new(); + + public SpeakerIdentityPyannoteValidationOptions PyannoteValidation { get; set; } = new(); +} + +public sealed class SpeakerIdentityPyannoteValidationOptions +{ + public bool Enabled { get; set; } + + public double MinimumSingleSpeakerCoverage { get; set; } = 0.90; + + public double MinimumMatchingKnownSnippetRatio { get; set; } = 0.50; + + public PyannoteDiarizationOptions Diarization { get; set; } = new() + { + Enabled = true, + AlignmentMode = PyannoteAlignmentMode.PyannoteTurns + }; } public sealed class AgentOptions diff --git a/MeetingAssistant/Program.cs b/MeetingAssistant/Program.cs index 86b3b62..3db2f3d 100644 --- a/MeetingAssistant/Program.cs +++ b/MeetingAssistant/Program.cs @@ -50,6 +50,7 @@ builder.Services.AddDbContextFactory((services, dbOpti }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index 5740a17..c32b39e 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -1334,7 +1334,11 @@ public sealed class MeetingRecordingCoordinator Options = options; LaunchProfileName = launchProfileName; pipelines.Add(pipeline); - speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples); + speakerSampleCollector = new SpeakerAudioSampleCollector( + liveSampleBufferDuration, + maxSpeakerSamples, + options.SpeakerIdentification.MinimumSampleSpeechDuration, + options.SpeakerIdentification.MaximumSampleSegmentGap); } public CancellationTokenSource CaptureCancellationSource { get; } diff --git a/MeetingAssistant/Recording/SpeakerAudioSampleCollector.cs b/MeetingAssistant/Recording/SpeakerAudioSampleCollector.cs index d47be6f..9eb82e5 100644 --- a/MeetingAssistant/Recording/SpeakerAudioSampleCollector.cs +++ b/MeetingAssistant/Recording/SpeakerAudioSampleCollector.cs @@ -9,11 +9,33 @@ internal sealed class SpeakerAudioSampleCollector private readonly RollingAudioBuffer audioBuffer; private readonly Dictionary> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase); private readonly int maxSamplesPerSpeaker; + private readonly TimeSpan minimumUninterruptedSpeechDuration; + private readonly TimeSpan maximumSegmentGap; + private PendingSpeakerSpan? pendingSpan; public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker) + : this( + bufferDuration, + maxSamplesPerSpeaker, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(1)) + { + } + + public SpeakerAudioSampleCollector( + TimeSpan bufferDuration, + int maxSamplesPerSpeaker, + TimeSpan minimumUninterruptedSpeechDuration, + TimeSpan maximumSegmentGap) { audioBuffer = new RollingAudioBuffer(bufferDuration); this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker); + this.minimumUninterruptedSpeechDuration = minimumUninterruptedSpeechDuration > TimeSpan.Zero + ? minimumUninterruptedSpeechDuration + : TimeSpan.Zero; + this.maximumSegmentGap = maximumSegmentGap >= TimeSpan.Zero + ? maximumSegmentGap + : TimeSpan.Zero; } public void AppendAudio(AudioChunk chunk) @@ -26,6 +48,7 @@ internal sealed class SpeakerAudioSampleCollector lock (gate) { samplesBySpeaker.Clear(); + pendingSpan = null; audioBuffer.Reset(); } } @@ -37,25 +60,31 @@ internal sealed class SpeakerAudioSampleCollector return; } - var score = Score(segment); + TranscriptionSegment sampleSegment; + lock (gate) + { + sampleSegment = ExtendPendingSpan(segment); + } + + var score = Score(sampleSegment, minimumUninterruptedSpeechDuration); if (score <= 0) { return; } - var wavBytes = audioBuffer.TryExtractWav(segment.Start, segment.End); + var wavBytes = audioBuffer.TryExtractWav(sampleSegment.Start, sampleSegment.End); if (wavBytes.Length == 0) { return; } - var sample = new SpeakerAudioSample(segment.Speaker, segment, wavBytes, score); + var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score); lock (gate) { - if (!samplesBySpeaker.TryGetValue(segment.Speaker, out var samples)) + if (!samplesBySpeaker.TryGetValue(sampleSegment.Speaker, out var samples)) { samples = []; - samplesBySpeaker[segment.Speaker] = samples; + samplesBySpeaker[sampleSegment.Speaker] = samples; } samples.Add(sample); @@ -86,10 +115,29 @@ internal sealed class SpeakerAudioSampleCollector !string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase); } - private static double Score(TranscriptionSegment segment) + private TranscriptionSegment ExtendPendingSpan(TranscriptionSegment segment) + { + if (pendingSpan is null || + !SpeakerSampleSpanSelector.CanExtend(pendingSpan.Speaker, pendingSpan.End, segment, maximumSegmentGap)) + { + pendingSpan = new PendingSpeakerSpan( + segment.Speaker, + segment.Start, + segment.End, + [segment.Text]); + return pendingSpan.ToSegment(); + } + + pendingSpan = pendingSpan.Extend(segment); + return pendingSpan.ToSegment(); + } + + private static double Score( + TranscriptionSegment segment, + TimeSpan minimumUninterruptedSpeechDuration) { var durationSeconds = (segment.End - segment.Start).TotalSeconds; - if (durationSeconds < 2 || durationSeconds > 30) + if (durationSeconds < minimumUninterruptedSpeechDuration.TotalSeconds) { return 0; } @@ -97,13 +145,13 @@ internal sealed class SpeakerAudioSampleCollector var words = segment.Text .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Length; - if (words < 3 || words > 80) + if (words < 3) { 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 durationScore = Math.Min(durationSeconds / Math.Max(1, minimumUninterruptedSpeechDuration.TotalSeconds), 2); + var wordScore = Math.Min(words / 60.0, 1); var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') || segment.Text.TrimEnd().EndsWith('?') || segment.Text.TrimEnd().EndsWith('!') @@ -111,4 +159,29 @@ internal sealed class SpeakerAudioSampleCollector : 0; return durationScore * 70 + wordScore * 30 + sentenceBonus; } + + private sealed record PendingSpeakerSpan( + string Speaker, + TimeSpan Start, + TimeSpan End, + IReadOnlyList TextParts) + { + public PendingSpeakerSpan Extend(TranscriptionSegment segment) + { + return this with + { + End = segment.End > End ? segment.End : End, + TextParts = TextParts.Append(segment.Text).ToList() + }; + } + + public TranscriptionSegment ToSegment() + { + return new TranscriptionSegment( + Start, + End, + Speaker, + string.Join(' ', TextParts.Where(part => !string.IsNullOrWhiteSpace(part)))); + } + } } diff --git a/MeetingAssistant/Speakers/AzureSpeechSpeakerIdentityMatcher.cs b/MeetingAssistant/Speakers/AzureSpeechSpeakerIdentityMatcher.cs index 4a19042..f514b04 100644 --- a/MeetingAssistant/Speakers/AzureSpeechSpeakerIdentityMatcher.cs +++ b/MeetingAssistant/Speakers/AzureSpeechSpeakerIdentityMatcher.cs @@ -15,15 +15,18 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher private readonly ISpeakerIdentityDiarizationClient diarizationClient; private readonly SpeakerIdentificationOptions options; private readonly ILogger logger; + private readonly ISpeakerIdentityMatchValidator matchValidator; public AzureSpeechSpeakerIdentityMatcher( ISpeakerIdentityDiarizationClient diarizationClient, Microsoft.Extensions.Options.IOptions options, - ILogger logger) + ILogger logger, + ISpeakerIdentityMatchValidator matchValidator) { this.diarizationClient = diarizationClient; this.options = options.Value.SpeakerIdentification; this.logger = logger; + this.matchValidator = matchValidator; } public async Task MatchAsync( @@ -35,6 +38,14 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher return null; } + if (!await matchValidator.ValidateSampleAsync(request.UnknownSnippet, cancellationToken)) + { + logger.LogInformation( + "Speaker identity matching {DiarizedSpeaker} skipped because secondary validation rejected the unknown sample", + request.DiarizedSpeaker); + return null; + } + logger.LogInformation( "Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)", request.DiarizedSpeaker, @@ -84,6 +95,20 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher var requiredMatches = known.Count() > 1 ? 2 : 1; if (matchingSnippetCount >= requiredMatches) { + var validationRequest = new SpeakerIdentityMatchValidationRequest( + request.DiarizedSpeaker, + known.Key, + request.UnknownSnippet, + known.Select(segment => segment.Snippet).ToList()); + if (!await matchValidator.ValidateMatchAsync(validationRequest, cancellationToken)) + { + logger.LogInformation( + "Speaker identity matching {DiarizedSpeaker} rejected identity {IdentityId} after secondary validation", + request.DiarizedSpeaker, + known.Key); + continue; + } + logger.LogInformation( "Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}", request.DiarizedSpeaker, @@ -99,7 +124,7 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher } finally { - TryDelete(tempPath); + SpeakerCompositeWav.TryDelete(tempPath); } } @@ -130,7 +155,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request) { - using var firstReader = OpenFirstReadableWave(request); + using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave( + request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet)); if (firstReader is null) { return new CompositeLayout(null, []); @@ -145,118 +171,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher { foreach (var snippet in candidate.Snippets.Where(storedSnippet => storedSnippet.Length > 0)) { - var segment = AppendSnippet(writer, firstReader.WaveFormat, snippet, current); + var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current); if (segment is null) { continue; } - knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value)); + knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value, snippet)); current = segment.Value.End + silence; - WriteSilence(writer, firstReader.WaveFormat, silence); + SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence); } } - var unknownSegment = AppendSnippet(writer, firstReader.WaveFormat, request.UnknownSnippet, current); + var unknownSegment = SpeakerCompositeWav.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 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) - { - } + return SpeakerCompositeWav.FindBestSpeaker(segment, segments); } private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList KnownSegments); - private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment); + private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment, byte[] Snippet); - private readonly record struct TimeSegment(TimeSpan Start, TimeSpan End); } diff --git a/MeetingAssistant/Speakers/NoopSpeakerIdentityMatchValidator.cs b/MeetingAssistant/Speakers/NoopSpeakerIdentityMatchValidator.cs new file mode 100644 index 0000000..24500b8 --- /dev/null +++ b/MeetingAssistant/Speakers/NoopSpeakerIdentityMatchValidator.cs @@ -0,0 +1,18 @@ +namespace MeetingAssistant.Speakers; + +public sealed class NoopSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator +{ + public Task ValidateSampleAsync( + byte[] wavBytes, + CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task ValidateMatchAsync( + SpeakerIdentityMatchValidationRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(true); + } +} diff --git a/MeetingAssistant/Speakers/PyannoteSpeakerIdentityMatchValidator.cs b/MeetingAssistant/Speakers/PyannoteSpeakerIdentityMatchValidator.cs new file mode 100644 index 0000000..f96f57e --- /dev/null +++ b/MeetingAssistant/Speakers/PyannoteSpeakerIdentityMatchValidator.cs @@ -0,0 +1,237 @@ +using MeetingAssistant.Transcription; +using Microsoft.Extensions.Options; +using NAudio.Wave; + +namespace MeetingAssistant.Speakers; + +public sealed class PyannoteSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator +{ + private readonly PyannoteTranscriptFinalizer finalizer; + private readonly SpeakerIdentityPyannoteValidationOptions options; + private readonly ILogger logger; + + public PyannoteSpeakerIdentityMatchValidator( + PyannoteTranscriptFinalizer finalizer, + IOptions options, + ILogger logger) + { + this.finalizer = finalizer; + this.options = options.Value.SpeakerIdentification.PyannoteValidation; + this.logger = logger; + } + + public async Task ValidateSampleAsync( + byte[] wavBytes, + CancellationToken cancellationToken) + { + if (!options.Enabled) + { + return true; + } + + var segments = await DiarizeBytesAsync(wavBytes, cancellationToken); + var result = HasSingleSpeaker(segments); + if (!result) + { + logger.LogInformation( + "pyannote rejected speaker identity sample because it did not contain one dominant speaker"); + } + + return result; + } + + public async Task ValidateMatchAsync( + SpeakerIdentityMatchValidationRequest request, + CancellationToken cancellationToken) + { + if (!options.Enabled) + { + return true; + } + + var tempPath = Path.Combine( + Path.GetTempPath(), + "meeting-assistant-speaker-match-pyannote", + $"{Guid.NewGuid():N}.wav"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!); + var layout = WriteCompositeWav( + tempPath, + request.KnownSnippets, + request.UnknownSnippet, + TimeSpan.FromSeconds(1)); + if (layout.UnknownSegment is null || layout.KnownSegments.Count == 0) + { + return false; + } + + var segments = await DiarizePathAsync(tempPath, cancellationToken); + var unknownSpeaker = FindBestSpeaker(layout.UnknownSegment.Value, segments); + if (string.IsNullOrWhiteSpace(unknownSpeaker)) + { + logger.LogInformation( + "pyannote rejected speaker identity match for {DiarizedSpeaker} because the unknown sample had no speaker", + request.DiarizedSpeaker); + return false; + } + + var compared = 0; + var matching = 0; + foreach (var knownSegment in layout.KnownSegments) + { + var knownSpeaker = FindBestSpeaker(knownSegment, segments); + if (string.IsNullOrWhiteSpace(knownSpeaker)) + { + continue; + } + + compared++; + if (string.Equals(knownSpeaker, unknownSpeaker, StringComparison.Ordinal)) + { + matching++; + } + } + + var minimumRatio = Math.Clamp(options.MinimumMatchingKnownSnippetRatio, 0, 1); + var accepted = compared > 0 && (double)matching / compared >= minimumRatio; + if (!accepted) + { + logger.LogInformation( + "pyannote rejected speaker identity match for {DiarizedSpeaker} and identity {IdentityId}: {Matching}/{Compared} known sample(s) matched", + request.DiarizedSpeaker, + request.IdentityId, + matching, + compared); + } + + return accepted; + } + finally + { + SpeakerCompositeWav.TryDelete(tempPath); + } + } + + private async Task> DiarizeBytesAsync( + byte[] wavBytes, + CancellationToken cancellationToken) + { + var tempPath = Path.Combine( + Path.GetTempPath(), + "meeting-assistant-speaker-sample-pyannote", + $"{Guid.NewGuid():N}.wav"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!); + await File.WriteAllBytesAsync(tempPath, wavBytes, cancellationToken); + return await DiarizePathAsync(tempPath, cancellationToken); + } + finally + { + SpeakerCompositeWav.TryDelete(tempPath); + } + } + + private async Task> DiarizePathAsync( + string wavPath, + CancellationToken cancellationToken) + { + TimeSpan duration; + try + { + using var reader = new WaveFileReader(wavPath); + duration = reader.TotalTime; + } + catch (Exception exception) when (exception is IOException or InvalidDataException or FormatException) + { + logger.LogInformation(exception, "pyannote speaker identity validation could not read WAV input"); + return []; + } + + if (duration <= TimeSpan.Zero) + { + return []; + } + + return await finalizer.FinalizeAsync( + wavPath, + [new TranscriptionSegment(TimeSpan.Zero, duration, "Unknown", "speaker identity validation sample")], + options.Diarization, + SpeechRecognitionPipelineOptions.Default, + cancellationToken); + } + + private bool HasSingleSpeaker(IReadOnlyList segments) + { + var durationsBySpeaker = segments + .Where(segment => !string.IsNullOrWhiteSpace(segment.Speaker) && segment.End > segment.Start) + .GroupBy(segment => segment.Speaker) + .Select(group => new + { + Speaker = group.Key, + Duration = group.Sum(segment => (segment.End - segment.Start).TotalSeconds) + }) + .Where(group => group.Duration > 0) + .ToList(); + if (durationsBySpeaker.Count == 0) + { + return false; + } + + if (durationsBySpeaker.Count == 1) + { + return true; + } + + var total = durationsBySpeaker.Sum(group => group.Duration); + var dominant = durationsBySpeaker.Max(group => group.Duration); + return total > 0 && dominant / total >= Math.Clamp(options.MinimumSingleSpeakerCoverage, 0, 1); + } + + private CompositeLayout WriteCompositeWav( + string path, + IReadOnlyList knownSnippets, + byte[] unknownSnippet, + TimeSpan silence) + { + using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave(knownSnippets.Append(unknownSnippet)); + if (firstReader is null) + { + return new CompositeLayout(null, []); + } + + using var writer = new WaveFileWriter(path, firstReader.WaveFormat); + var current = TimeSpan.Zero; + var knownSegments = new List(); + + foreach (var snippet in knownSnippets.Where(storedSnippet => storedSnippet.Length > 0)) + { + var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current); + if (segment is null) + { + continue; + } + + knownSegments.Add(segment.Value); + current = segment.Value.End + silence; + SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence); + } + + var unknownSegment = SpeakerCompositeWav.AppendSnippet( + writer, + firstReader.WaveFormat, + unknownSnippet, + current); + return new CompositeLayout(unknownSegment, knownSegments); + } + + private static string? FindBestSpeaker( + TimeSegment segment, + IReadOnlyList segments) + { + return SpeakerCompositeWav.FindBestSpeaker(segment, segments); + } + + private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList KnownSegments); +} diff --git a/MeetingAssistant/Speakers/SpeakerCompositeWav.cs b/MeetingAssistant/Speakers/SpeakerCompositeWav.cs new file mode 100644 index 0000000..9cd61b0 --- /dev/null +++ b/MeetingAssistant/Speakers/SpeakerCompositeWav.cs @@ -0,0 +1,107 @@ +using MeetingAssistant.Transcription; +using NAudio.Wave; + +namespace MeetingAssistant.Speakers; + +internal static class SpeakerCompositeWav +{ + public static WaveFileReader? OpenFirstReadableWave(IEnumerable snippets) + { + foreach (var bytes in snippets) + { + if (bytes.Length == 0) + { + continue; + } + + try + { + return new WaveFileReader(new MemoryStream(bytes)); + } + catch (FormatException) + { + } + } + + return null; + } + + public 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); + } + + public 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); + } + + public static string? FindBestSpeaker( + TimeSegment segment, + IReadOnlyList 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; + } + + public static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + } + + 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; + } +} + +internal readonly record struct TimeSegment(TimeSpan Start, TimeSpan End); diff --git a/MeetingAssistant/Speakers/SpeakerIdentificationModels.cs b/MeetingAssistant/Speakers/SpeakerIdentificationModels.cs index eb57f10..e0ceaca 100644 --- a/MeetingAssistant/Speakers/SpeakerIdentificationModels.cs +++ b/MeetingAssistant/Speakers/SpeakerIdentificationModels.cs @@ -38,6 +38,12 @@ public sealed record SpeakerIdentityMatchCandidate( public sealed record SpeakerIdentityMatch(int IdentityId); +public sealed record SpeakerIdentityMatchValidationRequest( + string DiarizedSpeaker, + int IdentityId, + byte[] UnknownSnippet, + IReadOnlyList KnownSnippets); + public interface ISpeakerIdentityMatcher { Task MatchAsync( @@ -45,6 +51,17 @@ public interface ISpeakerIdentityMatcher CancellationToken cancellationToken); } +public interface ISpeakerIdentityMatchValidator +{ + Task ValidateSampleAsync( + byte[] wavBytes, + CancellationToken cancellationToken); + + Task ValidateMatchAsync( + SpeakerIdentityMatchValidationRequest request, + CancellationToken cancellationToken); +} + public interface ISpeakerSnippetExtractor { Task ExtractSnippetAsync( diff --git a/MeetingAssistant/Speakers/SpeakerIdentity.cs b/MeetingAssistant/Speakers/SpeakerIdentity.cs index 85f3175..554ecd6 100644 --- a/MeetingAssistant/Speakers/SpeakerIdentity.cs +++ b/MeetingAssistant/Speakers/SpeakerIdentity.cs @@ -13,6 +13,8 @@ public sealed class SpeakerIdentity public DateTimeOffset UpdatedAt { get; set; } + public int TranscriptionCount { get; set; } + public List CandidateNames { get; set; } = []; public List Aliases { get; set; } = []; @@ -154,8 +156,9 @@ public sealed class SpeakerIdentityDbContext : DbContext { modelBuilder.Entity(entity => { - entity.Property("TranscriptionCount") - .HasDefaultValue(0); + entity.Property(identity => identity.TranscriptionCount) + .IsRequired() + .ValueGeneratedNever(); entity.HasMany(identity => identity.CandidateNames) .WithOne(candidate => candidate.SpeakerIdentity) diff --git a/MeetingAssistant/Speakers/SpeakerIdentityService.cs b/MeetingAssistant/Speakers/SpeakerIdentityService.cs index 8cbd292..fc656a6 100644 --- a/MeetingAssistant/Speakers/SpeakerIdentityService.cs +++ b/MeetingAssistant/Speakers/SpeakerIdentityService.cs @@ -10,6 +10,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService private readonly IDbContextFactory dbContextFactory; private readonly ISpeakerSnippetExtractor snippetExtractor; private readonly ISpeakerIdentityMatcher matcher; + private readonly ISpeakerIdentityMatchValidator matchValidator; private readonly SpeakerIdentificationOptions options; private readonly ILogger logger; @@ -18,11 +19,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService ISpeakerSnippetExtractor snippetExtractor, ISpeakerIdentityMatcher matcher, IOptions options, - ILogger logger) + ILogger logger, + ISpeakerIdentityMatchValidator matchValidator) { this.dbContextFactory = dbContextFactory; this.snippetExtractor = snippetExtractor; this.matcher = matcher; + this.matchValidator = matchValidator; this.options = options.Value.SpeakerIdentification; this.logger = logger; } @@ -198,7 +201,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService ? suppliedSnippet : await snippetExtractor.ExtractSnippetAsync( request.AudioPath, - group.ToList(), + SpeakerSampleSpanSelector.SelectBestContinuousSpan( + request.Segments, + speaker, + options.MaximumSampleSegmentGap, + options.MinimumSampleSpeechDuration), cancellationToken); if (snippet.Length == 0) { @@ -544,6 +551,14 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0)) { + if (!await matchValidator.ValidateSampleAsync(snippet, cancellationToken)) + { + logger.LogInformation( + "Skipping speaker identity candidate for {Speaker} because secondary validation rejected the sample", + speaker); + continue; + } + var now = DateTimeOffset.UtcNow; var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null; var identity = new SpeakerIdentity diff --git a/MeetingAssistant/Speakers/SpeakerSampleSpanSelector.cs b/MeetingAssistant/Speakers/SpeakerSampleSpanSelector.cs new file mode 100644 index 0000000..5e3bf52 --- /dev/null +++ b/MeetingAssistant/Speakers/SpeakerSampleSpanSelector.cs @@ -0,0 +1,79 @@ +using MeetingAssistant.Transcription; + +namespace MeetingAssistant.Speakers; + +internal static class SpeakerSampleSpanSelector +{ + public static bool CanExtend( + string currentSpeaker, + TimeSpan currentEnd, + TranscriptionSegment nextSegment, + TimeSpan maximumSegmentGap) + { + return string.Equals(currentSpeaker, nextSegment.Speaker, StringComparison.OrdinalIgnoreCase) && + nextSegment.Start - currentEnd <= maximumSegmentGap; + } + + public static IReadOnlyList SelectBestContinuousSpan( + IReadOnlyList segments, + string speaker, + TimeSpan maximumSegmentGap, + TimeSpan minimumDuration) + { + var best = new List(); + var current = new List(); + + foreach (var segment in segments.OrderBy(segment => segment.Start)) + { + if (current.Count == 0) + { + if (IsSpeaker(segment, speaker)) + { + current.Add(segment); + best = LongerSpan(current, best); + } + + continue; + } + + if (!CanExtend(speaker, current[^1].End, segment, maximumSegmentGap)) + { + current.Clear(); + if (!IsSpeaker(segment, speaker)) + { + continue; + } + } + + current.Add(segment); + best = LongerSpan(current, best); + } + + return SpanDuration(best) >= minimumDuration + ? best + : []; + } + + public static TimeSpan SpanDuration(IReadOnlyList segments) + { + return segments.Count == 0 + ? TimeSpan.Zero + : segments[^1].End - segments[0].Start; + } + + private static bool IsSpeaker( + TranscriptionSegment segment, + string speaker) + { + return string.Equals(segment.Speaker, speaker, StringComparison.OrdinalIgnoreCase); + } + + private static List LongerSpan( + List current, + List best) + { + return SpanDuration(current) > SpanDuration(best) + ? current.ToList() + : best; + } +} diff --git a/MeetingAssistant/appsettings.json b/MeetingAssistant/appsettings.json index e817051..50a97c7 100644 --- a/MeetingAssistant/appsettings.json +++ b/MeetingAssistant/appsettings.json @@ -103,10 +103,30 @@ "MaxMatchCandidates": 100, "MatchIdentityActiveAge": "365.00:00:00", "MaxSnippetsPerSpeaker": 3, + "MinimumSampleSpeechDuration": "00:00:30", + "MaximumSampleSegmentGap": "00:00:01", "SilenceBetweenSnippetsSeconds": 1, "LiveSampleBufferDuration": "00:10:00", "MergeRecentIdentityAge": "14.00:00:00", - "MatchTimeout": "00:03:00" + "MatchTimeout": "00:03:00", + "PyannoteValidation": { + "Enabled": false, + "MinimumSingleSpeakerCoverage": 0.9, + "MinimumMatchingKnownSnippetRatio": 0.5, + "Diarization": { + "Enabled": true, + "DockerCommand": "docker", + "BaseImage": "python:3.11-slim", + "Image": "meeting-assistant-pyannote:local", + "BuildImage": true, + "ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models", + "Model": "pyannote/speaker-diarization-3.1", + "AnnotationSource": "SpeakerDiarization", + "AlignmentMode": "PyannoteTurns", + "TokenEnv": "HF_TOKEN", + "CommandTimeout": "00:30:00" + } + } }, "Automation": { "RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml" diff --git a/README.md b/README.md index 4f4f8c4..7f16952 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,25 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se "RecognitionStopTimeout": "00:00:10", "DiarizeIntermediateResults": true }, + "SpeakerIdentification": { + "MinimumSampleSpeechDuration": "00:00:30", + "MaximumSampleSegmentGap": "00:00:01", + "PyannoteValidation": { + "Enabled": false, + "MinimumSingleSpeakerCoverage": 0.9, + "MinimumMatchingKnownSnippetRatio": 0.5, + "Diarization": { + "Enabled": true, + "DockerCommand": "docker", + "Image": "meeting-assistant-pyannote:local", + "BuildImage": true, + "ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models", + "Model": "pyannote/speaker-diarization-3.1", + "TokenEnv": "HF_TOKEN", + "CommandTimeout": "00:30:00" + } + } + }, "Automation": { "RulesPath": "meeting-rules.local.yaml" }, @@ -217,6 +236,8 @@ During recording, Meeting Assistant writes the mixed PCM stream to a temporary W `azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed, which keeps short diagnostic runs from waiting indefinitely for final service events. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote. +Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization and reads its Hugging Face token from the nested diarization options. + ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider. `POST /asr/transcribe-file` is a local diagnostic endpoint for the configured ASR backend. Send `{ "path": "C:\\path\\to\\sample.wav" }` with a 16-bit PCM WAV file to stream it through the configured speech recognition pipeline and return the emitted live transcript segments. diff --git a/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/proposal.md b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/proposal.md new file mode 100644 index 0000000..7046c6d --- /dev/null +++ b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/proposal.md @@ -0,0 +1,20 @@ +# Harden speaker samples and pyannote validation + +## Why + +Azure Speech speaker identity matching is too brittle when Meeting Assistant learns from short samples. Speaker identity samples should represent enough uninterrupted speech for the diarization backend to distinguish voices reliably. + +Meeting Assistant can also use pyannote as a secondary, optional confidence layer: first to reject samples that contain multiple speakers, and then to reject Azure identity matches when pyannote cannot confirm that the unknown and known snippets are the same speaker. + +## What Changes + +- Require speaker identity samples to meet a configurable minimum uninterrupted speech duration, defaulting to 30 seconds. +- Accumulate adjacent same-speaker transcript segments into a single sample span when no other speaker interrupts the span. +- Add optional pyannote-backed sample and match validation for speaker identity matching. +- Reuse the existing pyannote Docker/runtime configuration shape where practical. + +## Impact + +- Speaker identity matching starts later for speakers who do not talk long enough uninterrupted. +- Live matching tests that depended on very short samples need to configure a lower minimum sample duration. +- Pyannote validation is opt-in and should not affect existing installations unless enabled. diff --git a/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/specs/meeting-transcription/spec.md b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/specs/meeting-transcription/spec.md new file mode 100644 index 0000000..5b7527a --- /dev/null +++ b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/specs/meeting-transcription/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Speaker identity samples require uninterrupted speech +Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech. + +The default minimum uninterrupted speech duration SHALL be 30 seconds. + +Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span. + +When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker. + +#### Scenario: Short speaker span is not retained +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech +- **THEN** Meeting Assistant does not retain a speaker identity sample for that span + +#### Scenario: Adjacent same-speaker segments form a sample +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting +- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span + +#### Scenario: Different speaker interrupts pending span +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks +- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it + +### Requirement: Speaker identity matching can use pyannote secondary validation +Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching. + +When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected. + +When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match. + +If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match. + +When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior. + +#### Scenario: Multi-speaker sample is rejected +- **GIVEN** pyannote secondary validation is enabled +- **WHEN** pyannote reports multiple speakers in a candidate sample +- **THEN** Meeting Assistant does not retain that sample for identity matching + +#### Scenario: Pyannote rejects primary match +- **GIVEN** pyannote secondary validation is enabled +- **AND** the primary identity matcher confirms `Guest03` as `Chris` +- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers +- **THEN** Meeting Assistant rejects the match + +#### Scenario: Disabled pyannote validation preserves primary match +- **GIVEN** pyannote secondary validation is disabled +- **WHEN** the primary identity matcher confirms `Guest03` as `Chris` +- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation diff --git a/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/tasks.md b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/tasks.md new file mode 100644 index 0000000..115ce72 --- /dev/null +++ b/openspec/changes/archive/2026-05-28-harden-speaker-samples-and-pyannote-validation/tasks.md @@ -0,0 +1,24 @@ +## 1. Specification + +- [x] Add requirements for minimum uninterrupted speaker samples. +- [x] Add requirements for optional pyannote secondary validation. + +## 2. Tests + +- [x] Cover sample collection waiting for the configured minimum uninterrupted speech duration. +- [x] Cover sample collection resetting when another speaker interrupts. +- [x] Cover pyannote sample validation rejecting multi-speaker samples. +- [x] Cover pyannote match validation rejecting an Azure-confirmed match. +- [x] Update existing live matching tests to configure short samples where they intentionally exercise fast matching. + +## 3. Implementation + +- [x] Add speaker identification configuration for minimum sample duration and pyannote validation. +- [x] Refactor live sample collection to accumulate continuous same-speaker spans. +- [x] Add pyannote diarization reuse for speaker identity validation. +- [x] Add secondary validation into the identity matching pipeline. + +## 4. Verification + +- [x] Run focused speaker/transcription tests. +- [x] Run `openspec validate harden-speaker-samples-and-pyannote-validation --strict`. diff --git a/openspec/specs/meeting-transcription/spec.md b/openspec/specs/meeting-transcription/spec.md index e56abff..b1a113b 100644 --- a/openspec/specs/meeting-transcription/spec.md +++ b/openspec/specs/meeting-transcription/spec.md @@ -435,3 +435,54 @@ The replacement speech recognition pipeline SHALL use the target launch profile' - **WHEN** the active transcription profile is switched to another launch profile - **THEN** the new speech recognition pipeline is created from the target profile options - **AND** it receives audio captured after the switch request and audio buffered during the switch + +### Requirement: Speaker identity samples require uninterrupted speech +Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech. + +The default minimum uninterrupted speech duration SHALL be 30 seconds. + +Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span. + +When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker. + +#### Scenario: Short speaker span is not retained +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech +- **THEN** Meeting Assistant does not retain a speaker identity sample for that span + +#### Scenario: Adjacent same-speaker segments form a sample +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting +- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span + +#### Scenario: Different speaker interrupts pending span +- **GIVEN** the configured minimum sample duration is 30 seconds +- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks +- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it + +### Requirement: Speaker identity matching can use pyannote secondary validation +Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching. + +When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected. + +When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match. + +If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match. + +When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior. + +#### Scenario: Multi-speaker sample is rejected +- **GIVEN** pyannote secondary validation is enabled +- **WHEN** pyannote reports multiple speakers in a candidate sample +- **THEN** Meeting Assistant does not retain that sample for identity matching + +#### Scenario: Pyannote rejects primary match +- **GIVEN** pyannote secondary validation is enabled +- **AND** the primary identity matcher confirms `Guest03` as `Chris` +- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers +- **THEN** Meeting Assistant rejects the match + +#### Scenario: Disabled pyannote validation preserves primary match +- **GIVEN** pyannote secondary validation is disabled +- **WHEN** the primary identity matcher confirms `Guest03` as `Chris` +- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation