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
@@ -1334,7 +1334,11 @@ public sealed class MeetingRecordingCoordinator
Options = options;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
speakerSampleCollector = new SpeakerAudioSampleCollector(
liveSampleBufferDuration,
maxSpeakerSamples,
options.SpeakerIdentification.MinimumSampleSpeechDuration,
options.SpeakerIdentification.MaximumSampleSegmentGap);
}
public CancellationTokenSource CaptureCancellationSource { get; }
@@ -9,11 +9,33 @@ internal sealed class SpeakerAudioSampleCollector
private readonly RollingAudioBuffer audioBuffer;
private readonly Dictionary<string, List<SpeakerAudioSample>> samplesBySpeaker = new(StringComparer.OrdinalIgnoreCase);
private readonly int maxSamplesPerSpeaker;
private readonly TimeSpan minimumUninterruptedSpeechDuration;
private readonly TimeSpan maximumSegmentGap;
private PendingSpeakerSpan? pendingSpan;
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);
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)
@@ -26,6 +48,7 @@ internal sealed class SpeakerAudioSampleCollector
lock (gate)
{
samplesBySpeaker.Clear();
pendingSpan = null;
audioBuffer.Reset();
}
}
@@ -37,25 +60,31 @@ internal sealed class SpeakerAudioSampleCollector
return;
}
var score = Score(segment);
TranscriptionSegment sampleSegment;
lock (gate)
{
sampleSegment = ExtendPendingSpan(segment);
}
var score = Score(sampleSegment, minimumUninterruptedSpeechDuration);
if (score <= 0)
{
return;
}
var wavBytes = audioBuffer.TryExtractWav(segment.Start, segment.End);
var wavBytes = audioBuffer.TryExtractWav(sampleSegment.Start, sampleSegment.End);
if (wavBytes.Length == 0)
{
return;
}
var sample = new SpeakerAudioSample(segment.Speaker, segment, wavBytes, score);
var sample = new SpeakerAudioSample(sampleSegment.Speaker, sampleSegment, wavBytes, score);
lock (gate)
{
if (!samplesBySpeaker.TryGetValue(segment.Speaker, out var samples))
if (!samplesBySpeaker.TryGetValue(sampleSegment.Speaker, out var samples))
{
samples = [];
samplesBySpeaker[segment.Speaker] = samples;
samplesBySpeaker[sampleSegment.Speaker] = samples;
}
samples.Add(sample);
@@ -86,10 +115,29 @@ internal sealed class SpeakerAudioSampleCollector
!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;
if (durationSeconds < 2 || durationSeconds > 30)
if (durationSeconds < minimumUninterruptedSpeechDuration.TotalSeconds)
{
return 0;
}
@@ -97,13 +145,13 @@ internal sealed class SpeakerAudioSampleCollector
var words = segment.Text
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Length;
if (words < 3 || words > 80)
if (words < 3)
{
return 0;
}
var durationScore = 1 - Math.Min(Math.Abs(durationSeconds - 8) / 8, 1);
var wordScore = 1 - Math.Min(Math.Abs(words - 18) / 18.0, 1);
var durationScore = Math.Min(durationSeconds / Math.Max(1, minimumUninterruptedSpeechDuration.TotalSeconds), 2);
var wordScore = Math.Min(words / 60.0, 1);
var sentenceBonus = segment.Text.TrimEnd().EndsWith('.') ||
segment.Text.TrimEnd().EndsWith('?') ||
segment.Text.TrimEnd().EndsWith('!')
@@ -111,4 +159,29 @@ internal sealed class SpeakerAudioSampleCollector
: 0;
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))));
}
}
}