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); } [Fact] public async Task MatcherReturnsNullWhenDiarizationTimesOut() { var matcher = CreateMatcher( new HangingSpeakerIdentityDiarizationClient(), TimeSpan.FromMilliseconds(20)); var snippet = CreateWavSnippet(); var match = await matcher.MatchAsync( new SpeakerIdentityMatchRequest( "Guest03", snippet, [new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]), CancellationToken.None); 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, ISpeakerIdentityMatchValidator? validator = null) { return new AzureSpeechSpeakerIdentityMatcher( diarizationClient, Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { SilenceBetweenSnippetsSeconds = 1, MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1) } }), NullLogger.Instance, validator ?? new NoopSpeakerIdentityMatchValidator()); } 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 segments; public FakeSpeakerIdentityDiarizationClient(IReadOnlyList segments) { this.segments = segments; } public string? WavPath { get; private set; } public Task> DiarizeAsync( string wavPath, CancellationToken cancellationToken) { WavPath = wavPath; Assert.True(File.Exists(wavPath)); return Task.FromResult(segments); } } private sealed class HangingSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient { public async Task> DiarizeAsync( string wavPath, CancellationToken cancellationToken) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); 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); } } }