Public Access
feat: add local Resemblyzer speaker recognition
PR and Push Build/Test / build-and-test (push) Successful in 12m37s
PR and Push Build/Test / build-and-test (push) Successful in 12m37s
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user