forked from Manuel/meeting-assistant
Merge remote-tracking branch 'origin/main' into codex/macos-support
# Conflicts: # MeetingAssistant/Program.cs # README.md # docs/meeting-assistant-configuration.md
This commit is contained in:
@@ -58,6 +58,64 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
Assert.Equal("Ctrl+Alt+L", profile.Options.Hotkey.Toggle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckedInConfigurationKeepsResemblyzerOptInWithDocumentedDefaults()
|
||||
{
|
||||
var profile = CreateProviderFromAppsettings().GetRequiredProfile(null);
|
||||
|
||||
Assert.False(profile.Options.SpeakerIdentification.Resemblyzer.Enabled);
|
||||
Assert.Equal(5, profile.Options.SpeakerIdentification.Resemblyzer.RequiredVectorsPerSpeaker);
|
||||
Assert.Equal(1000, profile.Options.SpeakerIdentification.Resemblyzer.MaxVectorsPerIdentity);
|
||||
Assert.Equal(20, profile.Options.SpeakerIdentification.Resemblyzer.OutlierPruningMinimumVectors);
|
||||
Assert.Equal(0.75, profile.Options.SpeakerIdentification.Resemblyzer.OutlierPruningNeighborSimilarity);
|
||||
Assert.Equal(3, profile.Options.SpeakerIdentification.Resemblyzer.OutlierPruningMinimumNeighbors);
|
||||
Assert.Equal(0.60, profile.Options.SpeakerIdentification.Resemblyzer.OutlierPruningMinimumClusterRatio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidResemblyzerOutlierPruningSettingsAreRejected()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:SpeakerIdentification:Resemblyzer:OutlierPruningMinimumClusterRatio"] = "1.1"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => provider.GetRequiredProfile(null));
|
||||
|
||||
Assert.Contains("OutlierPruningMinimumClusterRatio", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonFiniteResemblyzerSimilaritySettingsAreRejected()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:SpeakerIdentification:Resemblyzer:MinimumIdentitySimilarity"] = "NaN"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => provider.GetRequiredProfile(null));
|
||||
|
||||
Assert.Contains("MinimumIdentitySimilarity", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContradictorySpeakerSampleDurationsAreRejected()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:SpeakerIdentification:MinimumSampleSpeechDuration"] = "00:01:01",
|
||||
["MeetingAssistant:SpeakerIdentification:MaximumSampleDuration"] = "00:01:00"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => provider.GetRequiredProfile(null));
|
||||
|
||||
Assert.Contains("MinimumSampleSpeechDuration", exception.Message);
|
||||
Assert.Contains("MaximumSampleDuration", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateProfileHotkeysAreRejected()
|
||||
{
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -25,4 +25,16 @@ public sealed class NotificationActivationArgumentsTests
|
||||
Assert.Equal("abc123", arguments["promptId"]);
|
||||
Assert.Equal("continue", arguments["response"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InactivityPromptActionsIncludePauseAndParseItsResponse()
|
||||
{
|
||||
Assert.Contains(
|
||||
MeetingInactivityPromptActions.All,
|
||||
action => action.Content == "Pause transcription" &&
|
||||
action.ResponseArgument == "pause");
|
||||
Assert.Equal(
|
||||
MeetingInactivityPromptResponse.Pause,
|
||||
MeetingInactivityPromptActions.ParseResponse("pause"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,31 +18,8 @@ public sealed class PyannoteDiarizationWarmupHostedServiceTests
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
var service = new PyannoteDiarizationWarmupHostedService(
|
||||
finalizer,
|
||||
new FakeLaunchProfileOptionsProvider(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = { TranscriptionProvider = "azure-speech" },
|
||||
SpeakerIdentification =
|
||||
{
|
||||
PyannoteValidation =
|
||||
{
|
||||
Enabled = true,
|
||||
Diarization =
|
||||
{
|
||||
Enabled = true,
|
||||
DockerCommand = "docker",
|
||||
Image = "meeting-assistant-pyannote-validation:local",
|
||||
ModelsFolder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"models"),
|
||||
Token = "hf_test",
|
||||
TokenEnv = "",
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
new FakeLaunchProfileOptionsProvider(
|
||||
CreateValidationOptions("meeting-assistant-pyannote-validation:local")),
|
||||
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
|
||||
@@ -54,23 +31,109 @@ public sealed class PyannoteDiarizationWarmupHostedServiceTests
|
||||
Assert.True(commandRunner.RunCancellationWasObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedServiceUsesApplicationValidationRuntimeInsteadOfNamedProfileOverride()
|
||||
{
|
||||
var commandRunner = new BlockingCommandRunner();
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
var defaultOptions = CreateValidationOptions("meeting-assistant-pyannote-default:local");
|
||||
var namedOptions = CreateValidationOptions("meeting-assistant-pyannote-named:local");
|
||||
var service = new PyannoteDiarizationWarmupHostedService(
|
||||
finalizer,
|
||||
new FakeLaunchProfileOptionsProvider(
|
||||
[
|
||||
new LaunchProfile("english", namedOptions),
|
||||
new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, defaultOptions)
|
||||
]),
|
||||
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
|
||||
await commandRunner.WaitForRunAsync();
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-default:local"));
|
||||
Assert.DoesNotContain(commandRunner.Commands, command => command.Arguments.Contains("meeting-assistant-pyannote-named:local"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostedServiceDoesNotWarmIdentityValidationWhenResemblyzerIsSelected()
|
||||
{
|
||||
var commandRunner = new BlockingCommandRunner();
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
var options = CreateValidationOptions("meeting-assistant-pyannote-validation:local");
|
||||
options.SpeakerIdentification.Resemblyzer.Enabled = true;
|
||||
var service = new PyannoteDiarizationWarmupHostedService(
|
||||
finalizer,
|
||||
new FakeLaunchProfileOptionsProvider(options),
|
||||
NullLogger<PyannoteDiarizationWarmupHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(50));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(commandRunner.Commands);
|
||||
}
|
||||
|
||||
private static MeetingAssistantOptions CreateValidationOptions(string image)
|
||||
{
|
||||
return new MeetingAssistantOptions
|
||||
{
|
||||
Recording = { TranscriptionProvider = "azure-speech" },
|
||||
SpeakerIdentification =
|
||||
{
|
||||
PyannoteValidation =
|
||||
{
|
||||
Enabled = true,
|
||||
Diarization =
|
||||
{
|
||||
DockerCommand = "docker",
|
||||
Image = image,
|
||||
ModelsFolder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"models"),
|
||||
Token = "hf_test",
|
||||
TokenEnv = "",
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly IReadOnlyList<LaunchProfile> profiles;
|
||||
|
||||
public FakeLaunchProfileOptionsProvider(MeetingAssistantOptions options)
|
||||
: this([new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options)])
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public FakeLaunchProfileOptionsProvider(IReadOnlyList<LaunchProfile> profiles)
|
||||
{
|
||||
this.profiles = profiles;
|
||||
}
|
||||
|
||||
public LaunchProfile GetRequiredProfile(string? name)
|
||||
{
|
||||
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
|
||||
var profileName = string.IsNullOrWhiteSpace(name)
|
||||
? ConfigurationLaunchProfileOptionsProvider.DefaultProfileName
|
||||
: name;
|
||||
return profiles.Single(profile => profile.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfile> GetProfiles()
|
||||
{
|
||||
return [GetRequiredProfile(null)];
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
|
||||
|
||||
@@ -2,6 +2,7 @@ using MeetingAssistant;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.Wave;
|
||||
@@ -57,17 +58,49 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
||||
Assert.Empty(commandRunner.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OuterToggleControlsValidationWhenLegacyNestedToggleIsFalse()
|
||||
{
|
||||
var commandRunner = new CapturingCommandRunner(
|
||||
"""
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
|
||||
[{"start":0.0,"end":20.0,"speaker":"SPEAKER_00"}]
|
||||
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
|
||||
""");
|
||||
var modelsFolder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"models");
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["SpeakerIdentification:PyannoteValidation:Enabled"] = "true",
|
||||
["SpeakerIdentification:PyannoteValidation:Diarization:Enabled"] = "false",
|
||||
["SpeakerIdentification:PyannoteValidation:Diarization:BuildImage"] = "false",
|
||||
["SpeakerIdentification:PyannoteValidation:Diarization:ModelsFolder"] = modelsFolder,
|
||||
["SpeakerIdentification:PyannoteValidation:Diarization:Token"] = "hf_test",
|
||||
["SpeakerIdentification:PyannoteValidation:Diarization:TokenEnv"] = ""
|
||||
})
|
||||
.Build();
|
||||
var configuredOptions = configuration.Get<MeetingAssistantOptions>()!;
|
||||
var validator = CreateValidator(commandRunner, configuredOptions);
|
||||
|
||||
var valid = await validator.ValidateSampleAsync(
|
||||
CreateWav(TimeSpan.FromSeconds(20)),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(valid);
|
||||
Assert.Contains(commandRunner.Commands, command => command.Arguments.Contains("run"));
|
||||
}
|
||||
|
||||
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
|
||||
CapturingCommandRunner commandRunner,
|
||||
bool enabled = true)
|
||||
{
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
return CreateValidator(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
return new PyannoteSpeakerIdentityMatchValidator(
|
||||
finalizer,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
@@ -76,9 +109,8 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
||||
Enabled = enabled,
|
||||
MinimumSingleSpeakerCoverage = 0.90,
|
||||
MinimumMatchingKnownSnippetRatio = 1,
|
||||
Diarization = new PyannoteDiarizationOptions
|
||||
Diarization = new PyannoteRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BuildImage = false,
|
||||
DockerCommand = "docker",
|
||||
Image = "meeting-assistant-pyannote:local",
|
||||
@@ -94,7 +126,20 @@ public sealed class PyannoteSpeakerIdentityMatchValidatorTests
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
|
||||
CapturingCommandRunner commandRunner,
|
||||
MeetingAssistantOptions configuredOptions)
|
||||
{
|
||||
var finalizer = new PyannoteTranscriptFinalizer(
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
return new PyannoteSpeakerIdentityMatchValidator(
|
||||
finalizer,
|
||||
Options.Create(configuredOptions),
|
||||
NullLogger<PyannoteSpeakerIdentityMatchValidator>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -156,9 +156,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
||||
commandRunner,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<PyannoteTranscriptFinalizer>.Instance);
|
||||
var explicitDiarization = new PyannoteDiarizationOptions
|
||||
var explicitDiarization = new PyannoteRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DockerCommand = "docker",
|
||||
BaseImage = "python:3.11-slim",
|
||||
Image = "meeting-assistant-pyannote-azure:local",
|
||||
@@ -169,7 +168,7 @@ public sealed class PyannoteTranscriptFinalizerTests
|
||||
CommandTimeout = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
|
||||
await finalizer.FinalizeAsync(
|
||||
await finalizer.FinalizeEnabledAsync(
|
||||
audioPath,
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello")],
|
||||
explicitDiarization,
|
||||
@@ -186,9 +185,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
||||
{
|
||||
var commandRunner = new CapturingCommandRunner("");
|
||||
var finalizer = CreateFinalizer(commandRunner, token: "hf_test");
|
||||
var diarization = new PyannoteDiarizationOptions
|
||||
var diarization = new PyannoteRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DockerCommand = "docker",
|
||||
BaseImage = "python:3.11-slim",
|
||||
Image = "meeting-assistant-pyannote-warmup:local",
|
||||
@@ -218,9 +216,8 @@ public sealed class PyannoteTranscriptFinalizerTests
|
||||
var commandRunner = new CapturingCommandRunner("");
|
||||
var finalizer = CreateFinalizer(commandRunner, token: null);
|
||||
|
||||
await finalizer.WarmUpAsync(new PyannoteDiarizationOptions
|
||||
await finalizer.WarmUpAsync(new PyannoteRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Token = "",
|
||||
TokenEnv = "",
|
||||
ModelsFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "models")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ResemblyzerSpeakerIdentificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LiveMatchRelabelsSpeakerAndStoresFiveVectorsWithoutWavSnippets()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync("Chris", [UnitVector(0), UnitVector(0, 20, 0.02f)]);
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Chris", result.Segments.Single().Speaker);
|
||||
Assert.Equal("Chris", result.SpeakerMappings["Guest03"]);
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal(7, saved.VoiceVectors.Count);
|
||||
Assert.Empty(saved.Snippets);
|
||||
Assert.Single(saved.References);
|
||||
Assert.Single(fixture.Encoder.Requests);
|
||||
Assert.Equal(5, fixture.Encoder.Requests[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummaryOverrideCreatesNamedIdentityFromThreeAvailableVectors()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 3)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
var request = fixture.CreateRequest("Guest-01", sampleCount: 3);
|
||||
|
||||
await service.ApplySpeakerOverrideAsync(
|
||||
request,
|
||||
"Guest-01",
|
||||
"Sabrina",
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadOnlyIdentityAsync();
|
||||
Assert.Equal("Sabrina", saved.CanonicalName);
|
||||
Assert.Equal(3, saved.VoiceVectors.Count);
|
||||
Assert.Empty(saved.Snippets);
|
||||
Assert.Single(saved.References);
|
||||
Assert.Equal(3, fixture.Encoder.Requests.Single().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalProcessingLearnsUnmatchedSpeakerFromFiveVectorsAndAttendees()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest-01", sampleCount: 5, attendees: ["John", "Mike"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadOnlyIdentityAsync();
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Equal(5, saved.VoiceVectors.Count);
|
||||
Assert.Empty(saved.Snippets);
|
||||
Assert.Single(saved.References);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiveVectorThresholdDoesNotCapQualifyingCurrentRunEvidence()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 8)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest-01", sampleCount: 8, attendees: ["John", "Mike"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadOnlyIdentityAsync();
|
||||
Assert.Equal(8, saved.VoiceVectors.Count);
|
||||
Assert.Equal(8, fixture.Encoder.Requests.Single().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalProcessingPrunesForeignVectorsFromNewIdentityAtMinimum()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(options =>
|
||||
{
|
||||
options.OutlierPruningMinimumVectors = 20;
|
||||
options.OutlierPruningNeighborSimilarity = 0.90;
|
||||
options.OutlierPruningMinimumNeighbors = 3;
|
||||
options.OutlierPruningMinimumClusterRatio = 0.60;
|
||||
});
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 16)
|
||||
.Select(index => UnitVector(0, index + 2, 0.04f))
|
||||
.Concat(Enumerable.Range(0, 4)
|
||||
.Select(index => UnitVector(1, index + 30, 0.04f)))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest-01", sampleCount: 20, attendees: ["John", "Mike"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadOnlyIdentityAsync();
|
||||
Assert.Equal(16, saved.VoiceVectors.Count);
|
||||
Assert.All(
|
||||
saved.VoiceVectors,
|
||||
stored => Assert.True(SpeakerVoiceVectors.Decode(stored)[0] > 0.9f));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task FinalProcessingStoresVectorsCollectedAfterLiveMatch(bool transcriptAlreadyRelabeled)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(
|
||||
"Chris",
|
||||
[UnitVector(0), UnitVector(0, 20, 0.02f)]);
|
||||
var currentRunVectors = Enumerable.Range(0, 8)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
fixture.Encoder.Vectors = currentRunVectors.Take(5).ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var liveResult = await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 5),
|
||||
CancellationToken.None);
|
||||
fixture.Encoder.Vectors = currentRunVectors;
|
||||
var finalRequest = fixture.CreateRequest("Guest03", sampleCount: 8) with
|
||||
{
|
||||
KnownSpeakerMappings = liveResult.SpeakerMappings
|
||||
};
|
||||
if (transcriptAlreadyRelabeled)
|
||||
{
|
||||
finalRequest = finalRequest with { Segments = liveResult.Segments };
|
||||
}
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(finalRequest, CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal(10, saved.VoiceVectors.Count);
|
||||
Assert.Equal([5, 8], fixture.Encoder.Requests.Select(request => request.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RepeatedMatchDeduplicatesVectorsAndHonorsConfiguredLimit()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(options => options.MaxVectorsPerIdentity = 6);
|
||||
var identity = await fixture.AddIdentityAsync("Chris", [UnitVector(0), UnitVector(0, 20, 0.02f)]);
|
||||
fixture.Encoder.Vectors =
|
||||
[
|
||||
UnitVector(0),
|
||||
UnitVector(0, 2, 0.01f),
|
||||
UnitVector(0, 3, 0.02f),
|
||||
UnitVector(0, 4, 0.03f),
|
||||
UnitVector(0, 5, 0.04f)
|
||||
];
|
||||
var service = fixture.CreateService();
|
||||
var request = fixture.CreateRequest("Guest03", sampleCount: 5);
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(request, CancellationToken.None);
|
||||
await service.IdentifyKnownSpeakersAsync(request, CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal(6, saved.VoiceVectors.Count);
|
||||
Assert.Single(saved.References);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinishedMatchingExtractsFiveNonOverlappingSamplesWhenLiveSamplesAreMissing()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await fixture.AddIdentityAsync("Chris", [UnitVector(0), UnitVector(0, 20, 0.02f)]);
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.IdentifyFinishedSpeakersAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 0, segmentCount: 5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Chris", result.Segments[0].Speaker);
|
||||
Assert.Equal(5, fixture.SnippetExtractor.Requests.Count);
|
||||
Assert.All(
|
||||
fixture.SnippetExtractor.Requests.Zip(fixture.SnippetExtractor.Requests.Skip(1)),
|
||||
pair => Assert.True(pair.First[^1].End <= pair.Second[0].Start));
|
||||
Assert.Equal(5, fixture.Encoder.Requests.Single().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalMatchPromotesTheRemainingCandidateAndAuditsTheTranscript()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(
|
||||
null,
|
||||
[UnitVector(0), UnitVector(0, 20, 0.02f)],
|
||||
candidates: ["John", "Mike"]);
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 5, attendees: ["Jane", "John", "Chris"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("John", saved.CanonicalName);
|
||||
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Equal("John", result.Segments.Single().Speaker);
|
||||
Assert.All(saved.References, reference =>
|
||||
Assert.Contains("Guest03 was identified as John", File.ReadAllText(reference.TranscriptPath)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalMatchResetsCandidatesWhenAttendeesDoNotIntersect()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(
|
||||
null,
|
||||
[UnitVector(0), UnitVector(0, 20, 0.02f)],
|
||||
candidates: ["John", "Mike"]);
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 5, attendees: ["Jane", "Chris"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalUnmatchedSpeakerWithOneCandidateIsAuditedWhenLearned()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, index + 2, 0.03f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest("Guest-01", sampleCount: 5, attendees: ["Manuel"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadOnlyIdentityAsync();
|
||||
Assert.Equal("Manuel", saved.CanonicalName);
|
||||
Assert.Contains("Guest-01 was identified as Manuel", File.ReadAllText(saved.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CandidateLimitCountsOnlyIdentitiesWithCompatibleVectors()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(
|
||||
configureSpeaker: options => options.MaxMatchCandidates = 1);
|
||||
await fixture.AddIdentityAsync("Legacy WAV identity", []);
|
||||
await fixture.AddIdentityAsync("Chris", [UnitVector(0)]);
|
||||
fixture.Encoder.Vectors = Enumerable.Repeat(UnitVector(0), 5).ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
var result = await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest("Guest03", sampleCount: 5, attendees: []),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Chris", result.Segments.Single().Speaker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SummaryOverrideMergePreservesAllEvidenceFromCurrentRunCandidate()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var target = await fixture.AddIdentityAsync("Sabrina", [UnitVector(0)]);
|
||||
var request = fixture.CreateRequest("Guest-01", sampleCount: 3, attendees: ["Sabrina"]);
|
||||
await fixture.AddCurrentRunCandidateAsync(request, "Sabrina");
|
||||
fixture.Encoder.Vectors = Enumerable.Range(0, 3)
|
||||
.Select(index => UnitVector(0, index + 2, 0.02f))
|
||||
.ToList();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ApplySpeakerOverrideAsync(
|
||||
request,
|
||||
"Guest-01",
|
||||
"Sabrina",
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(target.Id);
|
||||
Assert.Single(saved.Snippets);
|
||||
Assert.Equal(5, saved.VoiceVectors.Count);
|
||||
Assert.Contains(saved.VoiceVectors, vector => vector.ModelId == "older-model");
|
||||
await using var context = new TestDbContextFactory(fixture.DatabasePath).CreateDbContext();
|
||||
Assert.Single(await context.SpeakerIdentities.ToListAsync());
|
||||
}
|
||||
|
||||
private static float[] UnitVector(
|
||||
int primaryDimension,
|
||||
int? secondaryDimension = null,
|
||||
float secondaryValue = 0)
|
||||
{
|
||||
var vector = new float[256];
|
||||
vector[primaryDimension] = 1;
|
||||
if (secondaryDimension is { } dimension)
|
||||
{
|
||||
vector[dimension] = secondaryValue;
|
||||
}
|
||||
|
||||
return vector;
|
||||
}
|
||||
|
||||
private static byte[] ToBytes(float[] vector)
|
||||
{
|
||||
var bytes = new byte[vector.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(vector, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string directory;
|
||||
private readonly string databasePath;
|
||||
private readonly SpeakerIdentificationOptions speakerOptions;
|
||||
|
||||
private Fixture(string directory, string databasePath, SpeakerIdentificationOptions speakerOptions)
|
||||
{
|
||||
this.directory = directory;
|
||||
this.databasePath = databasePath;
|
||||
this.speakerOptions = speakerOptions;
|
||||
}
|
||||
|
||||
public FakeEncoder Encoder { get; } = new();
|
||||
|
||||
public FakeSnippetExtractor SnippetExtractor { get; } = new();
|
||||
|
||||
public string DatabasePath => databasePath;
|
||||
|
||||
public static async Task<Fixture> CreateAsync(
|
||||
Action<ResemblyzerSpeakerRecognitionOptions>? configure = null,
|
||||
Action<SpeakerIdentificationOptions>? configureSpeaker = null)
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var databasePath = Path.Combine(directory, "speaker-identities.db");
|
||||
var speakerOptions = new SpeakerIdentificationOptions
|
||||
{
|
||||
DatabasePath = databasePath,
|
||||
MatchBatchSize = 6,
|
||||
MaxMatchCandidates = 100,
|
||||
MatchIdentityActiveAge = TimeSpan.FromDays(365),
|
||||
MinimumSampleSpeechDuration = TimeSpan.Zero,
|
||||
Resemblyzer = new ResemblyzerSpeakerRecognitionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequiredVectorsPerSpeaker = 5,
|
||||
MaxVectorsPerIdentity = 1000,
|
||||
MinimumClusterCohesion = 0.75,
|
||||
MinimumIdentitySimilarity = 0.75,
|
||||
MinimumSimilarityMargin = 0.05,
|
||||
ModelId = "resemblyzer-0.1.4-pretrained"
|
||||
}
|
||||
};
|
||||
configure?.Invoke(speakerOptions.Resemblyzer);
|
||||
configureSpeaker?.Invoke(speakerOptions);
|
||||
await using var context = new SpeakerIdentityDbContext(
|
||||
new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new Fixture(directory, databasePath, speakerOptions);
|
||||
}
|
||||
|
||||
public ResemblyzerSpeakerIdentificationService CreateService()
|
||||
{
|
||||
var appOptions = new MeetingAssistantOptions { SpeakerIdentification = speakerOptions };
|
||||
return new ResemblyzerSpeakerIdentificationService(
|
||||
new TestDbContextFactory(databasePath),
|
||||
SnippetExtractor,
|
||||
Encoder,
|
||||
new ResemblyzerVoiceClusterMatcher(
|
||||
speakerOptions.Resemblyzer,
|
||||
NullLogger<ResemblyzerVoiceClusterMatcher>.Instance),
|
||||
new ResemblyzerVoiceVectorOutlierPruner(
|
||||
speakerOptions.Resemblyzer,
|
||||
NullLogger<ResemblyzerVoiceVectorOutlierPruner>.Instance),
|
||||
Options.Create(appOptions),
|
||||
NullLogger<ResemblyzerSpeakerIdentificationService>.Instance);
|
||||
}
|
||||
|
||||
public SpeakerIdentificationRequest CreateRequest(
|
||||
string speaker,
|
||||
int sampleCount,
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
int segmentCount = 1)
|
||||
{
|
||||
var transcriptPath = Path.Combine(directory, "transcript.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
var segments = Enumerable.Range(0, segmentCount)
|
||||
.Select(index => new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(index * 30),
|
||||
TimeSpan.FromSeconds((index + 1) * 30),
|
||||
speaker,
|
||||
"enough useful words for a speaker sample"))
|
||||
.ToList();
|
||||
var segment = segments[0];
|
||||
return new SpeakerIdentificationRequest(
|
||||
Path.Combine(directory, "meeting.wav"),
|
||||
new MeetingNote(
|
||||
Path.Combine(directory, "meeting.md"),
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Test",
|
||||
Attendees = attendees?.ToList() ?? ["Chris"],
|
||||
Transcript = transcriptPath,
|
||||
AssistantContext = Path.Combine(directory, "context.md"),
|
||||
Summary = Path.Combine(directory, "summary.md")
|
||||
},
|
||||
""),
|
||||
segments,
|
||||
Enumerable.Range(0, sampleCount)
|
||||
.Select(index => new SpeakerAudioSample(speaker, segment, [(byte)(index + 1)], 100 - index))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
string? name,
|
||||
IReadOnlyList<float[]> vectors,
|
||||
IReadOnlyList<string>? candidates = null,
|
||||
IReadOnlyList<string>? aliases = null)
|
||||
{
|
||||
await using var context = new TestDbContextFactory(databasePath).CreateDbContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = name,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [],
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
VoiceVectors = vectors.Select((vector, index) => new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = speakerOptions.Resemblyzer.ModelId,
|
||||
Dimensions = 256,
|
||||
VectorBytes = ToBytes(vector),
|
||||
Fingerprint = $"known-{index}",
|
||||
CreatedAt = now.AddMinutes(index)
|
||||
}).ToList()
|
||||
};
|
||||
context.SpeakerIdentities.Add(identity);
|
||||
await context.SaveChangesAsync();
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async Task AddCurrentRunCandidateAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string candidateName)
|
||||
{
|
||||
await using var context = new TestDbContextFactory(databasePath).CreateDbContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
context.SpeakerIdentities.Add(new SpeakerIdentity
|
||||
{
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CandidateNames = [new SpeakerCandidateName { Name = candidateName }],
|
||||
Snippets = [new SpeakerSnippet { WavBytes = [9, 8, 7], CreatedAt = now }],
|
||||
VoiceVectors =
|
||||
[
|
||||
new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "older-model",
|
||||
Dimensions = 256,
|
||||
VectorBytes = ToBytes(UnitVector(1)),
|
||||
Fingerprint = "older-model-vector",
|
||||
CreatedAt = now
|
||||
}
|
||||
],
|
||||
References =
|
||||
[
|
||||
SpeakerIdentityReferences.Create(
|
||||
request.MeetingNote.Path,
|
||||
request.MeetingNote.Frontmatter.Transcript,
|
||||
now)
|
||||
]
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> LoadIdentityAsync(int id)
|
||||
{
|
||||
await using var context = new TestDbContextFactory(databasePath).CreateDbContext();
|
||||
return await context.SpeakerIdentities
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.VoiceVectors)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> LoadOnlyIdentityAsync()
|
||||
{
|
||||
await using var context = new TestDbContextFactory(databasePath).CreateDbContext();
|
||||
return await context.SpeakerIdentities
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.VoiceVectors)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEncoder : IResemblyzerVoiceEncoder
|
||||
{
|
||||
public IReadOnlyList<float[]> Vectors { get; set; } = [];
|
||||
|
||||
public List<IReadOnlyList<byte[]>> Requests { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<float[]>> EncodeAsync(
|
||||
IReadOnlyList<byte[]> wavSamples,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(wavSamples.Select(sample => sample.ToArray()).ToList());
|
||||
return Task.FromResult<IReadOnlyList<float[]>>(Vectors.Take(wavSamples.Count).ToList());
|
||||
}
|
||||
|
||||
public Task WarmUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSnippetExtractor : ISpeakerSnippetExtractor
|
||||
{
|
||||
public List<IReadOnlyList<TranscriptionSegment>> Requests { get; } = [];
|
||||
|
||||
public Task<byte[]> ExtractSnippetAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> speakerSegments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(speakerSegments.ToList());
|
||||
return Task.FromResult<byte[]>([checked((byte)Requests.Count)]);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string databasePath;
|
||||
|
||||
public TestDbContextFactory(string databasePath)
|
||||
{
|
||||
this.databasePath = databasePath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(
|
||||
new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ResemblyzerSpeakerIdentityMergeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MergeRecentIdentitiesRequiresTwoDisjointMatchingVectorClusters()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var databasePath = Path.Combine(directory, "identities.db");
|
||||
var factory = new TestDbContextFactory(databasePath);
|
||||
await using (var context = factory.CreateDbContext())
|
||||
{
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
context.SpeakerIdentities.Add(CreateIdentity(
|
||||
"Chris",
|
||||
DateTimeOffset.UtcNow.AddMonths(-2),
|
||||
Enumerable.Range(0, 10)
|
||||
.Select(index => UnitVector(0, index + 20, 0.02f))
|
||||
.ToList(),
|
||||
directory));
|
||||
context.SpeakerIdentities.Add(CreateIdentity(
|
||||
"Chris duplicate",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
Enumerable.Range(0, 10)
|
||||
.Select(index => UnitVector(0, index + 2, 0.02f))
|
||||
.Concat(Enumerable.Range(0, 4)
|
||||
.Select(index => UnitVector(1, index + 40, 0.02f)))
|
||||
.ToList(),
|
||||
directory));
|
||||
context.SpeakerIdentities.Add(CreateIdentity(
|
||||
"Decoy",
|
||||
DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
[UnitVector(0)],
|
||||
directory));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var resemblyzerOptions = new ResemblyzerSpeakerRecognitionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RequiredVectorsPerSpeaker = 5,
|
||||
MaxVectorsPerIdentity = 1000,
|
||||
MinimumClusterCohesion = 0.75,
|
||||
MinimumIdentitySimilarity = 0.75,
|
||||
MinimumSimilarityMargin = 0.05,
|
||||
ModelId = "resemblyzer-0.1.4-pretrained"
|
||||
};
|
||||
var service = new ResemblyzerSpeakerIdentityMergeService(
|
||||
factory,
|
||||
new ResemblyzerVoiceClusterMatcher(
|
||||
resemblyzerOptions,
|
||||
NullLogger<ResemblyzerVoiceClusterMatcher>.Instance),
|
||||
new ResemblyzerVoiceVectorOutlierPruner(
|
||||
resemblyzerOptions,
|
||||
NullLogger<ResemblyzerVoiceVectorOutlierPruner>.Instance),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
MergeRecentIdentityAge = TimeSpan.FromDays(14),
|
||||
MaxMatchCandidates = 1,
|
||||
MaxSnippetsPerSpeaker = 3,
|
||||
Resemblyzer = resemblyzerOptions
|
||||
}
|
||||
}),
|
||||
NullLogger<ResemblyzerSpeakerIdentityMergeService>.Instance);
|
||||
|
||||
var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.MatchAttempts);
|
||||
Assert.Equal(1, result.MergedPairs);
|
||||
await using var verification = factory.CreateDbContext();
|
||||
var saved = await verification.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.VoiceVectors)
|
||||
.SingleAsync(identity => identity.CanonicalName == "Chris");
|
||||
Assert.Equal(2, await verification.SpeakerIdentities.CountAsync());
|
||||
Assert.Equal("Chris", saved.CanonicalName);
|
||||
Assert.Contains(saved.Aliases, alias => alias.Name == "Chris duplicate");
|
||||
Assert.Equal(20, saved.VoiceVectors.Count);
|
||||
Assert.All(saved.VoiceVectors, vector => Assert.True(ToVector(vector.VectorBytes)[0] > 0.9f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static SpeakerIdentity CreateIdentity(
|
||||
string name,
|
||||
DateTimeOffset createdAt,
|
||||
IReadOnlyList<float[]> vectors,
|
||||
string directory)
|
||||
{
|
||||
var transcriptPath = Path.Combine(directory, $"{Guid.NewGuid():N}.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = name,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt,
|
||||
VoiceVectors = vectors.Select((vector, index) => new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = ToBytes(vector),
|
||||
Fingerprint = $"{name}-{index}",
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
}).ToList(),
|
||||
References =
|
||||
[
|
||||
new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = Path.Combine(directory, $"{Guid.NewGuid():N}.md"),
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = createdAt
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static float[] UnitVector(int primary, int? secondary = null, float secondaryValue = 0)
|
||||
{
|
||||
var vector = new float[256];
|
||||
vector[primary] = 1;
|
||||
if (secondary is { } index)
|
||||
{
|
||||
vector[index] = secondaryValue;
|
||||
}
|
||||
|
||||
return vector;
|
||||
}
|
||||
|
||||
private static byte[] ToBytes(float[] vector)
|
||||
{
|
||||
var bytes = new byte[vector.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(vector, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static float[] ToVector(byte[] bytes)
|
||||
{
|
||||
var vector = new float[bytes.Length / sizeof(float)];
|
||||
Buffer.BlockCopy(bytes, 0, vector, 0, bytes.Length);
|
||||
return vector;
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string databasePath;
|
||||
|
||||
public TestDbContextFactory(string databasePath)
|
||||
{
|
||||
this.databasePath = databasePath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(
|
||||
new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ResemblyzerVoiceClusterMatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void CoherentSimilarClusterSelectsUnambiguousIdentity()
|
||||
{
|
||||
var matcher = CreateMatcher();
|
||||
var query = Enumerable.Range(0, 5)
|
||||
.Select(index => UnitVector(0, secondaryDimension: index + 2, secondaryValue: 0.05f))
|
||||
.ToList();
|
||||
var candidates = new[]
|
||||
{
|
||||
new ResemblyzerVoiceVectorCandidate(42, [UnitVector(0), UnitVector(0, 10, 0.02f)]),
|
||||
new ResemblyzerVoiceVectorCandidate(77, [UnitVector(1), UnitVector(1, 11, 0.02f)])
|
||||
};
|
||||
|
||||
var result = matcher.Match(query, candidates);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Equal(42, result.IdentityId);
|
||||
Assert.True(result.Cohesion >= 0.99);
|
||||
Assert.True(result.BestSimilarity >= 0.99);
|
||||
Assert.True(result.RunnerUpSimilarity < 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FourVectorsDoNotTriggerAutomaticMatching()
|
||||
{
|
||||
var result = CreateMatcher().Match(
|
||||
Enumerable.Repeat(UnitVector(0), 4).ToList(),
|
||||
[new ResemblyzerVoiceVectorCandidate(42, [UnitVector(0)])]);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Contains("4/5", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncoherentClusterIsRejectedBeforeIdentityScoring()
|
||||
{
|
||||
var result = CreateMatcher().Match(
|
||||
Enumerable.Range(0, 5).Select(index => UnitVector(index)).ToList(),
|
||||
[new ResemblyzerVoiceVectorCandidate(42, [UnitVector(0)])]);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Contains("cohesion", result.Reason);
|
||||
Assert.Null(result.BestSimilarity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimilarityBelowThresholdIsRejected()
|
||||
{
|
||||
var result = CreateMatcher().Match(
|
||||
Enumerable.Repeat(UnitVector(0), 5).ToList(),
|
||||
[new ResemblyzerVoiceVectorCandidate(42, [UnitVector(1)])]);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Contains("best similarity", result.Reason);
|
||||
Assert.Equal(0, result.BestSimilarity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimilarCandidatesWithinMarginAreRejected()
|
||||
{
|
||||
var result = CreateMatcher().Match(
|
||||
Enumerable.Repeat(UnitVector(0), 5).ToList(),
|
||||
[
|
||||
new ResemblyzerVoiceVectorCandidate(42, [UnitVector(0)]),
|
||||
new ResemblyzerVoiceVectorCandidate(77, [UnitVector(0, 1, 0.01f)])
|
||||
]);
|
||||
|
||||
Assert.False(result.Accepted);
|
||||
Assert.Contains("margin", result.Reason);
|
||||
Assert.NotNull(result.RunnerUpSimilarity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidStoredCandidateDoesNotAbortScoringOtherIdentities()
|
||||
{
|
||||
var result = CreateMatcher().Match(
|
||||
Enumerable.Repeat(UnitVector(0), 5).ToList(),
|
||||
[
|
||||
new ResemblyzerVoiceVectorCandidate(13, [new float[256]]),
|
||||
new ResemblyzerVoiceVectorCandidate(42, [UnitVector(0)])
|
||||
]);
|
||||
|
||||
Assert.True(result.Accepted);
|
||||
Assert.Equal(42, result.IdentityId);
|
||||
}
|
||||
|
||||
private static ResemblyzerVoiceClusterMatcher CreateMatcher()
|
||||
{
|
||||
return new ResemblyzerVoiceClusterMatcher(
|
||||
new ResemblyzerSpeakerRecognitionOptions
|
||||
{
|
||||
RequiredVectorsPerSpeaker = 5,
|
||||
MinimumClusterCohesion = 0.75,
|
||||
MinimumIdentitySimilarity = 0.75,
|
||||
MinimumSimilarityMargin = 0.05
|
||||
},
|
||||
NullLogger<ResemblyzerVoiceClusterMatcher>.Instance);
|
||||
}
|
||||
|
||||
private static float[] UnitVector(
|
||||
int primaryDimension,
|
||||
int? secondaryDimension = null,
|
||||
float secondaryValue = 0)
|
||||
{
|
||||
var vector = new float[256];
|
||||
vector[primaryDimension] = 1;
|
||||
if (secondaryDimension is { } dimension)
|
||||
{
|
||||
vector[dimension] = secondaryValue;
|
||||
}
|
||||
|
||||
return vector;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ResemblyzerVoiceVectorOutlierPrunerTests
|
||||
{
|
||||
[Fact]
|
||||
public void DominantDensityClusterRemovesForeignSpeakerVectorsAtThreshold()
|
||||
{
|
||||
var identity = IdentityWithVectors(
|
||||
Enumerable.Range(0, 16)
|
||||
.Select(index => UnitVector(0, index + 2, 0.04f))
|
||||
.Concat(Enumerable.Range(0, 4)
|
||||
.Select(index => UnitVector(1, index + 30, 0.04f)))
|
||||
.ToList());
|
||||
var pruner = CreatePruner();
|
||||
|
||||
var result = pruner.Prune(identity);
|
||||
|
||||
Assert.Equal(4, result);
|
||||
Assert.Equal(16, identity.VoiceVectors.Count);
|
||||
Assert.All(
|
||||
identity.VoiceVectors,
|
||||
stored => Assert.True(SpeakerVoiceVectors.Decode(stored)[0] > 0.9f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmbiguousDenseClustersPreserveAllVectors()
|
||||
{
|
||||
var identity = IdentityWithVectors(
|
||||
Enumerable.Range(0, 10)
|
||||
.Select(index => UnitVector(0, index + 2, 0.04f))
|
||||
.Concat(Enumerable.Range(0, 10)
|
||||
.Select(index => UnitVector(1, index + 30, 0.04f)))
|
||||
.ToList());
|
||||
var pruner = CreatePruner();
|
||||
|
||||
var result = pruner.Prune(identity);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(20, identity.VoiceVectors.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BelowMinimumVectorCountPreservesAllVectorsWithoutEvaluation()
|
||||
{
|
||||
var identity = IdentityWithVectors(
|
||||
Enumerable.Range(0, 15)
|
||||
.Select(index => UnitVector(0, index + 2, 0.04f))
|
||||
.Concat(Enumerable.Range(0, 4)
|
||||
.Select(index => UnitVector(1, index + 30, 0.04f)))
|
||||
.ToList());
|
||||
var pruner = CreatePruner();
|
||||
|
||||
var result = pruner.Prune(identity);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(19, identity.VoiceVectors.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PruningPreservesOtherModelsAndMalformedStoredRows()
|
||||
{
|
||||
var identity = IdentityWithVectors(
|
||||
Enumerable.Range(0, 16)
|
||||
.Select(index => UnitVector(0, index + 2, 0.04f))
|
||||
.Concat(Enumerable.Range(0, 4)
|
||||
.Select(index => UnitVector(1, index + 30, 0.04f)))
|
||||
.ToList());
|
||||
SpeakerVoiceVectors.AddDistinct(
|
||||
identity,
|
||||
[UnitVector(2, 60, 0.04f)],
|
||||
"older-model",
|
||||
1000,
|
||||
DateTimeOffset.UtcNow);
|
||||
identity.VoiceVectors.Add(new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = [1],
|
||||
Fingerprint = "malformed",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
identity.VoiceVectors.Add(new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = new byte[256 * sizeof(float)],
|
||||
Fingerprint = "zero-magnitude",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
var pruner = CreatePruner();
|
||||
|
||||
var result = pruner.Prune(identity);
|
||||
|
||||
Assert.Equal(4, result);
|
||||
Assert.Equal(19, identity.VoiceVectors.Count);
|
||||
Assert.Contains(identity.VoiceVectors, vector => vector.ModelId == "older-model");
|
||||
Assert.Contains(identity.VoiceVectors, vector => vector.Fingerprint == "malformed");
|
||||
Assert.Contains(identity.VoiceVectors, vector => vector.Fingerprint == "zero-magnitude");
|
||||
}
|
||||
|
||||
private static ResemblyzerVoiceVectorOutlierPruner CreatePruner()
|
||||
{
|
||||
return new ResemblyzerVoiceVectorOutlierPruner(
|
||||
new ResemblyzerSpeakerRecognitionOptions
|
||||
{
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
OutlierPruningMinimumVectors = 20,
|
||||
OutlierPruningNeighborSimilarity = 0.90,
|
||||
OutlierPruningMinimumNeighbors = 3,
|
||||
OutlierPruningMinimumClusterRatio = 0.60
|
||||
},
|
||||
NullLogger<ResemblyzerVoiceVectorOutlierPruner>.Instance);
|
||||
}
|
||||
|
||||
private static SpeakerIdentity IdentityWithVectors(IReadOnlyList<float[]> vectors)
|
||||
{
|
||||
var identity = new SpeakerIdentity();
|
||||
SpeakerVoiceVectors.AddDistinct(
|
||||
identity,
|
||||
vectors,
|
||||
"resemblyzer-0.1.4-pretrained",
|
||||
1000,
|
||||
DateTimeOffset.UtcNow);
|
||||
return identity;
|
||||
}
|
||||
|
||||
private static float[] UnitVector(
|
||||
int primaryDimension,
|
||||
int secondaryDimension,
|
||||
float secondaryValue)
|
||||
{
|
||||
var vector = new float[256];
|
||||
vector[primaryDimension] = 1;
|
||||
vector[secondaryDimension] = secondaryValue;
|
||||
return vector;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ResemblyzerWarmupHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnabledWarmupStartsWithoutBlockingApplicationStartup()
|
||||
{
|
||||
var encoder = new BlockingEncoder();
|
||||
var options = new MeetingAssistantOptions();
|
||||
options.SpeakerIdentification.Resemblyzer.Enabled = true;
|
||||
var service = new ResemblyzerWarmupHostedService(
|
||||
encoder,
|
||||
Options.Create(options),
|
||||
NullLogger<ResemblyzerWarmupHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1));
|
||||
await encoder.WaitForWarmupAsync();
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(encoder.CancellationObserved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledWarmupDoesNotInvokeEncoder()
|
||||
{
|
||||
var encoder = new BlockingEncoder();
|
||||
var service = new ResemblyzerWarmupHostedService(
|
||||
encoder,
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<ResemblyzerWarmupHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(encoder.WarmupStarted);
|
||||
}
|
||||
|
||||
private sealed class BlockingEncoder : IResemblyzerVoiceEncoder
|
||||
{
|
||||
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public bool WarmupStarted { get; private set; }
|
||||
|
||||
public bool CancellationObserved { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<float[]>> EncodeAsync(
|
||||
IReadOnlyList<byte[]> wavSamples,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<float[]>>([]);
|
||||
}
|
||||
|
||||
public async Task WarmUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WarmupStarted = true;
|
||||
started.TrySetResult();
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
CancellationObserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForWarmupAsync()
|
||||
{
|
||||
return started.Task.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,22 @@ namespace MeetingAssistant.Tests;
|
||||
public sealed class SpeakerAudioSampleCollectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void CollectorWaitsForConfiguredUninterruptedSpeechDuration()
|
||||
public void DefaultSampleDurationsAreTenAndSixtySeconds()
|
||||
{
|
||||
var options = new SpeakerIdentificationOptions();
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.MinimumSampleSpeechDuration);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), options.MaximumSampleDuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorWaitsForConfiguredSpeakerAudioDuration()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSegmentGap: TimeSpan.FromSeconds(1));
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60));
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
|
||||
|
||||
collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five."));
|
||||
@@ -37,8 +46,8 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSegmentGap: TimeSpan.FromSeconds(1));
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60));
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(50)));
|
||||
|
||||
collector.TryAdd(Segment(0, 20, "Guest01", "one two three four five."));
|
||||
@@ -48,6 +57,61 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorCombinesConsecutiveSameSpeakerSegmentsAcrossProviderPauses()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(10),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60));
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(20)));
|
||||
|
||||
collector.TryAdd(Segment(0, 4, "Guest01", "one two three four."));
|
||||
collector.TryAdd(Segment(7, 13, "Guest01", "five six seven eight."));
|
||||
|
||||
var sample = Assert.Single(collector.Snapshot());
|
||||
Assert.Equal(TimeSpan.Zero, sample.Segment.Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(13), sample.Segment.End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorDoesNotCountProviderPausesAsSpeakerAudio()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(10),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60));
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(30)));
|
||||
|
||||
collector.TryAdd(Segment(0, 4, "Guest01", "one two three four."));
|
||||
collector.TryAdd(Segment(20, 25, "Guest01", "five six seven eight."));
|
||||
|
||||
Assert.Empty(collector.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorCapsRecognitionWavAtConfiguredMaximumDuration()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(10),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60));
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(75)));
|
||||
|
||||
var sample = collector.TryAdd(Segment(
|
||||
0,
|
||||
70,
|
||||
"Guest01",
|
||||
"one two three four five six seven eight nine ten."));
|
||||
|
||||
Assert.NotNull(sample);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), sample.Segment.End);
|
||||
Assert.True(ReadDuration(sample.WavBytes) <= TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorLogsWhenSampleIsDiscardedBecauseSpeechIsTooShort()
|
||||
{
|
||||
@@ -55,8 +119,8 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSegmentGap: TimeSpan.FromSeconds(1),
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60),
|
||||
logger: logger);
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
|
||||
|
||||
@@ -69,6 +133,47 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
Assert.Contains("minimum duration", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorCanResetThePendingSpanAfterEachAcceptedSample()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 5,
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(2),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60),
|
||||
requireNonOverlappingSamples: true);
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(6)));
|
||||
|
||||
collector.TryAdd(Segment(0, 2, "Guest01", "one two three."));
|
||||
collector.TryAdd(Segment(2, 4, "Guest01", "four five six."));
|
||||
collector.TryAdd(Segment(4, 6, "Guest01", "seven eight nine."));
|
||||
|
||||
var samples = collector.Snapshot().OrderBy(sample => sample.Segment.Start).ToList();
|
||||
|
||||
Assert.Collection(
|
||||
samples,
|
||||
sample => Assert.Equal((TimeSpan.Zero, TimeSpan.FromSeconds(2)), (sample.Segment.Start, sample.Segment.End)),
|
||||
sample => Assert.Equal((TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)), (sample.Segment.Start, sample.Segment.End)),
|
||||
sample => Assert.Equal((TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(6)), (sample.Segment.Start, sample.Segment.End)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResettingCollectorRejectsASegmentThatOverlapsAnAcceptedSample()
|
||||
{
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 5,
|
||||
minimumSampleSpeechDuration: TimeSpan.FromSeconds(2),
|
||||
maximumSampleDuration: TimeSpan.FromSeconds(60),
|
||||
requireNonOverlappingSamples: true);
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(4)));
|
||||
|
||||
collector.TryAdd(Segment(0, 2, "Guest01", "one two three."));
|
||||
collector.TryAdd(Segment(1, 3, "Guest01", "four five six."));
|
||||
|
||||
Assert.Single(collector.Snapshot());
|
||||
}
|
||||
|
||||
private static TranscriptionSegment Segment(
|
||||
double start,
|
||||
double end,
|
||||
|
||||
@@ -107,6 +107,9 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
return new SpeakerIdentityMergeService(
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
Matcher,
|
||||
new ResemblyzerVoiceVectorOutlierPruner(
|
||||
new ResemblyzerSpeakerRecognitionOptions(),
|
||||
NullLogger<ResemblyzerVoiceVectorOutlierPruner>.Instance),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using MeetingAssistant.Speakers;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerIdentityMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void MergeIntoRetainsNewestDistinctVoiceVectorsUpToConfiguredLimit()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var target = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = "Chris",
|
||||
VoiceVectors =
|
||||
[
|
||||
Vector("oldest", 1, now.AddMinutes(-4)),
|
||||
Vector("duplicate", 2, now.AddMinutes(-3))
|
||||
]
|
||||
};
|
||||
var source = new SpeakerIdentity
|
||||
{
|
||||
VoiceVectors =
|
||||
[
|
||||
Vector("duplicate", 2, now.AddMinutes(-2)),
|
||||
Vector("newer", 3, now.AddMinutes(-1)),
|
||||
Vector("newest", 4, now)
|
||||
]
|
||||
};
|
||||
|
||||
SpeakerIdentityMerger.MergeInto(target, source, maxSnippets: 3, maxVoiceVectors: 3);
|
||||
|
||||
Assert.Equal(["duplicate", "newer", "newest"], target.VoiceVectors.Select(vector => vector.Fingerprint).Order());
|
||||
Assert.All(target.VoiceVectors, vector => Assert.Same(target, vector.SpeakerIdentity));
|
||||
}
|
||||
|
||||
private static SpeakerVoiceVector Vector(string fingerprint, byte value, DateTimeOffset createdAt)
|
||||
{
|
||||
return new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "model",
|
||||
Dimensions = 256,
|
||||
VectorBytes = Enumerable.Repeat(value, 256 * sizeof(float)).ToArray(),
|
||||
Fingerprint = fingerprint,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerRecognitionFeatureSelectionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, typeof(SpeakerIdentityService), typeof(SpeakerIdentityMergeService), 1, false)]
|
||||
[InlineData(true, typeof(ResemblyzerSpeakerIdentificationService), typeof(ResemblyzerSpeakerIdentityMergeService), 1000, true)]
|
||||
public async Task FeatureFlagSelectsExactlyOneIdentificationBackend(
|
||||
bool enabled,
|
||||
Type expectedIdentificationType,
|
||||
Type expectedMergeType,
|
||||
int expectedMinimumSamples,
|
||||
bool expectedNonOverlappingSamples)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
|
||||
["MeetingAssistant:SpeakerIdentification:DatabasePath"] = Path.Combine(directory, "identities.db"),
|
||||
["MeetingAssistant:SpeakerIdentification:PyannoteValidation:Enabled"] = "false",
|
||||
["MeetingAssistant:SpeakerIdentification:Resemblyzer:Enabled"] = enabled.ToString()
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IResemblyzerVoiceEncoder>();
|
||||
services.AddSingleton<IResemblyzerVoiceEncoder, NoopEncoder>();
|
||||
});
|
||||
});
|
||||
|
||||
var service = factory.Services.GetRequiredService<ISpeakerIdentificationService>();
|
||||
|
||||
Assert.IsType(expectedIdentificationType, service);
|
||||
Assert.Single(factory.Services.GetServices<ISpeakerIdentificationService>());
|
||||
var mergeService = factory.Services.GetRequiredService<ISpeakerIdentityMergeService>();
|
||||
Assert.IsType(expectedMergeType, mergeService);
|
||||
Assert.Single(factory.Services.GetServices<ISpeakerIdentityMergeService>());
|
||||
var policy = factory.Services.GetRequiredService<SpeakerSampleCollectionPolicy>();
|
||||
Assert.Equal(expectedMinimumSamples, policy.MinimumRetainedSamples);
|
||||
Assert.Equal(expectedNonOverlappingSamples, policy.RequireNonOverlappingSamples);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopEncoder : IResemblyzerVoiceEncoder
|
||||
{
|
||||
public Task<IReadOnlyList<float[]>> EncodeAsync(
|
||||
IReadOnlyList<byte[]> wavSamples,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<float[]>>([]);
|
||||
}
|
||||
|
||||
public Task WarmUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerSampleSpanSelectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpanCombinesLinesAndCapsTheClip()
|
||||
{
|
||||
var span = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpan(
|
||||
[
|
||||
Segment(0, 4),
|
||||
Segment(7, 13),
|
||||
Segment(20, 70)
|
||||
],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.Equal(3, span.Count);
|
||||
Assert.Equal(TimeSpan.Zero, span[0].Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), span[^1].End);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), SpeakerSampleSpanSelector.SpanDuration(span));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpanDoesNotCountProviderPausesAsSpeakerAudio()
|
||||
{
|
||||
var span = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpan(
|
||||
[
|
||||
Segment(0, 4),
|
||||
Segment(20, 25)
|
||||
],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.Empty(span);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpansReturnsDistinctMinimumLengthSamples()
|
||||
{
|
||||
var segments = Enumerable.Range(0, 6)
|
||||
.Select(index => new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(index * 10),
|
||||
TimeSpan.FromSeconds((index + 1) * 10),
|
||||
"Guest01",
|
||||
$"segment {index} has enough words"))
|
||||
.ToList();
|
||||
|
||||
var spans = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpans(
|
||||
segments,
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60),
|
||||
maxSpans: 5);
|
||||
|
||||
Assert.Equal(5, spans.Count);
|
||||
Assert.All(spans, span => Assert.True(SpeakerSampleSpanSelector.SpanDuration(span) >= TimeSpan.FromSeconds(10)));
|
||||
Assert.All(
|
||||
spans.Zip(spans.Skip(1)),
|
||||
pair => Assert.True(pair.First[^1].End <= pair.Second[0].Start));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpansSkipsSegmentsThatOverlapACompletedSample()
|
||||
{
|
||||
var spans = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpans(
|
||||
[
|
||||
Segment(0, 10),
|
||||
Segment(9, 19),
|
||||
Segment(20, 30)
|
||||
],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60),
|
||||
maxSpans: 5);
|
||||
|
||||
Assert.Equal(2, spans.Count);
|
||||
Assert.Equal(TimeSpan.Zero, spans[0][0].Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), spans[1][0].Start);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpansCombinesConsecutiveLinesAcrossProviderPauses()
|
||||
{
|
||||
var spans = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpans(
|
||||
[
|
||||
Segment(0, 4),
|
||||
Segment(7, 13)
|
||||
],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60),
|
||||
maxSpans: 5);
|
||||
|
||||
var span = Assert.Single(spans);
|
||||
Assert.Equal(TimeSpan.Zero, span[0].Start);
|
||||
Assert.Equal(TimeSpan.FromSeconds(13), span[^1].End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpansDoesNotCountProviderPausesAsSpeakerAudio()
|
||||
{
|
||||
var spans = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpans(
|
||||
[
|
||||
Segment(0, 4),
|
||||
Segment(20, 25)
|
||||
],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60),
|
||||
maxSpans: 5);
|
||||
|
||||
Assert.Empty(spans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestSameSpeakerSpansCapsFinalizedSamplesAtConfiguredMaximumDuration()
|
||||
{
|
||||
var spans = SpeakerSampleSpanSelector.SelectBestSameSpeakerSpans(
|
||||
[Segment(0, 70)],
|
||||
"Guest01",
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(60),
|
||||
maxSpans: 5);
|
||||
|
||||
var span = Assert.Single(spans);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), SpeakerSampleSpanSelector.SpanDuration(span));
|
||||
}
|
||||
|
||||
private static TranscriptionSegment Segment(double start, double end)
|
||||
{
|
||||
return new TranscriptionSegment(
|
||||
TimeSpan.FromSeconds(start),
|
||||
TimeSpan.FromSeconds(end),
|
||||
"Guest01",
|
||||
"enough words for this segment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class SpeakerVoiceVectorPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IdentityStoresVersionedVoiceVectorAndDeletesItWithIdentity()
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"speaker-identities.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
|
||||
var options = new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath}")
|
||||
.Options;
|
||||
|
||||
await using (var context = new SpeakerIdentityDbContext(options))
|
||||
{
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
context.SpeakerIdentities.Add(new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = "Chris",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
VoiceVectors =
|
||||
[
|
||||
new SpeakerVoiceVector
|
||||
{
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = new byte[256 * sizeof(float)],
|
||||
Fingerprint = "vector-a",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
]
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var context = new SpeakerIdentityDbContext(options))
|
||||
{
|
||||
var identity = await context.SpeakerIdentities
|
||||
.Include(candidate => candidate.VoiceVectors)
|
||||
.SingleAsync();
|
||||
var vector = Assert.Single(identity.VoiceVectors);
|
||||
Assert.Equal("resemblyzer-0.1.4-pretrained", vector.ModelId);
|
||||
Assert.Equal(256, vector.Dimensions);
|
||||
Assert.Equal(256 * sizeof(float), vector.VectorBytes.Length);
|
||||
|
||||
context.SpeakerIdentities.Remove(identity);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var context = new SpeakerIdentityDbContext(options))
|
||||
{
|
||||
Assert.Empty(await context.SpeakerVoiceVectors.ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IdentityRejectsDuplicateVoiceVectorFingerprint()
|
||||
{
|
||||
var databasePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"meeting-assistant-tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"speaker-identities.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
|
||||
var options = new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={databasePath}")
|
||||
.Options;
|
||||
await using var context = new SpeakerIdentityDbContext(options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = "Chris",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
context.SpeakerIdentities.Add(identity);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
context.SpeakerVoiceVectors.AddRange(
|
||||
CreateVector(identity.Id, "same-vector"),
|
||||
CreateVector(identity.Id, "same-vector"));
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => context.SaveChangesAsync());
|
||||
}
|
||||
|
||||
private static SpeakerVoiceVector CreateVector(int identityId, string fingerprint)
|
||||
{
|
||||
return new SpeakerVoiceVector
|
||||
{
|
||||
SpeakerIdentityId = identityId,
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = new byte[256 * sizeof(float)],
|
||||
Fingerprint = fingerprint,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,8 @@ public sealed class TaskbarIconTests
|
||||
menu,
|
||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||
("Microphone", MeetingTaskbarAction.OpenSubmenu, true),
|
||||
("Pause transcription", MeetingTaskbarAction.PauseTranscription, true),
|
||||
("Microphone", MeetingTaskbarAction.OpenSubmenu, false),
|
||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
||||
("Switch to english\tCtrl+Alt+L", MeetingTaskbarAction.SwitchProfile, false),
|
||||
("Exit", MeetingTaskbarAction.Exit, true));
|
||||
@@ -94,10 +95,32 @@ public sealed class TaskbarIconTests
|
||||
menu,
|
||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, true),
|
||||
("Pause transcription", MeetingTaskbarAction.PauseTranscription, true),
|
||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
||||
("Exit", MeetingTaskbarAction.Exit, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TranscriptionPauseActionTracksActivePauseStateWithoutHidingFinish()
|
||||
{
|
||||
var runningMenu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||
[Profile("default")]);
|
||||
var pausedMenu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default", isPaused: true),
|
||||
[Profile("default")]);
|
||||
|
||||
Assert.Contains(runningMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.PauseTranscription &&
|
||||
item.Text == "Pause transcription");
|
||||
Assert.Contains(pausedMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.UnpauseTranscription &&
|
||||
item.Text == "Unpause transcription");
|
||||
Assert.Contains(pausedMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StopRecording &&
|
||||
item.Text == "Finish meeting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
|
||||
{
|
||||
@@ -200,7 +223,8 @@ public sealed class TaskbarIconTests
|
||||
private static RecordingStatus Status(
|
||||
bool isRecording = false,
|
||||
RecordingProcessState state = RecordingProcessState.Idle,
|
||||
string? profile = null)
|
||||
string? profile = null,
|
||||
bool isPaused = false)
|
||||
{
|
||||
return new RecordingStatus(
|
||||
isRecording,
|
||||
@@ -209,7 +233,8 @@ public sealed class TaskbarIconTests
|
||||
state == RecordingProcessState.Idle ? null : "context.md",
|
||||
state == RecordingProcessState.Idle ? null : "summary.md",
|
||||
state,
|
||||
profile);
|
||||
profile,
|
||||
isPaused);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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(
|
||||
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<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);
|
||||
}
|
||||
@@ -864,6 +864,32 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsExposeVoiceVectorCountsSeparatelyFromWavSamples()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [1, 2, 3]);
|
||||
fixture.Context.SpeakerVoiceVectors.Add(new SpeakerVoiceVector
|
||||
{
|
||||
SpeakerIdentityId = identity.Id,
|
||||
ModelId = "resemblyzer-0.1.4-pretrained",
|
||||
Dimensions = 256,
|
||||
VectorBytes = new byte[256 * sizeof(float)],
|
||||
Fingerprint = "vector-1",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
var tools = fixture.CreateTools();
|
||||
|
||||
var searchResult = await tools.SearchIdentities("Sabrina");
|
||||
var readResult = await tools.ReadIdentity(identity.Id);
|
||||
|
||||
Assert.Contains("\"sampleCount\": 1", searchResult);
|
||||
Assert.Contains("\"voiceVectorCount\": 1", searchResult);
|
||||
Assert.Contains("\"voiceVectorCount\": 1", readResult);
|
||||
Assert.DoesNotContain("vector-1", readResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsRefusesSamplelessSpeakerIdentityCreation()
|
||||
{
|
||||
@@ -872,7 +898,7 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
var result = await tools.CreateIdentity("Sabrina", ["Sabi"], ["Guest-01"]);
|
||||
|
||||
Assert.StartsWith("Refused: speaker identities require at least one audio sample.", result);
|
||||
Assert.StartsWith("Refused: speaker identities require audio evidence", result);
|
||||
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user