forked from Manuel/meeting-assistant
250 lines
9.0 KiB
C#
250 lines
9.0 KiB
C#
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<InvalidOperationException>(
|
|
() => 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(
|
|
Path.Combine("Scripts", "python.exe"),
|
|
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<InvalidDataException>(
|
|
() => 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<InvalidDataException>(
|
|
() => 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<InvalidDataException>(
|
|
() => 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<InvalidDataException>(
|
|
() => 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<InvalidDataException>(
|
|
() => 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<InvalidOperationException>(
|
|
() => fixture.Encoder.EncodeAsync([[1]], CancellationToken.None));
|
|
|
|
Assert.Contains("exit code 17", error.Message);
|
|
Assert.Contains("runtime failed", error.Message);
|
|
}
|
|
|
|
private static string VectorOutput(IReadOnlyList<float[]> 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<VenvResemblyzerVoiceEncoder>.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<CapturedCommand> Commands { get; } = [];
|
|
|
|
public Task<CommandResult> RunAsync(
|
|
string fileName,
|
|
IReadOnlyList<string> arguments,
|
|
CancellationToken cancellationToken,
|
|
IReadOnlyDictionary<string, string>? 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<string> Arguments);
|
|
}
|