using MeetingAssistant; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Text.Json; namespace MeetingAssistant.Tests; public sealed class VenvResemblyzerVoiceEncoderTests { [Fact] public async Task WarmUpAsyncProvisionsVersionedCpuOnlyEnvironmentWithoutDocker() { using var fixture = new EncoderFixture(); await fixture.Encoder.WarmUpAsync(CancellationToken.None); Assert.DoesNotContain( fixture.Runner.Commands, command => command.FileName.Contains("docker", StringComparison.OrdinalIgnoreCase)); Assert.Contains( fixture.Runner.Commands, command => command.FileName == "python-test" && command.Arguments.Take(2).SequenceEqual(["-m", "venv"])); Assert.Contains( fixture.Runner.Commands, command => command.Arguments.Contains("torch==2.14.0+cpu") && command.Arguments.Contains("https://download.pytorch.org/whl/cpu")); Assert.Contains( fixture.Runner.Commands, command => command.Arguments.Contains("webrtcvad-wheels==2.0.14")); Assert.Contains( fixture.Runner.Commands, command => command.Arguments.Contains("Resemblyzer==0.1.4") && command.Arguments.Contains("--no-deps")); } [Fact] public async Task WarmUpAsyncDoesNotMarkFailedEnvironmentReady() { using var fixture = new EncoderFixture( warmupExitCode: 21, warmupError: "incompatible environment"); await Assert.ThrowsAsync( () => fixture.Encoder.WarmUpAsync(CancellationToken.None)); Assert.Empty(Directory.EnumerateFiles( fixture.RuntimeFolder, ".ready", SearchOption.AllDirectories)); } [Fact] public async Task EncodeAsyncRunsBatchWithManagedEnvironmentPython() { var first = new float[256]; first[0] = 2; var second = new float[256]; second[1] = 3; using var fixture = new EncoderFixture(VectorOutput([first, second])); var vectors = await fixture.Encoder.EncodeAsync([[1, 2, 3], [4, 5, 6]], CancellationToken.None); Assert.Equal(2, vectors.Count); Assert.Equal(1f, vectors[0][0], 5); Assert.Equal(1f, vectors[1][1], 5); var encodingCommand = Assert.Single( fixture.Runner.Commands, command => command.Arguments.Any( argument => argument.EndsWith("encode.py", StringComparison.Ordinal))); Assert.EndsWith( OperatingSystem.IsWindows() ? Path.Combine("Scripts", "python.exe") : Path.Combine("bin", "python"), encodingCommand.FileName, StringComparison.OrdinalIgnoreCase); Assert.Equal(2, encodingCommand.Arguments.Count); Assert.True(Path.IsPathFullyQualified(encodingCommand.Arguments[1])); } [Fact] public async Task EncodeAsyncRejectsWrongVectorDimension() { var vector = new float[255]; vector[0] = 1; using var fixture = new EncoderFixture(VectorOutput([vector])); var error = await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); Assert.Contains("255 dimensions", error.Message); } [Fact] public async Task EncodeAsyncRejectsMalformedVectorJson() { using var fixture = new EncoderFixture( "__MEETING_ASSISTANT_RESEMBLYZER_JSON_START__\n[not-json]\n__MEETING_ASSISTANT_RESEMBLYZER_JSON_END__"); await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); } [Fact] public async Task EncodeAsyncRejectsNonFiniteVector() { var values = string.Join(',', new[] { "NaN" }.Concat(Enumerable.Repeat("0", 255))); using var fixture = new EncoderFixture( $"__MEETING_ASSISTANT_RESEMBLYZER_JSON_START__\n[[{values}]]\n__MEETING_ASSISTANT_RESEMBLYZER_JSON_END__"); await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); } [Fact] public async Task EncodeAsyncRejectsWrongResultCount() { var vector = new float[256]; vector[0] = 1; using var fixture = new EncoderFixture(VectorOutput([vector, vector])); var error = await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); Assert.Contains("2 vectors for 1 WAV samples", error.Message); } [Fact] public async Task EncodeAsyncRejectsZeroMagnitudeVector() { using var fixture = new EncoderFixture(VectorOutput([new float[256]])); var error = await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); Assert.Contains("zero magnitude", error.Message); } [Fact] public async Task EncodeAsyncReportsFailedLocalCommand() { using var fixture = new EncoderFixture("", 17, "runtime failed"); var error = await Assert.ThrowsAsync( () => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None)); Assert.Contains("exit code 17", error.Message); Assert.Contains("runtime failed", error.Message); } private static string VectorOutput(IReadOnlyList vectors) { return $""" __MEETING_ASSISTANT_RESEMBLYZER_JSON_START__ {JsonSerializer.Serialize(vectors)} __MEETING_ASSISTANT_RESEMBLYZER_JSON_END__ """; } private sealed class EncoderFixture : IDisposable { public EncoderFixture( string encodingOutput = "", int encodingExitCode = 0, string encodingError = "", int warmupExitCode = 0, string warmupError = "") { RuntimeFolder = Path.Combine( Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "resemblyzer"); Runner = new CapturingCommandRunner( encodingOutput, encodingExitCode, encodingError, warmupExitCode, warmupError); var options = new MeetingAssistantOptions(); options.SpeakerIdentification.Resemblyzer.PythonCommand = "python-test"; options.SpeakerIdentification.Resemblyzer.RuntimeFolder = RuntimeFolder; Encoder = new VenvResemblyzerVoiceEncoder( Runner, Options.Create(options), NullLogger.Instance); } public CapturingCommandRunner Runner { get; } public VenvResemblyzerVoiceEncoder Encoder { get; } public string RuntimeFolder { get; } public void Dispose() { if (Directory.Exists(RuntimeFolder)) { Directory.Delete(RuntimeFolder, recursive: true); } } } private sealed class CapturingCommandRunner : ICommandRunner { private readonly string encodingOutput; private readonly int encodingExitCode; private readonly string encodingError; private readonly int warmupExitCode; private readonly string warmupError; public CapturingCommandRunner( string encodingOutput = "", int encodingExitCode = 0, string encodingError = "", int warmupExitCode = 0, string warmupError = "") { this.encodingOutput = encodingOutput; this.encodingExitCode = encodingExitCode; this.encodingError = encodingError; this.warmupExitCode = warmupExitCode; this.warmupError = warmupError; } public List Commands { get; } = []; public Task RunAsync( string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, IReadOnlyDictionary? environment = null) { Commands.Add(new CapturedCommand(fileName, arguments.ToList())); var isEncoding = arguments.Any(argument => argument.EndsWith("encode.py", StringComparison.Ordinal)); if (isEncoding) { return Task.FromResult(new CommandResult(encodingExitCode, encodingOutput, encodingError)); } var isWarmup = arguments.Any(argument => argument.Contains("VoiceEncoder", StringComparison.Ordinal)); return Task.FromResult(isWarmup ? new CommandResult(warmupExitCode, string.Empty, warmupError) : new CommandResult(0, string.Empty, string.Empty)); } } private sealed record CapturedCommand(string FileName, IReadOnlyList Arguments); }