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
@@ -0,0 +1,191 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace MeetingAssistant.Speakers;
internal sealed record DecodedSpeakerVoiceVector(
SpeakerVoiceVector Stored,
float[] Vector);
internal static class ResemblyzerVectorContract
{
public const int Dimensions = 256;
}
internal static class SpeakerVoiceVectors
{
public static float[] Decode(SpeakerVoiceVector stored)
{
if (stored.Dimensions != ResemblyzerVectorContract.Dimensions ||
stored.VectorBytes.Length != stored.Dimensions * sizeof(float))
{
throw new InvalidDataException(
$"Stored voice vector {stored.Id} has invalid dimension or byte length metadata.");
}
var vector = new float[stored.Dimensions];
for (var index = 0; index < vector.Length; index++)
{
vector[index] = BinaryPrimitives.ReadSingleLittleEndian(
stored.VectorBytes.AsSpan(index * sizeof(float), sizeof(float)));
if (!float.IsFinite(vector[index]))
{
throw new InvalidDataException($"Stored voice vector {stored.Id} contains a non-finite value.");
}
}
return vector;
}
public static int AddDistinct(
SpeakerIdentity identity,
IEnumerable<float[]> vectors,
string modelId,
int maximumCount,
DateTimeOffset createdAt)
{
var limit = Math.Max(1, maximumCount);
var fingerprints = identity.VoiceVectors
.Select(vector => vector.Fingerprint)
.ToHashSet(StringComparer.Ordinal);
var added = 0;
foreach (var vector in vectors)
{
if (identity.VoiceVectors.Count >= limit)
{
break;
}
var bytes = Encode(vector);
var fingerprint = Fingerprint(modelId, bytes);
if (!fingerprints.Add(fingerprint))
{
continue;
}
identity.VoiceVectors.Add(new SpeakerVoiceVector
{
ModelId = modelId,
Dimensions = vector.Length,
VectorBytes = bytes,
Fingerprint = fingerprint,
CreatedAt = createdAt
});
added++;
}
return added;
}
public static IReadOnlyList<float[]> DecodeCompatible(
SpeakerIdentity identity,
string modelId,
ILogger logger)
{
return DecodeCompatibleEntries(identity, modelId, logger)
.Select(entry => entry.Vector)
.ToList();
}
public static IReadOnlyList<DecodedSpeakerVoiceVector> DecodeCompatibleEntries(
SpeakerIdentity identity,
string modelId,
ILogger logger)
{
var vectors = new List<DecodedSpeakerVoiceVector>();
foreach (var stored in identity.VoiceVectors
.Where(vector => string.Equals(vector.ModelId, modelId, StringComparison.Ordinal))
.OrderBy(vector => vector.CreatedAt)
.ThenBy(vector => vector.Id))
{
try
{
vectors.Add(new DecodedSpeakerVoiceVector(stored, Decode(stored)));
}
catch (InvalidDataException exception)
{
logger.LogWarning(
exception,
"Ignoring invalid stored voice vector {VectorId} for identity {IdentityId}",
stored.Id,
identity.Id);
}
}
return vectors;
}
public static double Cosine(float[] first, float[] second)
{
double dot = 0;
for (var index = 0; index < first.Length; index++)
{
dot += first[index] * second[index];
}
return dot;
}
public static float[] Normalize(float[]? vector, string description)
{
if (vector is null || vector.Length != ResemblyzerVectorContract.Dimensions)
{
throw new InvalidDataException(
$"{description} has {vector?.Length ?? 0} dimensions; expected {ResemblyzerVectorContract.Dimensions}.");
}
double magnitudeSquared = 0;
foreach (var value in vector)
{
if (!float.IsFinite(value))
{
throw new InvalidDataException($"{description} contains a non-finite value.");
}
magnitudeSquared += value * value;
}
var magnitude = Math.Sqrt(magnitudeSquared);
if (magnitude <= double.Epsilon)
{
throw new InvalidDataException($"{description} has zero magnitude.");
}
return vector.Select(value => (float)(value / magnitude)).ToArray();
}
private static byte[] Encode(float[] vector)
{
if (vector.Length != ResemblyzerVectorContract.Dimensions)
{
throw new InvalidDataException(
$"Voice vector has {vector.Length} dimensions; expected {ResemblyzerVectorContract.Dimensions}.");
}
var bytes = new byte[vector.Length * sizeof(float)];
for (var index = 0; index < vector.Length; index++)
{
if (!float.IsFinite(vector[index]))
{
throw new InvalidDataException("Voice vector contains a non-finite value.");
}
BinaryPrimitives.WriteSingleLittleEndian(
bytes.AsSpan(index * sizeof(float), sizeof(float)),
vector[index]);
}
return bytes;
}
private static string Fingerprint(string modelId, byte[] vectorBytes)
{
var modelBytes = Encoding.UTF8.GetBytes(modelId);
var input = new byte[modelBytes.Length + 1 + vectorBytes.Length];
modelBytes.CopyTo(input, 0);
input[modelBytes.Length] = 0;
vectorBytes.CopyTo(input, modelBytes.Length + 1);
return Convert.ToHexString(SHA256.HashData(input));
}
}