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:
dh
2026-09-11 19:16:53 +02:00
71 changed files with 7513 additions and 453 deletions
@@ -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
};
}
}
+29 -4
View File
@@ -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());
}