Public Access
feat: add local Resemblyzer speaker recognition
PR and Push Build/Test / build-and-test (push) Successful in 12m37s
PR and Push Build/Test / build-and-test (push) Successful in 12m37s
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user