feat(persons): eine Person, mehrere Namen und mehrere Stimmproben
Die Identitaet war der NAME. Derselbe Mensch, einmal "Sascha" und einmal "Sascha Wolf" benannt, existierte zweimal und konnte zwei Cluster benennen -- Aufnahme #101 (34 s, zwei Menschen) zeigte Michael/Sascha/Sprecher 3/ Sascha Wolf. Der Vornamens-Trick in diar.js hat das ueberdeckt, setzte aber auch "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241). Neu: persons + person_names (Name = PK, gehoert genau einer Person), voiceprints.person_id. Mehrere Proben pro Person (max. 8) statt eines laufenden Mittels; bewertet wird die beste Probe. Score UND Abstand rechnen zwischen Personen -- vorher rissen zwei Abdruecke desselben Menschen (0,928) die Abstandspruefung, womit die Zwangsbedingung faktisch aus war. diar.identityKey bildet Marker-Namen und Abdruecke auf p<person_id> ab. GUI "Stimmen" zeigt Personen mit Namens-Chips: Namen hinzufuegen/entfernen, Personen zusammenfuehren, Proben verwerfen. Altbestand geleert (Dump in backup/), weil lab/vpcos.mjs neben echten Dubletten (Jens ~ Jens Kuppler = 1,000) auch kontaminierte Abdruecke zeigte: Johannes lag mit 0,912 an Sascha Wolf, aber 0,115 an Johannes Jeckel. Verifiziert an #101: Sascha Wolf als zweiter Name derselben Person -> Re-Diarisierung benennt nur noch Michael + Sascha statt drei Namen fuer zwei Menschen. GUI mit Playwright geprueft, keine JS-Fehler.
This commit is contained in:
+41
-16
@@ -180,7 +180,7 @@ export async function diarize(wavPath, numSpeakers, opts = {}) {
|
||||
if (MODE === 'v2') {
|
||||
const turns = await sherpaTurns(wavPath, n, true);
|
||||
if (turns.length) {
|
||||
try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || []); }
|
||||
try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || [], opts.persons || {}); }
|
||||
catch (e) { console.log('diar: v2 re-clustering failed, falling back to legacy:', e.message); }
|
||||
}
|
||||
}
|
||||
@@ -345,9 +345,9 @@ function ahcAverage(X, threshold, kStop = 0, initGroups = null, names = null) {
|
||||
}
|
||||
|
||||
// v2 entry: embed the windows, then cluster them.
|
||||
async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = []) {
|
||||
async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) {
|
||||
const ok = await embedWindows(wavPath, turns);
|
||||
return clusterWindows(turns, ok, numSpeakers, markers, voiceprints);
|
||||
return clusterWindows(turns, ok, numSpeakers, markers, voiceprints, persons);
|
||||
}
|
||||
|
||||
// Map each live marker to the turn the named person was speaking in: the turn
|
||||
@@ -362,7 +362,7 @@ const ANCHOR_MIN_SEC = 1.0; // too short to identify a voice from
|
||||
// likely the name belongs to somebody else who spoke in between). The vote
|
||||
// weight below is derived from it — a covering anchor must outweigh a 20-second
|
||||
// guess, otherwise one mistyped marker decides a whole cluster (#79).
|
||||
function markerAnchors(turns, markers) {
|
||||
function markerAnchors(turns, markers, persons) {
|
||||
const byName = new Map();
|
||||
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
|
||||
for (const mk of markers || []) {
|
||||
@@ -378,15 +378,22 @@ function markerAnchors(turns, markers) {
|
||||
if (d < bd) { bd = d; pick = c.i; }
|
||||
}
|
||||
if (pick < 0) continue;
|
||||
const key = normName(mk.name);
|
||||
const key = identityKey(mk.name, persons);
|
||||
(byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd });
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
// Constraint key for a person. First name only, lowercased: the same human is
|
||||
// enrolled/typed as both "Sascha" and "Sascha Wolf" (two voiceprint rows exist),
|
||||
// and treating those as two people would forbid merging their clusters.
|
||||
const normName = (s) => String(s || '').trim().toLowerCase().split(/\s+/)[0];
|
||||
// Zwangsbedingungs-Schlüssel = die IDENTITÄT hinter einem Namen, nicht der Name.
|
||||
// Seit 2026-08-07 ist das die Person aus `person_names`: "Sascha" und "Sascha Wolf"
|
||||
// sind derselbe Mensch, ihre Cluster dürfen also verschmelzen. Ohne Personen-
|
||||
// Eintrag zählt der VOLLE Name — der alte Trick, nur den Vornamen zu nehmen, setzte
|
||||
// "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen).
|
||||
const identityKey = (s, persons) => {
|
||||
const n = String(s || '').trim().toLowerCase();
|
||||
if (!n) return '';
|
||||
const pid = persons && persons[n];
|
||||
return pid ? `p${pid}` : n;
|
||||
};
|
||||
// Anchor vote weight: 1.0 for a marker inside the turn, decaying to 0.2 at the
|
||||
// far end of the look-back window.
|
||||
const anchorWeight = (d) => d <= 0 ? 1 : Math.max(0.2, 1 - 0.8 * (d / ANCHOR_BACK_SEC));
|
||||
@@ -445,7 +452,16 @@ export async function embedWindows(wavPath, turns) {
|
||||
// The heart of v2: window embeddings → clusters → per-turn speaker labels.
|
||||
// Split from the audio/embedding part so the clustering can be replayed offline
|
||||
// on cached embeddings (lab/lab-eval.mjs) — the tuning ran against THIS code.
|
||||
export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = []) {
|
||||
export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) {
|
||||
// Tags sind Identitäts-Schlüssel (siehe identityKey); fürs Log brauchen wir den
|
||||
// Klarnamen dahinter.
|
||||
const keyLabel = new Map();
|
||||
for (const mk of markers || []) { const k = identityKey(mk.name, persons); if (k && !keyLabel.has(k)) keyLabel.set(k, mk.name); }
|
||||
for (const vp of voiceprints || []) {
|
||||
const k = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons);
|
||||
if (k && !keyLabel.has(k)) keyLabel.set(k, vp.name);
|
||||
}
|
||||
const disp = k => keyLabel.get(k) || k;
|
||||
// Channel compensation: L2 → subtract the recording mean → L2 again.
|
||||
let X = ok.map(w => l2(w.vec));
|
||||
const mu = meanVec(X);
|
||||
@@ -505,7 +521,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
||||
// Live markers → must-link (same name in one group) + cannot-link (different
|
||||
// names never merge). Applied to the folded stage-A clusters: the cluster a
|
||||
// marker's turn landed in carries that name.
|
||||
const anchors = markerAnchors(turns, markers);
|
||||
const anchors = markerAnchors(turns, markers, persons);
|
||||
let tags = null;
|
||||
if (anchors.size) {
|
||||
const { lab } = labelTurns(groups);
|
||||
@@ -529,7 +545,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
||||
// Two different names anchored in ONE cluster means stage A already glued
|
||||
// two people together — the cannot-link can no longer separate them, so at
|
||||
// least say so instead of silently keeping the louder vote.
|
||||
if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Namen → "${name}" gewinnt (Sprecher evtl. verschmolzen)`);
|
||||
if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Personen → "${disp(name)}" gewinnt (Sprecher evtl. verschmolzen)`);
|
||||
}
|
||||
// must-link: groups carrying the SAME name become one — but only as far as
|
||||
// they sound alike (see MARK_LINK_REL). The strongest-vote group is the seed;
|
||||
@@ -550,7 +566,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
||||
const c = dot(seedCent, centroidOf(groups[g]));
|
||||
if (c >= linkMin) { take.push(g); continue; }
|
||||
rejected++;
|
||||
console.log(`diar v2: Marker "${name}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`);
|
||||
console.log(`diar v2: Marker "${disp(name)}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`);
|
||||
}
|
||||
merged.push(take.flatMap(g => groups[g]));
|
||||
mtags.push(name);
|
||||
@@ -574,10 +590,19 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
||||
groups.forEach((g, gi) => {
|
||||
if (tags[gi]) return;
|
||||
const c = rawCent(g);
|
||||
let best = null, s1 = -Infinity, s2 = -Infinity;
|
||||
// Pro PERSON ihr bester Abdruck, und der Abstand gilt zur nächstbesten
|
||||
// PERSON. Vorher lief beides über einzelne Abdrücke: zwei Abdrücke desselben
|
||||
// Menschen ("Sascha" 0,928 zu "Sascha Wolf") rissen die Abstandsprüfung, die
|
||||
// Zwangsbedingung war damit faktisch aus.
|
||||
const perId = new Map();
|
||||
for (const vp of voiceprints) {
|
||||
const key = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons);
|
||||
const s = spk.cosine(c, vp.vec);
|
||||
if (s > s1) { s2 = s1; s1 = s; best = normName(vp.name); }
|
||||
if (!perId.has(key) || s > perId.get(key)) perId.set(key, s);
|
||||
}
|
||||
let best = null, s1 = -Infinity, s2 = -Infinity;
|
||||
for (const [key, s] of perId) {
|
||||
if (s > s1) { s2 = s1; s1 = s; best = key; }
|
||||
else if (s > s2) s2 = s;
|
||||
}
|
||||
if (best && s1 >= VP_MIN && (s1 - s2) >= VP_MARGIN && !taken.has(best)) cand.push({ gi, name: best, s: s1 });
|
||||
@@ -587,7 +612,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
||||
for (const c of cand) {
|
||||
if (taken.has(c.name) || tags[c.gi]) continue;
|
||||
tags[c.gi] = c.name; taken.add(c.name); vpTags++;
|
||||
console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${c.name}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`);
|
||||
console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${disp(c.name)}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`);
|
||||
}
|
||||
if (!vpTags) console.log(`diar v2: keine Stimme sicher genug erkannt (${voiceprints.length} Abdrücke, Schwelle ${VP_MIN})`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user