Files
meeting-assistant/MeetingAssistant.Tests/AzureSpeechSpeakerIdentityMatcherTests.cs
T
codex a72cda0c03
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
Harden speaker identity samples
2026-05-28 12:02:44 +02:00

224 lines
7.8 KiB
C#

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<AzureSpeechSpeakerIdentityMatcher>.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<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);
}
}
private sealed class HangingSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
{
public async Task<IReadOnlyList<TranscriptionSegment>> 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<bool> ValidateSampleAsync(byte[] wavBytes, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
MatchValidationCount++;
return Task.FromResult(false);
}
}
}