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); }