Diarization v2: Fenster-Clustering mit gelernter Schwelle + Live-Namensmarker

#74 (22 Teilnehmer) ergab automatisch 44 Sprecher, dieselbe Stimme mehrfach.
Ursache gemessen: ein Zentroid pro sherpa-Cluster aus max. 3 Schnitten, fester
Kosinus auf rohen Embeddings (Kreuz-Turn-Kosinus ~0,4 = keine Trennschaerfe).

v2: sherpa liefert nur Turn-Grenzen, Identitaet aus 3-s-Fenster-Embeddings,
Mittelwert-Zentrierung, Average-Linkage-AHC, Weak-Fold, Schwelle pro Aufnahme
per 2-Gauss-EM gelernt. Validiert: #74 44->20, #70 ->4, #68 ->2.

Live-Namensmarker: Namen waehrend der Aufnahme tippen; Zeitstempel benennt
spaeter den Cluster und dient als must-/cannot-link-Zwangsbedingung.
This commit is contained in:
Claude
2026-07-27 17:00:45 +02:00
parent 432c4b14dd
commit 48fa6b4f9e
17 changed files with 1357 additions and 67 deletions
+52
View File
@@ -0,0 +1,52 @@
// Which merge threshold does a recording itself suggest? Fits a 2-component
// Gaussian mixture (EM) to the pairwise window-similarity histogram — the
// "different speaker" and "same speaker" modes — and takes the point where the
// two weighted densities cross. Compares that with the fixed thresholds.
import fs from 'node:fs';
const f = x => x.toFixed(3);
const norm = v => { let s = 0; for (const x of v) s += x * x; s = Math.sqrt(s) + 1e-9; return v.map(x => x / s); };
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
for (const file of process.argv.slice(2)) {
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
const wins = d.wins.filter(w => w.v);
let X = wins.map(w => norm(w.v));
const dim = X[0].length, mu = new Array(dim).fill(0);
for (const v of X) for (let i = 0; i < dim; i++) mu[i] += v[i] / X.length;
X = X.map(v => norm(v.map((x, i) => x - mu[i])));
const n = X.length;
// sample pairs (deterministic stride, no RNG)
const vals = [];
for (let k = 1; k < 400000 && vals.length < 60000; k++) {
const i = (k * 7919) % n, j = (k * 104729 + 13) % n;
if (i !== j) vals.push(dot(X[i], X[j]));
}
// EM for 2 Gaussians
let m1 = -0.1, m2 = 0.5, s1 = 0.15, s2 = 0.15, w1 = 0.8;
const N = (x, m, s) => Math.exp(-((x - m) ** 2) / (2 * s * s)) / (s * Math.sqrt(2 * Math.PI));
for (let it = 0; it < 100; it++) {
let sw1 = 0, sm1 = 0, sm2 = 0, sv1 = 0, sv2 = 0, sw2 = 0;
for (const x of vals) {
const a = w1 * N(x, m1, s1), b = (1 - w1) * N(x, m2, s2);
const r = a / (a + b + 1e-300);
sw1 += r; sw2 += 1 - r; sm1 += r * x; sm2 += (1 - r) * x;
}
const nm1 = sm1 / sw1, nm2 = sm2 / sw2;
for (const x of vals) {
const a = w1 * N(x, m1, s1), b = (1 - w1) * N(x, m2, s2);
const r = a / (a + b + 1e-300);
sv1 += r * (x - nm1) ** 2; sv2 += (1 - r) * (x - nm2) ** 2;
}
m1 = nm1; m2 = nm2;
s1 = Math.max(0.02, Math.sqrt(sv1 / sw1)); s2 = Math.max(0.02, Math.sqrt(sv2 / sw2));
w1 = sw1 / vals.length;
}
// crossing point between the means: w1*N1(x) = w2*N2(x)
let lo = m1, hi = m2, t = (lo + hi) / 2;
for (let i = 0; i < 60; i++) {
t = (lo + hi) / 2;
const g = w1 * N(t, m1, s1) - (1 - w1) * N(t, m2, s2);
if (g > 0) lo = t; else hi = t;
}
console.log(`${file.split('.').slice(0, 2).join('.')}: diff N(${f(m1)},${f(s1)}) w=${f(w1)} | same N(${f(m2)},${f(s2)}) w=${f(1 - w1)} → crossing=${f(t)}`);
}