Public Access
This commit is contained in:
@@ -15,15 +15,18 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
private readonly ISpeakerIdentityDiarizationClient diarizationClient;
|
||||
private readonly SpeakerIdentificationOptions options;
|
||||
private readonly ILogger<AzureSpeechSpeakerIdentityMatcher> logger;
|
||||
private readonly ISpeakerIdentityMatchValidator matchValidator;
|
||||
|
||||
public AzureSpeechSpeakerIdentityMatcher(
|
||||
ISpeakerIdentityDiarizationClient diarizationClient,
|
||||
Microsoft.Extensions.Options.IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<AzureSpeechSpeakerIdentityMatcher> logger)
|
||||
ILogger<AzureSpeechSpeakerIdentityMatcher> logger,
|
||||
ISpeakerIdentityMatchValidator matchValidator)
|
||||
{
|
||||
this.diarizationClient = diarizationClient;
|
||||
this.options = options.Value.SpeakerIdentification;
|
||||
this.logger = logger;
|
||||
this.matchValidator = matchValidator;
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentityMatch?> MatchAsync(
|
||||
@@ -35,6 +38,14 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
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(
|
||||
"Speaker identity matching {DiarizedSpeaker} against {CandidateCount} candidate(s)",
|
||||
request.DiarizedSpeaker,
|
||||
@@ -84,6 +95,20 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
var requiredMatches = known.Count() > 1 ? 2 : 1;
|
||||
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(
|
||||
"Speaker identity matching {DiarizedSpeaker} matched identity {IdentityId}",
|
||||
request.DiarizedSpeaker,
|
||||
@@ -99,7 +124,7 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
SpeakerCompositeWav.TryDelete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +155,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
|
||||
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)
|
||||
{
|
||||
return new CompositeLayout(null, []);
|
||||
@@ -145,118 +171,33 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
{
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value));
|
||||
knownSegments.Add(new KnownCompositeSegment(candidate.IdentityId, segment.Value, snippet));
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
return SpeakerCompositeWav.FindBestSpeaker(segment, segments);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user