/** * Nearest-neighbour classification over the identity features. Dimensions * are z-scored against the pooled training set so no single feature * dominates by units; the vote is distance-weighted over the k nearest * training hits. There is no model to fit, so relabelling a hit in the * timeline and folding it back takes effect on the next take immediately. */ export interface TrainingSet { voiceIds: string[]; // one per training hit vecs: number[][]; // matching identity vectors } const K = 3; export function classify(vec: number[], train: TrainingSet): { voiceId: string; confidence: number } | null { const n = train.vecs.length; if (n === 0) return null; const dims = vec.length; const mean = new Float64Array(dims); const std = new Float64Array(dims); for (const v of train.vecs) for (let d = 0; d < dims; d++) mean[d] += v[d]; for (let d = 0; d < dims; d++) mean[d] /= n; for (const v of train.vecs) for (let d = 0; d < dims; d++) std[d] += (v[d] - mean[d]) ** 2; for (let d = 0; d < dims; d++) std[d] = Math.sqrt(std[d] / n) || 1; const dist: { d: number; voiceId: string }[] = []; for (let i = 0; i < n; i++) { let s = 0; for (let d = 0; d < dims; d++) { const z = (vec[d] - train.vecs[i][d]) / std[d]; s += z * z; } dist.push({ d: Math.sqrt(s), voiceId: train.voiceIds[i] }); } dist.sort((a, b) => a.d - b.d); const votes = new Map(); let totalW = 0; for (const { d, voiceId } of dist.slice(0, Math.min(K, n))) { const w = 1 / (d + 1e-3); votes.set(voiceId, (votes.get(voiceId) ?? 0) + w); totalW += w; } let best = '', bestW = -1; for (const [id, w] of votes) if (w > bestW) { best = id; bestW = w; } return { voiceId: best, confidence: bestW / totalW }; }