feat: add local Resemblyzer speaker recognition
PR and Push Build/Test / build-and-test (push) Successful in 12m37s

This commit is contained in:
2026-09-11 13:49:27 +02:00
parent 43fc8aaec0
commit f86af983e8
48 changed files with 5250 additions and 273 deletions
@@ -4,56 +4,84 @@ 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(
public static IReadOnlyList<TranscriptionSegment> SelectBestSameSpeakerSpan(
IReadOnlyList<TranscriptionSegment> segments,
string speaker,
TimeSpan maximumSegmentGap,
TimeSpan minimumDuration)
TimeSpan minimumDuration,
TimeSpan maximumSampleDuration)
{
var best = new List<TranscriptionSegment>();
var current = new List<TranscriptionSegment>();
SpeakerSampleSpan? best = null;
SpeakerSampleSpan? current = null;
foreach (var segment in segments.OrderBy(segment => segment.Start))
{
if (current.Count == 0)
if (!IsSpeaker(segment, speaker))
{
if (IsSpeaker(segment, speaker))
{
current.Add(segment);
best = LongerSpan(current, best);
}
current = null;
continue;
}
if (!CanExtend(speaker, current[^1].End, segment, maximumSegmentGap))
current = ExtendOrStart(current, segment, maximumSampleDuration);
if (current is not null &&
(best is null || current.SpeechDuration > best.SpeechDuration))
{
current.Clear();
if (!IsSpeaker(segment, speaker))
{
continue;
}
best = current;
}
current.Add(segment);
best = LongerSpan(current, best);
}
return SpanDuration(best) >= minimumDuration
? best
return best is not null && best.SpeechDuration >= minimumDuration
? best.Segments
: [];
}
public static IReadOnlyList<IReadOnlyList<TranscriptionSegment>> SelectBestSameSpeakerSpans(
IReadOnlyList<TranscriptionSegment> segments,
string speaker,
TimeSpan minimumDuration,
TimeSpan maximumSampleDuration,
int maxSpans)
{
if (maxSpans <= 0)
{
return [];
}
var completed = new List<SpeakerSampleSpan>();
SpeakerSampleSpan? current = null;
TimeSpan? lastCompletedEnd = null;
foreach (var segment in segments.OrderBy(segment => segment.Start))
{
if (!IsSpeaker(segment, speaker))
{
current = null;
continue;
}
if (lastCompletedEnd is { } end && segment.Start < end)
{
current = null;
continue;
}
current = ExtendOrStart(current, segment, maximumSampleDuration);
if (current is null || current.SpeechDuration < minimumDuration)
{
continue;
}
completed.Add(current);
lastCompletedEnd = current.End;
current = null;
}
return completed
.OrderByDescending(span => span.SpeechDuration)
.ThenBy(span => span.Start)
.Take(maxSpans)
.OrderBy(span => span.Start)
.Select(span => span.Segments)
.ToList();
}
public static TimeSpan SpanDuration(IReadOnlyList<TranscriptionSegment> segments)
{
return segments.Count == 0
@@ -61,19 +89,20 @@ internal static class SpeakerSampleSpanSelector
: segments[^1].End - segments[0].Start;
}
private static SpeakerSampleSpan? ExtendOrStart(
SpeakerSampleSpan? current,
TranscriptionSegment segment,
TimeSpan maximumSampleDuration)
{
return current is not null && current.TryExtend(segment, out var extended)
? extended
: SpeakerSampleSpan.Create(segment, maximumSampleDuration);
}
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;
}
}