Harden speaker identity samples
PR and Push Build/Test / build-and-test (push) Successful in 7m0s

This commit is contained in:
2026-05-28 12:02:44 +02:00
parent c84f8a984a
commit a72cda0c03
23 changed files with 1251 additions and 123 deletions
@@ -118,9 +118,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
Assert.Null(match); Assert.Null(match);
} }
[Fact]
public async Task MatcherReturnsNullWhenSecondaryValidatorRejectsPrimaryMatch()
{
var diarizationClient = new FakeSpeakerIdentityDiarizationClient(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-1", "known"),
new TranscriptionSegment(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3), "Guest-1", "unknown")
]);
var validator = new RejectingSpeakerIdentityMatchValidator();
var matcher = CreateMatcher(diarizationClient, validator: validator);
var snippet = CreateWavSnippet();
var match = await matcher.MatchAsync(
new SpeakerIdentityMatchRequest(
"Guest03",
snippet,
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
CancellationToken.None);
Assert.Null(match);
Assert.Equal(1, validator.MatchValidationCount);
}
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher( private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(
ISpeakerIdentityDiarizationClient diarizationClient, ISpeakerIdentityDiarizationClient diarizationClient,
TimeSpan? matchTimeout = null) TimeSpan? matchTimeout = null,
ISpeakerIdentityMatchValidator? validator = null)
{ {
return new AzureSpeechSpeakerIdentityMatcher( return new AzureSpeechSpeakerIdentityMatcher(
diarizationClient, diarizationClient,
@@ -132,7 +156,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1) MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1)
} }
}), }),
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance); NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance,
validator ?? new NoopSpeakerIdentityMatchValidator());
} }
private static byte[] CreateWavSnippet() private static byte[] CreateWavSnippet()
@@ -177,4 +202,22 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
return []; return [];
} }
} }
private sealed class RejectingSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public int MatchValidationCount { get; private set; }
public Task<bool> ValidateSampleAsync(byte[] wavBytes, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
MatchValidationCount++;
return Task.FromResult(false);
}
}
} }
@@ -0,0 +1,137 @@
using MeetingAssistant;
using MeetingAssistant.Recording;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class PyannoteSpeakerIdentityMatchValidatorTests
{
[Fact]
public async Task ValidatorRejectsSampleWhenPyannoteReportsMultipleSpeakers()
{
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":10.0,"speaker":"SPEAKER_00"},{"start":10.0,"end":20.0,"speaker":"SPEAKER_01"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var validator = CreateValidator(commandRunner);
var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(20)), CancellationToken.None);
Assert.False(valid);
}
[Fact]
public async Task ValidatorAcceptsMatchWhenPyannoteAssignsKnownAndUnknownSamplesToSameSpeaker()
{
var commandRunner = new CapturingCommandRunner(
"""
__MEETING_ASSISTANT_PYANNOTE_JSON_START__
[{"start":0.0,"end":1.0,"speaker":"SPEAKER_00"},{"start":2.0,"end":3.0,"speaker":"SPEAKER_00"}]
__MEETING_ASSISTANT_PYANNOTE_JSON_END__
""");
var validator = CreateValidator(commandRunner);
var wav = CreateWav(TimeSpan.FromSeconds(1));
var valid = await validator.ValidateMatchAsync(
new SpeakerIdentityMatchValidationRequest("Guest03", 42, wav, [wav]),
CancellationToken.None);
Assert.True(valid);
}
[Fact]
public async Task DisabledValidatorDoesNotRunPyannote()
{
var commandRunner = new CapturingCommandRunner("");
var validator = CreateValidator(commandRunner, enabled: false);
var valid = await validator.ValidateSampleAsync(CreateWav(TimeSpan.FromSeconds(1)), CancellationToken.None);
Assert.True(valid);
Assert.Empty(commandRunner.Commands);
}
private static PyannoteSpeakerIdentityMatchValidator CreateValidator(
CapturingCommandRunner commandRunner,
bool enabled = true)
{
var finalizer = new PyannoteTranscriptFinalizer(
commandRunner,
Options.Create(new MeetingAssistantOptions()),
NullLogger<PyannoteTranscriptFinalizer>.Instance);
return new PyannoteSpeakerIdentityMatchValidator(
finalizer,
Options.Create(new MeetingAssistantOptions
{
SpeakerIdentification = new SpeakerIdentificationOptions
{
PyannoteValidation = new SpeakerIdentityPyannoteValidationOptions
{
Enabled = enabled,
MinimumSingleSpeakerCoverage = 0.90,
MinimumMatchingKnownSnippetRatio = 1,
Diarization = new PyannoteDiarizationOptions
{
Enabled = true,
BuildImage = false,
DockerCommand = "docker",
Image = "meeting-assistant-pyannote:local",
ModelsFolder = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-tests",
Guid.NewGuid().ToString("N"),
"models"),
Token = "hf_test",
TokenEnv = "",
CommandTimeout = TimeSpan.FromMinutes(1),
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
}
}
}
}),
NullLogger<PyannoteSpeakerIdentityMatchValidator>.Instance);
}
private static byte[] CreateWav(TimeSpan duration)
{
const int sampleRate = 16000;
using var stream = new MemoryStream();
using (var writer = new WaveFileWriter(stream, new WaveFormat(sampleRate, 16, 1)))
{
var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * sizeof(short))];
writer.Write(bytes, 0, bytes.Length);
}
return stream.ToArray();
}
private sealed class CapturingCommandRunner : ICommandRunner
{
private readonly string output;
public CapturingCommandRunner(string output)
{
this.output = output;
}
public IReadOnlyList<CapturedCommand> Commands { get; private set; } = [];
public Task<CommandResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string>? environment = null)
{
Commands = Commands.Append(new CapturedCommand(fileName, arguments)).ToList();
return Task.FromResult(new CommandResult(0, output, ""));
}
}
private sealed record CapturedCommand(string FileName, IReadOnlyList<string> Arguments);
}
@@ -984,7 +984,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions SpeakerIdentification = new SpeakerIdentificationOptions
{ {
InitialDelay = TimeSpan.Zero, InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10) Interval = TimeSpan.FromMilliseconds(10),
MinimumSampleSpeechDuration = TimeSpan.Zero
} }
}), }),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1031,7 +1032,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions SpeakerIdentification = new SpeakerIdentificationOptions
{ {
InitialDelay = TimeSpan.Zero, InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(10) Interval = TimeSpan.FromMilliseconds(10),
MinimumSampleSpeechDuration = TimeSpan.Zero
} }
}), }),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1066,7 +1068,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions SpeakerIdentification = new SpeakerIdentificationOptions
{ {
InitialDelay = TimeSpan.Zero, InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20) Interval = TimeSpan.FromMilliseconds(20),
MinimumSampleSpeechDuration = TimeSpan.Zero
} }
}), }),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1103,7 +1106,8 @@ public sealed class RecordingCoordinatorTests
SpeakerIdentification = new SpeakerIdentificationOptions SpeakerIdentification = new SpeakerIdentificationOptions
{ {
InitialDelay = TimeSpan.Zero, InitialDelay = TimeSpan.Zero,
Interval = TimeSpan.FromMilliseconds(20) Interval = TimeSpan.FromMilliseconds(20),
MinimumSampleSpeechDuration = TimeSpan.Zero
} }
}), }),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance,
@@ -1672,6 +1676,7 @@ public sealed class RecordingCoordinatorTests
values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true"; values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true";
values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00"; values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00";
values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050"; values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050";
values["MeetingAssistant:SpeakerIdentification:MinimumSampleSpeechDuration"] = "00:00:00";
} }
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -1708,7 +1713,7 @@ public sealed class RecordingCoordinatorTests
var deadline = DateTimeOffset.UtcNow.AddSeconds(5); var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline) while (DateTimeOffset.UtcNow < deadline)
{ {
if (segments.Any(segment => segment.Text?.Contains(text, StringComparison.Ordinal) == true)) if (segments.Any(segment => segment?.Text?.Contains(text, StringComparison.Ordinal) == true))
{ {
return; return;
} }
@@ -0,0 +1,76 @@
using MeetingAssistant.Recording;
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Tests;
public sealed class SpeakerAudioSampleCollectorTests
{
[Fact]
public void CollectorWaitsForConfiguredUninterruptedSpeechDuration()
{
var collector = new SpeakerAudioSampleCollector(
TimeSpan.FromMinutes(2),
maxSamplesPerSpeaker: 3,
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
maximumSegmentGap: TimeSpan.FromSeconds(1));
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five."));
collector.TryAdd(Segment(12, 24, "Guest01", "six seven eight nine ten."));
Assert.Empty(collector.Snapshot());
collector.TryAdd(Segment(24, 31, "Guest01", "eleven twelve thirteen fourteen fifteen."));
var sample = Assert.Single(collector.Snapshot());
Assert.Equal("Guest01", sample.Speaker);
Assert.Equal(TimeSpan.Zero, sample.Segment.Start);
Assert.Equal(TimeSpan.FromSeconds(31), sample.Segment.End);
Assert.True(ReadDuration(sample.WavBytes) >= TimeSpan.FromSeconds(30));
}
[Fact]
public void CollectorDoesNotCombineSpeechAcrossDifferentSpeakerInterruption()
{
var collector = new SpeakerAudioSampleCollector(
TimeSpan.FromMinutes(2),
maxSamplesPerSpeaker: 3,
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
maximumSegmentGap: TimeSpan.FromSeconds(1));
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(50)));
collector.TryAdd(Segment(0, 20, "Guest01", "one two three four five."));
collector.TryAdd(Segment(20, 22, "Guest02", "interrupting now."));
collector.TryAdd(Segment(22, 35, "Guest01", "six seven eight nine ten."));
Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01");
}
private static TranscriptionSegment Segment(
double start,
double end,
string speaker,
string text)
{
return new TranscriptionSegment(
TimeSpan.FromSeconds(start),
TimeSpan.FromSeconds(end),
speaker,
text);
}
private static AudioChunk CreateAudio(TimeSpan duration)
{
const int sampleRate = 16000;
const int channels = 1;
var bytes = new byte[(int)(duration.TotalSeconds * sampleRate * channels * sizeof(short))];
return new AudioChunk(bytes, sampleRate, channels);
}
private static TimeSpan ReadDuration(byte[] wavBytes)
{
using var reader = new WaveFileReader(new MemoryStream(wavBytes));
return reader.TotalTime;
}
}
@@ -199,6 +199,60 @@ public sealed class SpeakerIdentityServiceTests
Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath)); Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath));
} }
[Fact]
public async Task UnmatchedSpeakerIsNotLearnedWhenSecondaryValidationRejectsSample()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
fixture.MatchValidator.SampleIsValid = false;
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]),
CancellationToken.None);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact]
public async Task FinishedSnippetExtractionWaitsForMinimumContinuousSpeechDuration()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30));
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "short")]),
CancellationToken.None);
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact]
public async Task FinishedSnippetExtractionDoesNotCombineAcrossSpeakerInterruption()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync(options =>
options.MinimumSampleSpeechDuration = TimeSpan.FromSeconds(30));
var service = fixture.CreateService();
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Manuel"],
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(20), "Guest01", "first"),
new TranscriptionSegment(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(22), "Guest02", "interrupting"),
new TranscriptionSegment(TimeSpan.FromSeconds(22), TimeSpan.FromSeconds(35), "Guest01", "second")
]),
CancellationToken.None);
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
Assert.Empty(fixture.Context.SpeakerIdentities);
}
[Fact] [Fact]
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile() public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
{ {
@@ -450,6 +504,87 @@ public sealed class SpeakerIdentityServiceTests
File.Delete(dbPath); File.Delete(dbPath);
} }
[Fact]
public async Task SpeakerOverrideCreatesIdentityWhenLegacyTranscriptionCountHasNoDatabaseDefault()
{
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
await using var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerIdentities" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentities" PRIMARY KEY AUTOINCREMENT,
"CanonicalName" TEXT NULL,
"TranscriptionCount" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerCandidateNames" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerCandidateNames" PRIMARY KEY AUTOINCREMENT,
"SpeakerIdentityId" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
CONSTRAINT "FK_SpeakerCandidateNames_SpeakerIdentities_SpeakerIdentityId"
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
);
""");
await context.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE "SpeakerSnippets" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerSnippets" PRIMARY KEY AUTOINCREMENT,
"SpeakerIdentityId" INTEGER NOT NULL,
"WavBytes" BLOB NOT NULL,
"CreatedAt" TEXT NOT NULL,
CONSTRAINT "FK_SpeakerSnippets_SpeakerIdentities_SpeakerIdentityId"
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
);
""");
var options = new SpeakerIdentificationOptions
{
DatabasePath = dbPath,
MinimumSampleSpeechDuration = TimeSpan.Zero
};
var service = new SpeakerIdentityService(
new TestSpeakerIdentityDbContextFactory(dbPath),
new FakeSpeakerSnippetExtractor(),
new FakeSpeakerIdentityMatcher(),
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance,
new FakeSpeakerIdentityMatchValidator());
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Daniel");
var root = Path.GetDirectoryName(dbPath)!;
var transcriptPath = Path.Combine(root, "transcript.md");
await File.WriteAllTextAsync(transcriptPath, "Transcript");
var request = new SpeakerIdentificationRequest(
"test.wav",
new MeetingNote(
Path.Combine(root, "meeting.md"),
new MeetingNoteFrontmatter
{
Title = "Test Meeting",
StartTime = DateTimeOffset.Parse("2026-05-28T10:00:00+02:00"),
Attendees = ["Hecht, Daniel"],
Transcript = transcriptPath,
AssistantContext = Path.Combine(root, "context.md"),
Summary = Path.Combine(root, "summary.md")
},
""),
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Hecht, Daniel", CancellationToken.None);
context.ChangeTracker.Clear();
var saved = await context.SpeakerIdentities.SingleAsync();
Assert.Equal("Hecht, Daniel", saved.CanonicalName);
Assert.Equal(0, saved.TranscriptionCount);
File.Delete(dbPath);
}
private sealed class SpeakerIdentityFixture : IAsyncDisposable private sealed class SpeakerIdentityFixture : IAsyncDisposable
{ {
private readonly string tempDirectory; private readonly string tempDirectory;
@@ -472,6 +607,8 @@ public sealed class SpeakerIdentityServiceTests
public FakeSpeakerIdentityMatcher Matcher { get; } = new(); public FakeSpeakerIdentityMatcher Matcher { get; } = new();
public FakeSpeakerIdentityMatchValidator MatchValidator { get; } = new();
public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new(); public FakeSpeakerSnippetExtractor SnippetExtractor { get; } = new();
public static async Task<SpeakerIdentityFixture> CreateAsync( public static async Task<SpeakerIdentityFixture> CreateAsync(
@@ -484,7 +621,8 @@ public sealed class SpeakerIdentityServiceTests
{ {
DatabasePath = dbPath, DatabasePath = dbPath,
MaxSnippetsPerSpeaker = 3, MaxSnippetsPerSpeaker = 3,
MatchBatchSize = 6 MatchBatchSize = 6,
MinimumSampleSpeechDuration = TimeSpan.Zero
}; };
configureOptions?.Invoke(options); configureOptions?.Invoke(options);
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>() var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
@@ -501,7 +639,8 @@ public sealed class SpeakerIdentityServiceTests
SnippetExtractor, SnippetExtractor,
Matcher, Matcher,
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }), Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
NullLogger<SpeakerIdentityService>.Instance); NullLogger<SpeakerIdentityService>.Instance,
MatchValidator);
} }
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer() public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
@@ -627,6 +766,11 @@ public sealed class SpeakerIdentityServiceTests
IReadOnlyList<TranscriptionSegment> speakerSegments, IReadOnlyList<TranscriptionSegment> speakerSegments,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (speakerSegments.Count == 0)
{
return Task.FromResult<byte[]>([]);
}
ExtractCalls++; ExtractCalls++;
return Task.FromResult<byte[]>([7, 8, 9]); return Task.FromResult<byte[]>([7, 8, 9]);
} }
@@ -657,4 +801,23 @@ public sealed class SpeakerIdentityServiceTests
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value)); return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(MatchIdentityId.Value));
} }
} }
private sealed class FakeSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public bool SampleIsValid { get; set; } = true;
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
return Task.FromResult(SampleIsValid);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
} }
@@ -293,6 +293,10 @@ public sealed class SpeakerIdentificationOptions
public int MaxSnippetsPerSpeaker { get; set; } = 3; public int MaxSnippetsPerSpeaker { get; set; } = 3;
public TimeSpan MinimumSampleSpeechDuration { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan MaximumSampleSegmentGap { get; set; } = TimeSpan.FromSeconds(1);
public double SilenceBetweenSnippetsSeconds { get; set; } = 1; public double SilenceBetweenSnippetsSeconds { get; set; } = 1;
public TimeSpan LiveSampleBufferDuration { get; set; } = TimeSpan.FromMinutes(10); public TimeSpan LiveSampleBufferDuration { get; set; } = TimeSpan.FromMinutes(10);
@@ -302,6 +306,23 @@ public sealed class SpeakerIdentificationOptions
public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3); public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3);
public AzureSpeechOptions AzureSpeech { get; set; } = new(); public AzureSpeechOptions AzureSpeech { get; set; } = new();
public SpeakerIdentityPyannoteValidationOptions PyannoteValidation { get; set; } = new();
}
public sealed class SpeakerIdentityPyannoteValidationOptions
{
public bool Enabled { get; set; }
public double MinimumSingleSpeakerCoverage { get; set; } = 0.90;
public double MinimumMatchingKnownSnippetRatio { get; set; } = 0.50;
public PyannoteDiarizationOptions Diarization { get; set; } = new()
{
Enabled = true,
AlignmentMode = PyannoteAlignmentMode.PyannoteTurns
};
} }
public sealed class AgentOptions public sealed class AgentOptions
+1
View File
@@ -50,6 +50,7 @@ builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOpti
}); });
builder.Services.AddSingleton<ISpeakerSnippetExtractor, WavSpeakerSnippetExtractor>(); builder.Services.AddSingleton<ISpeakerSnippetExtractor, WavSpeakerSnippetExtractor>();
builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpeakerIdentityDiarizationClient>(); builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpeakerIdentityDiarizationClient>();
builder.Services.AddSingleton<ISpeakerIdentityMatchValidator, PyannoteSpeakerIdentityMatchValidator>();
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>(); builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>(); builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>(); builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
@@ -1334,7 +1334,11 @@ public sealed class MeetingRecordingCoordinator
Options = options; Options = options;
LaunchProfileName = launchProfileName; LaunchProfileName = launchProfileName;
pipelines.Add(pipeline); pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples); speakerSampleCollector = new SpeakerAudioSampleCollector(
liveSampleBufferDuration,
maxSpeakerSamples,
options.SpeakerIdentification.MinimumSampleSpeechDuration,
options.SpeakerIdentification.MaximumSampleSegmentGap);
} }
public CancellationTokenSource CaptureCancellationSource { get; } public CancellationTokenSource CaptureCancellationSource { get; }
@@ -9,11 +9,33 @@ internal sealed class SpeakerAudioSampleCollector
private readonly RollingAudioBuffer audioBuffer; private readonly RollingAudioBuffer audioBuffer;
private readonly Dictionary<string, List<SpeakerAudioSample>> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<SpeakerAudioSample>> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase);
private readonly int maxSamplesPerSpeaker; private readonly int maxSamplesPerSpeaker;
private readonly TimeSpan minimumUninterruptedSpeechDuration;
private readonly TimeSpan maximumSegmentGap;
private PendingSpeakerSpan? pendingSpan;
public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker) public SpeakerAudioSampleCollector(TimeSpan bufferDuration, int maxSamplesPerSpeaker)
: this(
bufferDuration,
maxSamplesPerSpeaker,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(1))
{
}
public SpeakerAudioSampleCollector(
TimeSpan bufferDuration,
int maxSamplesPerSpeaker,
TimeSpan minimumUninterruptedSpeechDuration,
TimeSpan maximumSegmentGap)
{ {
audioBuffer = new RollingAudioBuffer(bufferDuration); audioBuffer = new RollingAudioBuffer(bufferDuration);
this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker); this.maxSamplesPerSpeaker = Math.Max(1, maxSamplesPerSpeaker);
this.minimumUninterruptedSpeechDuration = minimumUninterruptedSpeechDuration > TimeSpan.Zero
? minimumUninterruptedSpeechDuration
: TimeSpan.Zero;
this.maximumSegmentGap = maximumSegmentGap >= TimeSpan.Zero
? maximumSegmentGap
: TimeSpan.Zero;
} }
public void AppendAudio(AudioChunk chunk) public void AppendAudio(AudioChunk chunk)
@@ -26,6 +48,7 @@ internal sealed class SpeakerAudioSampleCollector
lock (gate) lock (gate)
{ {
samplesBySpeaker.Clear(); samplesBySpeaker.Clear();
pendingSpan = null;
audioBuffer.Reset(); audioBuffer.Reset();
} }
} }
@@ -37,25 +60,31 @@ internal sealed class SpeakerAudioSampleCollector
return; return;
} }
var score = Score(segment); TranscriptionSegment sampleSegment;
lock (gate)
{
sampleSegment = ExtendPendingSpan(segment);
}
var score = Score(sampleSegment, minimumUninterruptedSpeechDuration);
if (score <= 0) if (score <= 0)
{ {
return; return;
} }
var wavBytes = audioBuffer.TryExtractWav(segment.Start, segment.End); var wavBytes = audioBuffer.TryExtractWav(sampleSegment.Start, sampleSegment.End);
if (wavBytes.Length == 0) if (wavBytes.Length == 0)
{ {
return; return;
} }
var sample = new SpeakerAudioSample(segment.Speaker, segment, wavBytes, score); var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score);
lock (gate) lock (gate)
{ {
if (!samplesBySpeaker.TryGetValue(segment.Speaker, out var samples)) if (!samplesBySpeaker.TryGetValue(sampleSegment.Speaker, out var samples))
{ {
samples = []; samples = [];
samplesBySpeaker[segment.Speaker] = samples; samplesBySpeaker[sampleSegment.Speaker] = samples;
} }
samples.Add(sample); samples.Add(sample);
@@ -86,10 +115,29 @@ internal sealed class SpeakerAudioSampleCollector
!string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase); !string.Equals(speaker, "Unknown", StringComparison.OrdinalIgnoreCase);
} }
private static double Score(TranscriptionSegment segment) private TranscriptionSegment ExtendPendingSpan(TranscriptionSegment segment)
{
if (pendingSpan is null ||
!SpeakerSampleSpanSelector.CanExtend(pendingSpan.Speaker, pendingSpan.End, segment, maximumSegmentGap))
{
pendingSpan = new PendingSpeakerSpan(
segment.Speaker,
segment.Start,
segment.End,
[segment.Text]);
return pendingSpan.ToSegment();
}
pendingSpan = pendingSpan.Extend(segment);
return pendingSpan.ToSegment();
}
private static double Score(
TranscriptionSegment segment,
TimeSpan minimumUninterruptedSpeechDuration)
{ {
var durationSeconds = (segment.End - segment.Start).TotalSeconds; var durationSeconds = (segment.End - segment.Start).TotalSeconds;
if (durationSeconds < 2 || durationSeconds > 30) if (durationSeconds < minimumUninterruptedSpeechDuration.TotalSeconds)
{ {
return 0; return 0;
} }
@@ -97,13 +145,13 @@ internal sealed class SpeakerAudioSampleCollector
var words = segment.Text var words = segment.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length; .Length;
if (words < 3 || words > 80) if (words < 3)
{ {
return 0; return 0;
} }
var durationScore = 1 - Math.Min(Math.Abs(durationSeconds - 8) / 8, 1); var durationScore = Math.Min(durationSeconds / Math.Max(1, minimumUninterruptedSpeechDuration.TotalSeconds), 2);
var wordScore = 1 - Math.Min(Math.Abs(words - 18) / 18.0, 1); var wordScore = Math.Min(words / 60.0, 1);
var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') || var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') ||
segment.Text.TrimEnd().EndsWith('?') || segment.Text.TrimEnd().EndsWith('?') ||
segment.Text.TrimEnd().EndsWith('!') segment.Text.TrimEnd().EndsWith('!')
@@ -111,4 +159,29 @@ internal sealed class SpeakerAudioSampleCollector
: 0; : 0;
return durationScore * 70 + wordScore * 30 + sentenceBonus; return durationScore * 70 + wordScore * 30 + sentenceBonus;
} }
private sealed record PendingSpeakerSpan(
string Speaker,
TimeSpan Start,
TimeSpan End,
IReadOnlyList<string> TextParts)
{
public PendingSpeakerSpan Extend(TranscriptionSegment segment)
{
return this with
{
End = segment.End > End ? segment.End : End,
TextParts = TextParts.Append(segment.Text).ToList()
};
}
public TranscriptionSegment ToSegment()
{
return new TranscriptionSegment(
Start,
End,
Speaker,
string.Join(' ', TextParts.Where(part => !string.IsNullOrWhiteSpace(part))));
}
}
} }
@@ -15,15 +15,18 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
private readonly ISpeakerIdentityDiarizationClient diarizationClient; private readonly ISpeakerIdentityDiarizationClient diarizationClient;
private readonly SpeakerIdentificationOptions options; private readonly SpeakerIdentificationOptions options;
private readonly ILogger<AzureSpeechSpeakerIdentityMatcher> logger; private readonly ILogger<AzureSpeechSpeakerIdentityMatcher> logger;
private readonly ISpeakerIdentityMatchValidator matchValidator;
public AzureSpeechSpeakerIdentityMatcher( public AzureSpeechSpeakerIdentityMatcher(
ISpeakerIdentityDiarizationClient diarizationClient, ISpeakerIdentityDiarizationClient diarizationClient,
Microsoft.Extensions.Options.IOptions<MeetingAssistantOptions> options, Microsoft.Extensions.Options.IOptions<MeetingAssistantOptions> options,
ILogger<AzureSpeechSpeakerIdentityMatcher> logger) ILogger<AzureSpeechSpeakerIdentityMatcher> logger,
ISpeakerIdentityMatchValidator matchValidator)
{ {
this.diarizationClient = diarizationClient; this.diarizationClient = diarizationClient;
this.options = options.Value.SpeakerIdentification; this.options = options.Value.SpeakerIdentification;
this.logger = logger; this.logger = logger;
this.matchValidator = matchValidator;
} }
public async Task<SpeakerIdentityMatch?> MatchAsync( public async Task<SpeakerIdentityMatch?> MatchAsync(
@@ -35,6 +38,14 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
return null; return null;
} }
if (!await matchValidator.ValidateSampleAsync(request.UnknownSnippet, cancellationToken))
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} skipped because secondary validation rejected the unknown sample",
request.DiarizedSpeaker);
return null;
}
logger.LogInformation( logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)", "Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)",
request.DiarizedSpeaker, request.DiarizedSpeaker,
@@ -84,6 +95,20 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
var requiredMatches = known.Count() > 1 ? 2 : 1; var requiredMatches = known.Count() > 1 ? 2 : 1;
if (matchingSnippetCount >= requiredMatches) if (matchingSnippetCount >= requiredMatches)
{ {
var validationRequest = new SpeakerIdentityMatchValidationRequest(
request.DiarizedSpeaker,
known.Key,
request.UnknownSnippet,
known.Select(segment => segment.Snippet).ToList());
if (!await matchValidator.ValidateMatchAsync(validationRequest, cancellationToken))
{
logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} rejected identity {IdentityId} after secondary validation",
request.DiarizedSpeaker,
known.Key);
continue;
}
logger.LogInformation( logger.LogInformation(
"Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}", "Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}",
request.DiarizedSpeaker, request.DiarizedSpeaker,
@@ -99,7 +124,7 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
} }
finally finally
{ {
TryDelete(tempPath); SpeakerCompositeWav.TryDelete(tempPath);
} }
} }
@@ -130,7 +155,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request) private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
{ {
using var firstReader = OpenFirstReadableWave(request); using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave(
request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet));
if (firstReader is null) if (firstReader is null)
{ {
return new CompositeLayout(null, []); return new CompositeLayout(null, []);
@@ -145,118 +171,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{ {
foreach (var snippet in candidate.Snippets.Where(storedSnippet => storedSnippet.Length > 0)) foreach (var snippet in candidate.Snippets.Where(storedSnippet => storedSnippet.Length > 0))
{ {
var segment = AppendSnippet(writer, firstReader.WaveFormat, snippet, current); var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
if (segment is null) if (segment is null)
{ {
continue; continue;
} }
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value)); knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value, snippet));
current = segment.Value.End + silence; current = segment.Value.End + silence;
WriteSilence(writer, firstReader.WaveFormat, silence); SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence);
} }
} }
var unknownSegment = AppendSnippet(writer, firstReader.WaveFormat, request.UnknownSnippet, current); var unknownSegment = SpeakerCompositeWav.AppendSnippet(
writer,
firstReader.WaveFormat,
request.UnknownSnippet,
current);
return new CompositeLayout(unknownSegment, knownSegments); return new CompositeLayout(unknownSegment, knownSegments);
} }
private static WaveFileReader? OpenFirstReadableWave(SpeakerIdentityMatchRequest request)
{
foreach (var bytes in request.Candidates.SelectMany(candidate => candidate.Snippets).Append(request.UnknownSnippet))
{
if (bytes.Length == 0)
{
continue;
}
try
{
return new WaveFileReader(new MemoryStream(bytes));
}
catch (FormatException)
{
}
}
return null;
}
private static TimeSegment? AppendSnippet(
WaveFileWriter writer,
WaveFormat targetFormat,
byte[] snippet,
TimeSpan start)
{
if (snippet.Length == 0)
{
return null;
}
using var reader = new WaveFileReader(new MemoryStream(snippet));
if (!WaveFormatsMatch(reader.WaveFormat, targetFormat))
{
throw new InvalidDataException("Speaker identity snippets must use the same WAV format.");
}
reader.CopyTo(writer);
return new TimeSegment(start, start + reader.TotalTime);
}
private static void WriteSilence(WaveFileWriter writer, WaveFormat format, TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var bytes = new byte[(int)(format.AverageBytesPerSecond * duration.TotalSeconds)];
writer.Write(bytes, 0, bytes.Length);
}
private static string? FindBestSpeaker(TimeSegment segment, IReadOnlyList<TranscriptionSegment> segments) private static string? FindBestSpeaker(TimeSegment segment, IReadOnlyList<TranscriptionSegment> segments)
{ {
var bestOverlap = 0d; return SpeakerCompositeWav.FindBestSpeaker(segment, segments);
string? bestSpeaker = null;
foreach (var transcriptSegment in segments)
{
var overlap = Math.Min(segment.End.TotalSeconds, transcriptSegment.End.TotalSeconds)
- Math.Max(segment.Start.TotalSeconds, transcriptSegment.Start.TotalSeconds);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
bestSpeaker = transcriptSegment.Speaker;
}
}
return bestSpeaker;
}
private static bool WaveFormatsMatch(WaveFormat left, WaveFormat right)
{
return left.Encoding == right.Encoding
&& left.SampleRate == right.SampleRate
&& left.Channels == right.Channels
&& left.BitsPerSample == right.BitsPerSample;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
} }
private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<KnownCompositeSegment> KnownSegments); private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<KnownCompositeSegment> KnownSegments);
private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment); private sealed record KnownCompositeSegment(int IdentityId, TimeSegment Segment, byte[] Snippet);
private readonly record struct TimeSegment(TimeSpan Start, TimeSpan End);
} }
@@ -0,0 +1,18 @@
namespace MeetingAssistant.Speakers;
public sealed class NoopSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
public Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
public Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(true);
}
}
@@ -0,0 +1,237 @@
using MeetingAssistant.Transcription;
using Microsoft.Extensions.Options;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
public sealed class PyannoteSpeakerIdentityMatchValidator : ISpeakerIdentityMatchValidator
{
private readonly PyannoteTranscriptFinalizer finalizer;
private readonly SpeakerIdentityPyannoteValidationOptions options;
private readonly ILogger<PyannoteSpeakerIdentityMatchValidator> logger;
public PyannoteSpeakerIdentityMatchValidator(
PyannoteTranscriptFinalizer finalizer,
IOptions<MeetingAssistantOptions> options,
ILogger<PyannoteSpeakerIdentityMatchValidator> logger)
{
this.finalizer = finalizer;
this.options = options.Value.SpeakerIdentification.PyannoteValidation;
this.logger = logger;
}
public async Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return true;
}
var segments = await DiarizeBytesAsync(wavBytes, cancellationToken);
var result = HasSingleSpeaker(segments);
if (!result)
{
logger.LogInformation(
"pyannote rejected speaker identity sample because it did not contain one dominant speaker");
}
return result;
}
public async Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken)
{
if (!options.Enabled)
{
return true;
}
var tempPath = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-speaker-match-pyannote",
$"{Guid.NewGuid():N}.wav");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
var layout = WriteCompositeWav(
tempPath,
request.KnownSnippets,
request.UnknownSnippet,
TimeSpan.FromSeconds(1));
if (layout.UnknownSegment is null || layout.KnownSegments.Count == 0)
{
return false;
}
var segments = await DiarizePathAsync(tempPath, cancellationToken);
var unknownSpeaker = FindBestSpeaker(layout.UnknownSegment.Value, segments);
if (string.IsNullOrWhiteSpace(unknownSpeaker))
{
logger.LogInformation(
"pyannote rejected speaker identity match for {DiarizedSpeaker} because the unknown sample had no speaker",
request.DiarizedSpeaker);
return false;
}
var compared = 0;
var matching = 0;
foreach (var knownSegment in layout.KnownSegments)
{
var knownSpeaker = FindBestSpeaker(knownSegment, segments);
if (string.IsNullOrWhiteSpace(knownSpeaker))
{
continue;
}
compared++;
if (string.Equals(knownSpeaker, unknownSpeaker, StringComparison.Ordinal))
{
matching++;
}
}
var minimumRatio = Math.Clamp(options.MinimumMatchingKnownSnippetRatio, 0, 1);
var accepted = compared > 0 && (double)matching / compared >= minimumRatio;
if (!accepted)
{
logger.LogInformation(
"pyannote rejected speaker identity match for {DiarizedSpeaker} and identity {IdentityId}: {Matching}/{Compared} known sample(s) matched",
request.DiarizedSpeaker,
request.IdentityId,
matching,
compared);
}
return accepted;
}
finally
{
SpeakerCompositeWav.TryDelete(tempPath);
}
}
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizeBytesAsync(
byte[] wavBytes,
CancellationToken cancellationToken)
{
var tempPath = Path.Combine(
Path.GetTempPath(),
"meeting-assistant-speaker-sample-pyannote",
$"{Guid.NewGuid():N}.wav");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
await File.WriteAllBytesAsync(tempPath, wavBytes, cancellationToken);
return await DiarizePathAsync(tempPath, cancellationToken);
}
finally
{
SpeakerCompositeWav.TryDelete(tempPath);
}
}
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizePathAsync(
string wavPath,
CancellationToken cancellationToken)
{
TimeSpan duration;
try
{
using var reader = new WaveFileReader(wavPath);
duration = reader.TotalTime;
}
catch (Exception exception) when (exception is IOException or InvalidDataException or FormatException)
{
logger.LogInformation(exception, "pyannote speaker identity validation could not read WAV input");
return [];
}
if (duration <= TimeSpan.Zero)
{
return [];
}
return await finalizer.FinalizeAsync(
wavPath,
[new TranscriptionSegment(TimeSpan.Zero, duration, "Unknown", "speaker identity validation sample")],
options.Diarization,
SpeechRecognitionPipelineOptions.Default,
cancellationToken);
}
private bool HasSingleSpeaker(IReadOnlyList<TranscriptionSegment> segments)
{
var durationsBySpeaker = segments
.Where(segment => !string.IsNullOrWhiteSpace(segment.Speaker) && segment.End > segment.Start)
.GroupBy(segment => segment.Speaker)
.Select(group => new
{
Speaker = group.Key,
Duration = group.Sum(segment => (segment.End - segment.Start).TotalSeconds)
})
.Where(group => group.Duration > 0)
.ToList();
if (durationsBySpeaker.Count == 0)
{
return false;
}
if (durationsBySpeaker.Count == 1)
{
return true;
}
var total = durationsBySpeaker.Sum(group => group.Duration);
var dominant = durationsBySpeaker.Max(group => group.Duration);
return total > 0 && dominant / total >= Math.Clamp(options.MinimumSingleSpeakerCoverage, 0, 1);
}
private CompositeLayout WriteCompositeWav(
string path,
IReadOnlyList<byte[]> knownSnippets,
byte[] unknownSnippet,
TimeSpan silence)
{
using var firstReader = SpeakerCompositeWav.OpenFirstReadableWave(knownSnippets.Append(unknownSnippet));
if (firstReader is null)
{
return new CompositeLayout(null, []);
}
using var writer = new WaveFileWriter(path, firstReader.WaveFormat);
var current = TimeSpan.Zero;
var knownSegments = new List<TimeSegment>();
foreach (var snippet in knownSnippets.Where(storedSnippet => storedSnippet.Length > 0))
{
var segment = SpeakerCompositeWav.AppendSnippet(writer, firstReader.WaveFormat, snippet, current);
if (segment is null)
{
continue;
}
knownSegments.Add(segment.Value);
current = segment.Value.End + silence;
SpeakerCompositeWav.WriteSilence(writer, firstReader.WaveFormat, silence);
}
var unknownSegment = SpeakerCompositeWav.AppendSnippet(
writer,
firstReader.WaveFormat,
unknownSnippet,
current);
return new CompositeLayout(unknownSegment, knownSegments);
}
private static string? FindBestSpeaker(
TimeSegment segment,
IReadOnlyList<TranscriptionSegment> segments)
{
return SpeakerCompositeWav.FindBestSpeaker(segment, segments);
}
private sealed record CompositeLayout(TimeSegment? UnknownSegment, IReadOnlyList<TimeSegment> KnownSegments);
}
@@ -0,0 +1,107 @@
using MeetingAssistant.Transcription;
using NAudio.Wave;
namespace MeetingAssistant.Speakers;
internal static class SpeakerCompositeWav
{
public static WaveFileReader? OpenFirstReadableWave(IEnumerable<byte[]> snippets)
{
foreach (var bytes in snippets)
{
if (bytes.Length == 0)
{
continue;
}
try
{
return new WaveFileReader(new MemoryStream(bytes));
}
catch (FormatException)
{
}
}
return null;
}
public static TimeSegment? AppendSnippet(
WaveFileWriter writer,
WaveFormat targetFormat,
byte[] snippet,
TimeSpan start)
{
if (snippet.Length == 0)
{
return null;
}
using var reader = new WaveFileReader(new MemoryStream(snippet));
if (!WaveFormatsMatch(reader.WaveFormat, targetFormat))
{
throw new InvalidDataException("Speaker identity snippets must use the same WAV format.");
}
reader.CopyTo(writer);
return new TimeSegment(start, start + reader.TotalTime);
}
public static void WriteSilence(
WaveFileWriter writer,
WaveFormat format,
TimeSpan duration)
{
if (duration <= TimeSpan.Zero)
{
return;
}
var bytes = new byte[(int)(format.AverageBytesPerSecond * duration.TotalSeconds)];
writer.Write(bytes, 0, bytes.Length);
}
public static string? FindBestSpeaker(
TimeSegment segment,
IReadOnlyList<TranscriptionSegment> segments)
{
var bestOverlap = 0d;
string? bestSpeaker = null;
foreach (var transcriptSegment in segments)
{
var overlap = Math.Min(segment.End.TotalSeconds, transcriptSegment.End.TotalSeconds)
- Math.Max(segment.Start.TotalSeconds, transcriptSegment.Start.TotalSeconds);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
bestSpeaker = transcriptSegment.Speaker;
}
}
return bestSpeaker;
}
public static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
}
}
private static bool WaveFormatsMatch(WaveFormat left, WaveFormat right)
{
return left.Encoding == right.Encoding
&& left.SampleRate == right.SampleRate
&& left.Channels == right.Channels
&& left.BitsPerSample == right.BitsPerSample;
}
}
internal readonly record struct TimeSegment(TimeSpan Start, TimeSpan End);
@@ -38,6 +38,12 @@ public sealed record SpeakerIdentityMatchCandidate(
public sealed record SpeakerIdentityMatch(int IdentityId); public sealed record SpeakerIdentityMatch(int IdentityId);
public sealed record SpeakerIdentityMatchValidationRequest(
string DiarizedSpeaker,
int IdentityId,
byte[] UnknownSnippet,
IReadOnlyList<byte[]> KnownSnippets);
public interface ISpeakerIdentityMatcher public interface ISpeakerIdentityMatcher
{ {
Task<SpeakerIdentityMatch?> MatchAsync( Task<SpeakerIdentityMatch?> MatchAsync(
@@ -45,6 +51,17 @@ public interface ISpeakerIdentityMatcher
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
public interface ISpeakerIdentityMatchValidator
{
Task<bool> ValidateSampleAsync(
byte[] wavBytes,
CancellationToken cancellationToken);
Task<bool> ValidateMatchAsync(
SpeakerIdentityMatchValidationRequest request,
CancellationToken cancellationToken);
}
public interface ISpeakerSnippetExtractor public interface ISpeakerSnippetExtractor
{ {
Task<byte[]> ExtractSnippetAsync( Task<byte[]> ExtractSnippetAsync(
+5 -2
View File
@@ -13,6 +13,8 @@ public sealed class SpeakerIdentity
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public int TranscriptionCount { get; set; }
public List<SpeakerCandidateName> CandidateNames { get; set; } = []; public List<SpeakerCandidateName> CandidateNames { get; set; } = [];
public List<SpeakerAlias> Aliases { get; set; } = []; public List<SpeakerAlias> Aliases { get; set; } = [];
@@ -154,8 +156,9 @@ public sealed class SpeakerIdentityDbContext : DbContext
{ {
modelBuilder.Entity<SpeakerIdentity>(entity => modelBuilder.Entity<SpeakerIdentity>(entity =>
{ {
entity.Property<int>("TranscriptionCount") entity.Property(identity => identity.TranscriptionCount)
.HasDefaultValue(0); .IsRequired()
.ValueGeneratedNever();
entity.HasMany(identity => identity.CandidateNames) entity.HasMany(identity => identity.CandidateNames)
.WithOne(candidate => candidate.SpeakerIdentity) .WithOne(candidate => candidate.SpeakerIdentity)
@@ -10,6 +10,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory; private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
private readonly ISpeakerSnippetExtractor snippetExtractor; private readonly ISpeakerSnippetExtractor snippetExtractor;
private readonly ISpeakerIdentityMatcher matcher; private readonly ISpeakerIdentityMatcher matcher;
private readonly ISpeakerIdentityMatchValidator matchValidator;
private readonly SpeakerIdentificationOptions options; private readonly SpeakerIdentificationOptions options;
private readonly ILogger<SpeakerIdentityService> logger; private readonly ILogger<SpeakerIdentityService> logger;
@@ -18,11 +19,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
ISpeakerSnippetExtractor snippetExtractor, ISpeakerSnippetExtractor snippetExtractor,
ISpeakerIdentityMatcher matcher, ISpeakerIdentityMatcher matcher,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
ILogger<SpeakerIdentityService> logger) ILogger<SpeakerIdentityService> logger,
ISpeakerIdentityMatchValidator matchValidator)
{ {
this.dbContextFactory = dbContextFactory; this.dbContextFactory = dbContextFactory;
this.snippetExtractor = snippetExtractor; this.snippetExtractor = snippetExtractor;
this.matcher = matcher; this.matcher = matcher;
this.matchValidator = matchValidator;
this.options = options.Value.SpeakerIdentification; this.options = options.Value.SpeakerIdentification;
this.logger = logger; this.logger = logger;
} }
@@ -198,7 +201,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
? suppliedSnippet ? suppliedSnippet
: await snippetExtractor.ExtractSnippetAsync( : await snippetExtractor.ExtractSnippetAsync(
request.AudioPath, request.AudioPath,
group.ToList(), SpeakerSampleSpanSelector.SelectBestContinuousSpan(
request.Segments,
speaker,
options.MaximumSampleSegmentGap,
options.MinimumSampleSpeechDuration),
cancellationToken); cancellationToken);
if (snippet.Length == 0) if (snippet.Length == 0)
{ {
@@ -544,6 +551,14 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0)) foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
{ {
if (!await matchValidator.ValidateSampleAsync(snippet, cancellationToken))
{
logger.LogInformation(
"Skipping speaker identity candidate for {Speaker} because secondary validation rejected the sample",
speaker);
continue;
}
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null; var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
var identity = new SpeakerIdentity var identity = new SpeakerIdentity
@@ -0,0 +1,79 @@
using MeetingAssistant.Transcription;
namespace MeetingAssistant.Speakers;
internal static class SpeakerSampleSpanSelector
{
public static bool CanExtend(
string currentSpeaker,
TimeSpan currentEnd,
TranscriptionSegment nextSegment,
TimeSpan maximumSegmentGap)
{
return string.Equals(currentSpeaker, nextSegment.Speaker, StringComparison.OrdinalIgnoreCase) &&
nextSegment.Start - currentEnd <= maximumSegmentGap;
}
public static IReadOnlyList<TranscriptionSegment> SelectBestContinuousSpan(
IReadOnlyList<TranscriptionSegment> segments,
string speaker,
TimeSpan maximumSegmentGap,
TimeSpan minimumDuration)
{
var best = new List<TranscriptionSegment>();
var current = new List<TranscriptionSegment>();
foreach (var segment in segments.OrderBy(segment => segment.Start))
{
if (current.Count == 0)
{
if (IsSpeaker(segment, speaker))
{
current.Add(segment);
best = LongerSpan(current, best);
}
continue;
}
if (!CanExtend(speaker, current[^1].End, segment, maximumSegmentGap))
{
current.Clear();
if (!IsSpeaker(segment, speaker))
{
continue;
}
}
current.Add(segment);
best = LongerSpan(current, best);
}
return SpanDuration(best) >= minimumDuration
? best
: [];
}
public static TimeSpan SpanDuration(IReadOnlyList<TranscriptionSegment> segments)
{
return segments.Count == 0
? TimeSpan.Zero
: segments[^1].End - segments[0].Start;
}
private static bool IsSpeaker(
TranscriptionSegment segment,
string speaker)
{
return string.Equals(segment.Speaker, speaker, StringComparison.OrdinalIgnoreCase);
}
private static List<TranscriptionSegment> LongerSpan(
List<TranscriptionSegment> current,
List<TranscriptionSegment> best)
{
return SpanDuration(current) > SpanDuration(best)
? current.ToList()
: best;
}
}
+21 -1
View File
@@ -103,10 +103,30 @@
"MaxMatchCandidates": 100, "MaxMatchCandidates": 100,
"MatchIdentityActiveAge": "365.00:00:00", "MatchIdentityActiveAge": "365.00:00:00",
"MaxSnippetsPerSpeaker": 3, "MaxSnippetsPerSpeaker": 3,
"MinimumSampleSpeechDuration": "00:00:30",
"MaximumSampleSegmentGap": "00:00:01",
"SilenceBetweenSnippetsSeconds": 1, "SilenceBetweenSnippetsSeconds": 1,
"LiveSampleBufferDuration": "00:10:00", "LiveSampleBufferDuration": "00:10:00",
"MergeRecentIdentityAge": "14.00:00:00", "MergeRecentIdentityAge": "14.00:00:00",
"MatchTimeout": "00:03:00" "MatchTimeout": "00:03:00",
"PyannoteValidation": {
"Enabled": false,
"MinimumSingleSpeakerCoverage": 0.9,
"MinimumMatchingKnownSnippetRatio": 0.5,
"Diarization": {
"Enabled": true,
"DockerCommand": "docker",
"BaseImage": "python:3.11-slim",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"AnnotationSource": "SpeakerDiarization",
"AlignmentMode": "PyannoteTurns",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "00:30:00"
}
}
}, },
"Automation": { "Automation": {
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml" "RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
+21
View File
@@ -168,6 +168,25 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"RecognitionStopTimeout": "00:00:10", "RecognitionStopTimeout": "00:00:10",
"DiarizeIntermediateResults": true "DiarizeIntermediateResults": true
}, },
"SpeakerIdentification": {
"MinimumSampleSpeechDuration": "00:00:30",
"MaximumSampleSegmentGap": "00:00:01",
"PyannoteValidation": {
"Enabled": false,
"MinimumSingleSpeakerCoverage": 0.9,
"MinimumMatchingKnownSnippetRatio": 0.5,
"Diarization": {
"Enabled": true,
"DockerCommand": "docker",
"Image": "meeting-assistant-pyannote:local",
"BuildImage": true,
"ModelsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Pyannote\\models",
"Model": "pyannote/speaker-diarization-3.1",
"TokenEnv": "HF_TOKEN",
"CommandTimeout": "00:30:00"
}
}
},
"Automation": { "Automation": {
"RulesPath": "meeting-rules.local.yaml" "RulesPath": "meeting-rules.local.yaml"
}, },
@@ -217,6 +236,8 @@ During recording, Meeting Assistant writes the mixed PCM stream to a temporary W
`azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed, which keeps short diagnostic runs from waiting indefinitely for final service events. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote. `azure-speech` streams captured PCM audio to Azure AI Speech using the Speech SDK `ConversationTranscriber`. Configure `AzureSpeech:Endpoint`, `Region`, `Language`, and either `Key` or `KeyEnv`; this repository uses `AZURE_SPEECH_KEY` by default and does not store the key in appsettings. `DiarizeIntermediateResults` enables diarization for intermediate and final conversation transcription results. Azure returns generic speaker IDs such as `Guest-1` and `Guest-2`, which Meeting Assistant writes directly into live transcript segments. `RecognitionStopTimeout` bounds SDK shutdown after the captured audio stream has been closed, which keeps short diagnostic runs from waiting indefinitely for final service events. Since Azure Speech emits diarized live transcript segments, the Azure pipeline keeps those segments as the finished transcript instead of running pyannote.
Speaker identity matching keeps candidate samples only after a diarized speaker has at least `SpeakerIdentification:MinimumSampleSpeechDuration` of continuous speech, defaulting to 30 seconds. Adjacent same-speaker segments may be combined when the gap is no larger than `MaximumSampleSegmentGap`, but a different speaker resets the pending span. `SpeakerIdentification:PyannoteValidation` is an optional secondary confidence layer. When enabled, pyannote rejects multi-speaker samples and must confirm an Azure-confirmed identity match before Meeting Assistant accepts it. It uses the same Docker-based pyannote runtime shape as local Whisper finalization and reads its Hugging Face token from the nested diarization options.
ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider. ASR backends are exposed downstream as speech recognition pipelines. A pipeline can initialize, wait for readiness, accept audio chunks, emit live transcript segments, and produce a finished transcript after capture completes. The configured implementation is selected from `Recording:TranscriptionProvider` and registered through DI so recording and diagnostic endpoints do not need to know whether the backend uses FunASR, Whisper.NET, Azure Speech, pyannote, or a later provider.
`POST /asr/transcribe-file` is a local diagnostic endpoint for the configured ASR backend. Send `{ "path": "C:\\path\\to\\sample.wav" }` with a 16-bit PCM WAV file to stream it through the configured speech recognition pipeline and return the emitted live transcript segments. `POST /asr/transcribe-file` is a local diagnostic endpoint for the configured ASR backend. Send `{ "path": "C:\\path\\to\\sample.wav" }` with a 16-bit PCM WAV file to stream it through the configured speech recognition pipeline and return the emitted live transcript segments.
@@ -0,0 +1,20 @@
# Harden speaker samples and pyannote validation
## Why
Azure Speech speaker identity matching is too brittle when Meeting Assistant learns from short samples. Speaker identity samples should represent enough uninterrupted speech for the diarization backend to distinguish voices reliably.
Meeting Assistant can also use pyannote as a secondary, optional confidence layer: first to reject samples that contain multiple speakers, and then to reject Azure identity matches when pyannote cannot confirm that the unknown and known snippets are the same speaker.
## What Changes
- Require speaker identity samples to meet a configurable minimum uninterrupted speech duration, defaulting to 30 seconds.
- Accumulate adjacent same-speaker transcript segments into a single sample span when no other speaker interrupts the span.
- Add optional pyannote-backed sample and match validation for speaker identity matching.
- Reuse the existing pyannote Docker/runtime configuration shape where practical.
## Impact
- Speaker identity matching starts later for speakers who do not talk long enough uninterrupted.
- Live matching tests that depended on very short samples need to configure a lower minimum sample duration.
- Pyannote validation is opt-in and should not affect existing installations unless enabled.
@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Speaker identity samples require uninterrupted speech
Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech.
The default minimum uninterrupted speech duration SHALL be 30 seconds.
Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span.
When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker.
#### Scenario: Short speaker span is not retained
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech
- **THEN** Meeting Assistant does not retain a speaker identity sample for that span
#### Scenario: Adjacent same-speaker segments form a sample
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting
- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span
#### Scenario: Different speaker interrupts pending span
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks
- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation
@@ -0,0 +1,24 @@
## 1. Specification
- [x] Add requirements for minimum uninterrupted speaker samples.
- [x] Add requirements for optional pyannote secondary validation.
## 2. Tests
- [x] Cover sample collection waiting for the configured minimum uninterrupted speech duration.
- [x] Cover sample collection resetting when another speaker interrupts.
- [x] Cover pyannote sample validation rejecting multi-speaker samples.
- [x] Cover pyannote match validation rejecting an Azure-confirmed match.
- [x] Update existing live matching tests to configure short samples where they intentionally exercise fast matching.
## 3. Implementation
- [x] Add speaker identification configuration for minimum sample duration and pyannote validation.
- [x] Refactor live sample collection to accumulate continuous same-speaker spans.
- [x] Add pyannote diarization reuse for speaker identity validation.
- [x] Add secondary validation into the identity matching pipeline.
## 4. Verification
- [x] Run focused speaker/transcription tests.
- [x] Run `openspec validate harden-speaker-samples-and-pyannote-validation --strict`.
@@ -435,3 +435,54 @@ The replacement speech recognition pipeline SHALL use the target launch profile'
- **WHEN** the active transcription profile is switched to another launch profile - **WHEN** the active transcription profile is switched to another launch profile
- **THEN** the new speech recognition pipeline is created from the target profile options - **THEN** the new speech recognition pipeline is created from the target profile options
- **AND** it receives audio captured after the switch request and audio buffered during the switch - **AND** it receives audio captured after the switch request and audio buffered during the switch
### Requirement: Speaker identity samples require uninterrupted speech
Meeting Assistant SHALL only retain speaker identity samples after a diarized speaker has produced at least the configured minimum duration of uninterrupted speech.
The default minimum uninterrupted speech duration SHALL be 30 seconds.
Meeting Assistant MAY combine adjacent transcript segments for the same diarized speaker into one sample span when no different diarized speaker interrupts the span.
When a different diarized speaker appears before the configured minimum duration is reached, Meeting Assistant SHALL discard the pending sample span for the previous speaker.
#### Scenario: Short speaker span is not retained
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces only 20 seconds of uninterrupted speech
- **THEN** Meeting Assistant does not retain a speaker identity sample for that span
#### Scenario: Adjacent same-speaker segments form a sample
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** a diarized speaker produces adjacent segments totaling at least 30 seconds without another speaker interrupting
- **THEN** Meeting Assistant retains one speaker identity sample covering the continuous span
#### Scenario: Different speaker interrupts pending span
- **GIVEN** the configured minimum sample duration is 30 seconds
- **WHEN** `Guest01` speaks for 20 seconds and then `Guest02` speaks
- **THEN** Meeting Assistant discards the pending `Guest01` span instead of retaining or later extending it
### Requirement: Speaker identity matching can use pyannote secondary validation
Meeting Assistant SHALL support an optional configurable pyannote secondary validation layer for speaker identity matching.
When pyannote secondary validation is enabled, Meeting Assistant SHALL verify candidate speaker samples before retaining them for identity matching. Samples that pyannote reports as containing multiple speakers SHALL be rejected.
When pyannote secondary validation is enabled and the primary identity matcher confirms a speaker, Meeting Assistant SHALL run a second validation pass through pyannote before accepting the match.
If pyannote secondary validation cannot confirm that the unknown live sample and matched identity samples belong to one speaker, Meeting Assistant SHALL reject the match.
When pyannote secondary validation is disabled, Meeting Assistant SHALL preserve the primary identity matching behavior.
#### Scenario: Multi-speaker sample is rejected
- **GIVEN** pyannote secondary validation is enabled
- **WHEN** pyannote reports multiple speakers in a candidate sample
- **THEN** Meeting Assistant does not retain that sample for identity matching
#### Scenario: Pyannote rejects primary match
- **GIVEN** pyannote secondary validation is enabled
- **AND** the primary identity matcher confirms `Guest03` as `Chris`
- **WHEN** pyannote reports that the unknown `Guest03` sample and known `Chris` samples contain different speakers
- **THEN** Meeting Assistant rejects the match
#### Scenario: Disabled pyannote validation preserves primary match
- **GIVEN** pyannote secondary validation is disabled
- **WHEN** the primary identity matcher confirms `Guest03` as `Chris`
- **THEN** Meeting Assistant accepts the match without running pyannote secondary validation