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:
@@ -33,6 +33,30 @@ export async function ensureSchema() {
|
|||||||
// uses the rows of the model currently loaded. NULL = pre-2026-07-27 TitaNet.
|
// uses the rows of the model currently loaded. NULL = pre-2026-07-27 TitaNet.
|
||||||
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS model VARCHAR(64) NULL`);
|
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS model VARCHAR(64) NULL`);
|
||||||
await pool.query(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`);
|
await pool.query(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`);
|
||||||
|
// Ein Mensch, mehrere Namen (seit 2026-08-07). Vorher war der NAME die Identität:
|
||||||
|
// "Sascha" und "Sascha Wolf" waren zwei Einträge derselben Stimme (Kosinus 0,928),
|
||||||
|
// konnten zwei verschiedene Cluster benennen und ließen die Diarisierung 4
|
||||||
|
// Sprecher statt 2 zeigen. diar.js hat das mit einem Vornamens-Trick überdeckt —
|
||||||
|
// der aber "Markus Ufer" und "Markus Witsch" (Kosinus 0,241, zwei Menschen)
|
||||||
|
// fälschlich gleichsetzt. Identität ist deshalb jetzt explizit die PERSON.
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS persons (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
display_name VARCHAR(128) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
||||||
|
// Ein Name gehört zu genau EINER Person (PK auf name; die Standard-Kollation
|
||||||
|
// utf8mb4_general_ci macht das absichtlich case-insensitiv, "sascha" = "Sascha").
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS person_names (
|
||||||
|
name VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||||
|
person_id INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_person (person_id))`);
|
||||||
|
// Abdrücke gehören der Person, nicht dem Namen — und es dürfen MEHRERE pro
|
||||||
|
// Person sein (verschiedene Sessions/Headsets/Räume). Das ersetzt das alte
|
||||||
|
// laufende Mittel: ein Zentroid über sehr unterschiedliche Aufnahmesituationen
|
||||||
|
// verwischt gerade die Merkmale, auf die es ankommt (Spalte `n` bleibt für die
|
||||||
|
// Altdaten stehen).
|
||||||
|
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS person_id INT NULL`);
|
||||||
|
await pool.query(`ALTER TABLE voiceprints ADD INDEX IF NOT EXISTS idx_person (person_id)`);
|
||||||
// Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime"
|
// Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime"
|
||||||
// heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped
|
// heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped
|
||||||
// to the correct spelling. `soundalikes` = optional comma-list of mis-hearings.
|
// to the correct spelling. `soundalikes` = optional comma-list of mis-hearings.
|
||||||
@@ -248,40 +272,132 @@ export async function updateGlossary(id, term, soundalikes) {
|
|||||||
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
|
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addVoiceprint(name, vec, model) {
|
// --- Personen (Identität) und ihre Stimmabdrücke ------------------------------
|
||||||
const [res] = await pool.execute(
|
// Höchstzahl Abdrücke je Person und Modell. Mehrere Proben sind der Sinn der
|
||||||
'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)',
|
// Sache (verschiedene Sessions), aber unbegrenzt wachsen darf es nicht: jede
|
||||||
[name, vec.length, JSON.stringify(vec), model || null]);
|
// Probe kostet bei jeder Identifikation einen Kosinus. Älteste fallen raus.
|
||||||
return res.insertId;
|
const MAX_PRINTS_PER_PERSON = +(process.env.AIME_MAX_PRINTS || 8);
|
||||||
|
|
||||||
|
// Name → Person. Unbekannter Name legt Person + Namenseintrag an, damit jeder
|
||||||
|
// Weg (Marker, Sprecher-Popup, Enrollment) dieselbe Identität trifft.
|
||||||
|
export async function resolvePerson(name) {
|
||||||
|
const n = String(name || '').trim();
|
||||||
|
if (!n) return null;
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT p.id, p.display_name FROM person_names pn JOIN persons p ON p.id = pn.person_id
|
||||||
|
WHERE pn.name = ? LIMIT 1`, [n]);
|
||||||
|
if (rows.length) return { id: rows[0].id, display_name: rows[0].display_name };
|
||||||
|
const [res] = await pool.execute('INSERT INTO persons (display_name) VALUES (?)', [n]);
|
||||||
|
await pool.execute('INSERT INTO person_names (name, person_id) VALUES (?,?)', [n, res.insertId]);
|
||||||
|
return { id: res.insertId, display_name: n };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a sample to a named voiceprint, keeping ONE row per name as a running
|
// Weiteren Namen an eine Person hängen ("Sascha" und "Sascha Wolf" = ein Mensch).
|
||||||
// centroid (mean of all samples). This is how the app "learns who is who": every
|
// Gehört der Name schon einer ANDEREN Person, werden die beiden zusammengeführt —
|
||||||
// time a speaker is named in a recording, their voice is folded into the centroid,
|
// das ist die Absicht des Nutzers, wenn er denselben Namen zweimal vergibt.
|
||||||
// so future recordings match them more reliably. New name -> fresh row.
|
export async function addPersonName(personId, name) {
|
||||||
export async function upsertVoiceprint(name, vec, model) {
|
const n = String(name || '').trim();
|
||||||
const [rows] = await pool.query(
|
if (!n) throw new Error('Name fehlt');
|
||||||
'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1',
|
const [rows] = await pool.execute('SELECT person_id FROM person_names WHERE name=?', [n]);
|
||||||
[name, model || null, model || null]);
|
if (rows.length) {
|
||||||
if (!rows.length) {
|
if (rows[0].person_id === +personId) return { merged: false };
|
||||||
const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n, model) VALUES (?,?,?,1,?)',
|
await mergePersons(personId, rows[0].person_id);
|
||||||
[name, vec.length, JSON.stringify(vec), model || null]);
|
return { merged: true };
|
||||||
return res.insertId;
|
|
||||||
}
|
}
|
||||||
const old = rows[0];
|
await pool.execute('INSERT INTO person_names (name, person_id) VALUES (?,?)', [n, personId]);
|
||||||
const on = old.n || 1;
|
return { merged: false };
|
||||||
const ov = JSON.parse(old.vec);
|
|
||||||
const mean = vec.map((x, i) => ((ov[i] || 0) * on + x) / (on + 1));
|
|
||||||
await pool.execute('UPDATE voiceprints SET vec=?, n=?, dim=? WHERE id=?',
|
|
||||||
[JSON.stringify(mean), on + 1, mean.length, old.id]);
|
|
||||||
return old.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der letzte Name einer Person darf nicht weg — sonst bleibt eine namenlose
|
||||||
|
// Identität mit Abdrücken zurück, die niemand mehr zuordnen kann.
|
||||||
|
export async function removePersonName(name) {
|
||||||
|
const n = String(name || '').trim();
|
||||||
|
const [rows] = await pool.execute('SELECT person_id FROM person_names WHERE name=?', [n]);
|
||||||
|
if (!rows.length) return false;
|
||||||
|
const [[{ c }]] = await pool.execute('SELECT COUNT(*) AS c FROM person_names WHERE person_id=?', [rows[0].person_id]);
|
||||||
|
if (c <= 1) throw new Error('letzter Name einer Person — stattdessen Person löschen');
|
||||||
|
await pool.execute('DELETE FROM person_names WHERE name=?', [n]);
|
||||||
|
// War es der Anzeigename, rückt der älteste verbliebene Name nach.
|
||||||
|
const [rest] = await pool.execute(
|
||||||
|
'SELECT name FROM person_names WHERE person_id=? ORDER BY created_at LIMIT 1', [rows[0].person_id]);
|
||||||
|
if (rest.length) await pool.execute('UPDATE persons SET display_name=? WHERE id=? AND display_name=?',
|
||||||
|
[rest[0].name, rows[0].person_id, n]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePerson(id, displayName) {
|
||||||
|
const n = String(displayName || '').trim();
|
||||||
|
if (!n) throw new Error('Name fehlt');
|
||||||
|
await addPersonName(id, n).catch(() => {});
|
||||||
|
await pool.execute('UPDATE persons SET display_name=? WHERE id=?', [n, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namen und Abdrücke von `fromId` nach `intoId` umhängen, Quelle löschen.
|
||||||
|
export async function mergePersons(intoId, fromId) {
|
||||||
|
if (+intoId === +fromId) return;
|
||||||
|
await pool.execute('UPDATE person_names SET person_id=? WHERE person_id=?', [intoId, fromId]);
|
||||||
|
await pool.execute('UPDATE voiceprints SET person_id=? WHERE person_id=?', [intoId, fromId]);
|
||||||
|
await pool.execute('DELETE FROM persons WHERE id=?', [fromId]);
|
||||||
|
await prunePrints(intoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePerson(id) {
|
||||||
|
await pool.execute('DELETE FROM voiceprints WHERE person_id=?', [id]);
|
||||||
|
await pool.execute('DELETE FROM person_names WHERE person_id=?', [id]);
|
||||||
|
await pool.execute('DELETE FROM persons WHERE id=?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personen mit allen Namen und der Zahl ihrer Abdrücke (GUI: Stimmen-Tab).
|
||||||
|
export async function listPersons() {
|
||||||
|
const [ps] = await pool.query('SELECT id, display_name, created_at FROM persons ORDER BY display_name');
|
||||||
|
const [ns] = await pool.query('SELECT person_id, name FROM person_names ORDER BY created_at');
|
||||||
|
const [vs] = await pool.query(
|
||||||
|
'SELECT person_id, COUNT(*) AS prints, MAX(created_at) AS last_print FROM voiceprints WHERE person_id IS NOT NULL GROUP BY person_id');
|
||||||
|
const byId = new Map(ps.map(p => [p.id, { ...p, names: [], prints: 0, last_print: null }]));
|
||||||
|
for (const n of ns) byId.get(n.person_id)?.names.push(n.name);
|
||||||
|
for (const v of vs) { const p = byId.get(v.person_id); if (p) { p.prints = v.prints; p.last_print = v.last_print; } }
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase-Name → person_id. diar.js braucht das, um Live-Marker und Abdrücke
|
||||||
|
// auf DIESELBE Identität abzubilden (ein Marker "Sascha" und ein Abdruck von
|
||||||
|
// "Sascha Wolf" dürfen sich nicht als zwei Personen widersprechen).
|
||||||
|
export async function personNameMap() {
|
||||||
|
const [rows] = await pool.query('SELECT name, person_id FROM person_names');
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) map[r.name.toLowerCase()] = r.person_id;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prunePrints(personId) {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id FROM voiceprints WHERE person_id=? ORDER BY created_at DESC, id DESC', [personId]);
|
||||||
|
const drop = rows.slice(MAX_PRINTS_PER_PERSON).map(r => r.id);
|
||||||
|
if (drop.length) await pool.query(`DELETE FROM voiceprints WHERE id IN (${drop.map(() => '?').join(',')})`, drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine neue Stimmprobe für die Person hinter `name` speichern (kein Mitteln mehr,
|
||||||
|
// siehe MAX_PRINTS_PER_PERSON). Legt die Person bei Bedarf an.
|
||||||
|
export async function learnVoice(name, vec, model) {
|
||||||
|
const person = await resolvePerson(name);
|
||||||
|
if (!person) throw new Error('Name fehlt');
|
||||||
|
const [res] = await pool.execute(
|
||||||
|
'INSERT INTO voiceprints (name, person_id, dim, vec, n, model) VALUES (?,?,?,?,1,?)',
|
||||||
|
[person.display_name, person.id, vec.length, JSON.stringify(vec), model || null]);
|
||||||
|
await prunePrints(person.id);
|
||||||
|
return { id: res.insertId, personId: person.id, name: person.display_name };
|
||||||
|
}
|
||||||
|
|
||||||
// model given → only the prints of that embedding model (what identification
|
// model given → only the prints of that embedding model (what identification
|
||||||
// must use); omitted → all rows (GUI listing).
|
// must use); omitted → all rows (GUI listing). `name` ist ab 2026-08-07 nur noch
|
||||||
|
// Anzeige — die Identität ist person_id (Fallback für Altzeilen ohne Person).
|
||||||
export async function listVoiceprints(model) {
|
export async function listVoiceprints(model) {
|
||||||
|
const sql = `SELECT v.id, v.person_id, v.dim, v.vec, v.model, v.created_at,
|
||||||
|
COALESCE(p.display_name, v.name) AS name
|
||||||
|
FROM voiceprints v LEFT JOIN persons p ON p.id = v.person_id`;
|
||||||
const [rows] = model
|
const [rows] = model
|
||||||
? await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints WHERE model=? ORDER BY name', [model])
|
? await pool.query(`${sql} WHERE v.model=? ORDER BY name, v.created_at`, [model])
|
||||||
: await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints ORDER BY name');
|
: await pool.query(`${sql} ORDER BY name, v.created_at`);
|
||||||
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
|
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
|
||||||
}
|
}
|
||||||
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
|
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
|
||||||
|
|||||||
+41
-16
@@ -180,7 +180,7 @@ export async function diarize(wavPath, numSpeakers, opts = {}) {
|
|||||||
if (MODE === 'v2') {
|
if (MODE === 'v2') {
|
||||||
const turns = await sherpaTurns(wavPath, n, true);
|
const turns = await sherpaTurns(wavPath, n, true);
|
||||||
if (turns.length) {
|
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); }
|
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.
|
// 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);
|
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
|
// 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
|
// 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
|
// weight below is derived from it — a covering anchor must outweigh a 20-second
|
||||||
// guess, otherwise one mistyped marker decides a whole cluster (#79).
|
// guess, otherwise one mistyped marker decides a whole cluster (#79).
|
||||||
function markerAnchors(turns, markers) {
|
function markerAnchors(turns, markers, persons) {
|
||||||
const byName = new Map();
|
const byName = new Map();
|
||||||
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
|
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
|
||||||
for (const mk of markers || []) {
|
for (const mk of markers || []) {
|
||||||
@@ -378,15 +378,22 @@ function markerAnchors(turns, markers) {
|
|||||||
if (d < bd) { bd = d; pick = c.i; }
|
if (d < bd) { bd = d; pick = c.i; }
|
||||||
}
|
}
|
||||||
if (pick < 0) continue;
|
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 });
|
(byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd });
|
||||||
}
|
}
|
||||||
return byName;
|
return byName;
|
||||||
}
|
}
|
||||||
// Constraint key for a person. First name only, lowercased: the same human is
|
// Zwangsbedingungs-Schlüssel = die IDENTITÄT hinter einem Namen, nicht der Name.
|
||||||
// enrolled/typed as both "Sascha" and "Sascha Wolf" (two voiceprint rows exist),
|
// Seit 2026-08-07 ist das die Person aus `person_names`: "Sascha" und "Sascha Wolf"
|
||||||
// and treating those as two people would forbid merging their clusters.
|
// sind derselbe Mensch, ihre Cluster dürfen also verschmelzen. Ohne Personen-
|
||||||
const normName = (s) => String(s || '').trim().toLowerCase().split(/\s+/)[0];
|
// 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
|
// Anchor vote weight: 1.0 for a marker inside the turn, decaying to 0.2 at the
|
||||||
// far end of the look-back window.
|
// far end of the look-back window.
|
||||||
const anchorWeight = (d) => d <= 0 ? 1 : Math.max(0.2, 1 - 0.8 * (d / ANCHOR_BACK_SEC));
|
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.
|
// The heart of v2: window embeddings → clusters → per-turn speaker labels.
|
||||||
// Split from the audio/embedding part so the clustering can be replayed offline
|
// 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.
|
// 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.
|
// Channel compensation: L2 → subtract the recording mean → L2 again.
|
||||||
let X = ok.map(w => l2(w.vec));
|
let X = ok.map(w => l2(w.vec));
|
||||||
const mu = meanVec(X);
|
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
|
// 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
|
// names never merge). Applied to the folded stage-A clusters: the cluster a
|
||||||
// marker's turn landed in carries that name.
|
// marker's turn landed in carries that name.
|
||||||
const anchors = markerAnchors(turns, markers);
|
const anchors = markerAnchors(turns, markers, persons);
|
||||||
let tags = null;
|
let tags = null;
|
||||||
if (anchors.size) {
|
if (anchors.size) {
|
||||||
const { lab } = labelTurns(groups);
|
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 different names anchored in ONE cluster means stage A already glued
|
||||||
// two people together — the cannot-link can no longer separate them, so at
|
// two people together — the cannot-link can no longer separate them, so at
|
||||||
// least say so instead of silently keeping the louder vote.
|
// 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
|
// 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;
|
// 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]));
|
const c = dot(seedCent, centroidOf(groups[g]));
|
||||||
if (c >= linkMin) { take.push(g); continue; }
|
if (c >= linkMin) { take.push(g); continue; }
|
||||||
rejected++;
|
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]));
|
merged.push(take.flatMap(g => groups[g]));
|
||||||
mtags.push(name);
|
mtags.push(name);
|
||||||
@@ -574,10 +590,19 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr
|
|||||||
groups.forEach((g, gi) => {
|
groups.forEach((g, gi) => {
|
||||||
if (tags[gi]) return;
|
if (tags[gi]) return;
|
||||||
const c = rawCent(g);
|
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) {
|
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);
|
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;
|
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 });
|
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) {
|
for (const c of cand) {
|
||||||
if (taken.has(c.name) || tags[c.gi]) continue;
|
if (taken.has(c.name) || tags[c.gi]) continue;
|
||||||
tags[c.gi] = c.name; taken.add(c.name); vpTags++;
|
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})`);
|
if (!vpTags) console.log(`diar v2: keine Stimme sicher genug erkannt (${voiceprints.length} Abdrücke, Schwelle ${VP_MIN})`);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -445,7 +445,7 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
|
|||||||
await gate.waitIdle('diarize');
|
await gate.waitIdle('diarize');
|
||||||
await setA76Max(A76_CAP);
|
await setA76Max(A76_CAP);
|
||||||
try {
|
try {
|
||||||
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [] });
|
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [], persons: opts.persons || {} });
|
||||||
diar.assignSpeakers(words, segs);
|
diar.assignSpeakers(words, segs);
|
||||||
} catch (e) { console.error('diarize failed:', e.message); }
|
} catch (e) { console.error('diarize failed:', e.message); }
|
||||||
try { fs.unlinkSync(`${outBase}.json`); } catch {}
|
try { fs.unlinkSync(`${outBase}.json`); } catch {}
|
||||||
|
|||||||
+926
-873
File diff suppressed because it is too large
Load Diff
+27
-4
@@ -71,11 +71,34 @@ export function cosine(a, b) {
|
|||||||
return d / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
|
return d / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best enrolled voiceprint for an embedding, or null if below threshold.
|
// Beste PERSON für ein Embedding, oder null unter der Schwelle.
|
||||||
|
//
|
||||||
|
// Bewertet wird pro Person mit ihrem BESTEN Abdruck (Maximum, kein Mittel): die
|
||||||
|
// Proben einer Person stammen aus verschiedenen Sessions und Headsets, und ein
|
||||||
|
// Mittel über sie verwischt genau das, was die Stimme ausmacht. `second` ist der
|
||||||
|
// beste Wert einer ANDEREN Person — nur damit ist ein Abstands-Kriterium
|
||||||
|
// überhaupt sinnvoll. Vorher lief der Abstand über einzelne ABDRÜCKE, und zwei
|
||||||
|
// Abdrücke derselben Person (Kosinus 0,928) haben ihn zuverlässig gerissen: die
|
||||||
|
// Zwangsbedingung in diar.js war dadurch faktisch abgeschaltet.
|
||||||
export function bestMatch(emb, voiceprints, threshold = THRESHOLD) {
|
export function bestMatch(emb, voiceprints, threshold = THRESHOLD) {
|
||||||
let best = null, bs = -1;
|
const perPerson = new Map(); // Identität → bester Kosinus
|
||||||
for (const v of voiceprints) { const s = cosine(emb, v.vec); if (s > bs) { bs = s; best = v; } }
|
const meta = new Map();
|
||||||
return (best && bs >= threshold) ? { id: best.id, name: best.name, score: bs } : null;
|
for (const v of voiceprints) {
|
||||||
|
const key = v.person_id != null ? `p${v.person_id}` : `n:${String(v.name || '').toLowerCase()}`;
|
||||||
|
const s = cosine(emb, v.vec);
|
||||||
|
if (!perPerson.has(key) || s > perPerson.get(key)) {
|
||||||
|
perPerson.set(key, s);
|
||||||
|
meta.set(key, { personId: v.person_id ?? null, name: v.name, printId: v.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bestKey = null, s1 = -Infinity, s2 = -Infinity;
|
||||||
|
for (const [key, s] of perPerson) {
|
||||||
|
if (s > s1) { s2 = s1; s1 = s; bestKey = key; }
|
||||||
|
else if (s > s2) s2 = s;
|
||||||
|
}
|
||||||
|
if (bestKey === null || s1 < threshold) return null;
|
||||||
|
const m = meta.get(bestKey);
|
||||||
|
return { id: m.printId, personId: m.personId, key: bestKey, name: m.name, score: s1, second: s2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; }
|
export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; }
|
||||||
|
|||||||
+54
-8
@@ -213,17 +213,63 @@ async function viewVoices() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="vmsg" class="muted"></div>
|
<div id="vmsg" class="muted"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card"><h3>Angelernte Stimmen</h3><div id="vplist" class="list">Lade…</div></div>`;
|
<div class="card"><h3>Personen</h3>
|
||||||
|
<div class="hint">Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
|
||||||
|
mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.</div>
|
||||||
|
<div id="vplist" class="list">Lade…</div></div>`;
|
||||||
const listEl = v.querySelector('#vplist');
|
const listEl = v.querySelector('#vplist');
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const vps = await api.get('/api/voiceprints');
|
const persons = await api.get('/api/persons');
|
||||||
listEl.innerHTML = vps.length ? vps.map(p => `
|
const opts = persons.map(p => `<option value="${p.id}">${escapeHtml(p.display_name)}</option>`).join('');
|
||||||
<div class="item"><div class="grow"><b>👤 ${escapeHtml(p.name)}</b>
|
listEl.innerHTML = persons.length ? persons.map(p => `
|
||||||
<div class="muted">${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim</div></div>
|
<div class="item person" data-pid="${p.id}">
|
||||||
<button class="btn danger" data-del="${p.id}">🗑</button></div>`).join('')
|
<div class="grow">
|
||||||
: '<div class="muted">Noch keine Stimmen. Oben anlernen.</div>';
|
<b>👤 ${escapeHtml(p.display_name)}</b>
|
||||||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); });
|
<div class="chips">${p.names.map(n => `<span class="chip">${escapeHtml(n)}${p.names.length > 1
|
||||||
|
? `<button class="chipx" data-delname="${encodeURIComponent(n)}" title="Namen entfernen">✕</button>` : ''}</span>`).join('')}
|
||||||
|
<button class="btn tiny" data-addname="${p.id}">+ Name</button></div>
|
||||||
|
<div class="muted">${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print
|
||||||
|
? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale)
|
||||||
|
? ' · ⚠ andere Modellversion' : ''}</div>
|
||||||
|
</div>
|
||||||
|
${persons.length > 1 ? `<select class="input tiny" data-merge="${p.id}">
|
||||||
|
<option value="">↳ zusammenführen mit…</option>${opts}</select>` : ''}
|
||||||
|
${p.voiceprints.length ? `<button class="btn" data-clear="${p.id}" title="Stimmproben löschen, Person behalten">🎙✕</button>` : ''}
|
||||||
|
<button class="btn danger" data-delperson="${p.id}" title="Person samt Proben löschen">🗑</button>
|
||||||
|
</div>`).join('')
|
||||||
|
: '<div class="muted">Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.</div>';
|
||||||
|
|
||||||
|
listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => {
|
||||||
|
const name = prompt('Weiterer Name für diese Person:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() });
|
||||||
|
toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓');
|
||||||
|
load();
|
||||||
|
} catch (e) { toast(e.message, true); }
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => {
|
||||||
|
try { await api.del('/api/persons/names/' + b.dataset.delname); load(); }
|
||||||
|
catch (e) { toast(e.message, true); }
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => {
|
||||||
|
const fromId = sel.value; if (!fromId) return;
|
||||||
|
if (fromId === sel.dataset.merge) { sel.value = ''; return; }
|
||||||
|
if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; }
|
||||||
|
try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); }
|
||||||
|
catch (e) { toast(e.message, true); }
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => {
|
||||||
|
if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return;
|
||||||
|
const p = persons.find(x => x.id === +b.dataset.clear);
|
||||||
|
for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id);
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => {
|
||||||
|
if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return;
|
||||||
|
await api.del('/api/persons/' + b.dataset.delperson); load();
|
||||||
|
});
|
||||||
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
+577
-560
File diff suppressed because it is too large
Load Diff
+77
-9
@@ -25,7 +25,7 @@ flowchart TB
|
|||||||
MK -- ja --> CON["must-link: gleicher Name = 1 Cluster,<br/>ABER nur wenn die Cluster akustisch passen<br/>(cos ≥ 0,6 × gelernte Schwelle) —<br/>sonst Marker verwerfen (falsch getippter Zeitpunkt)<br/>cannot-link: verschiedene Namen verschmelzen NIE"]
|
MK -- ja --> CON["must-link: gleicher Name = 1 Cluster,<br/>ABER nur wenn die Cluster akustisch passen<br/>(cos ≥ 0,6 × gelernte Schwelle) —<br/>sonst Marker verwerfen (falsch getippter Zeitpunkt)<br/>cannot-link: verschiedene Namen verschmelzen NIE"]
|
||||||
MK -- nein --> VP
|
MK -- nein --> VP
|
||||||
CON --> VP{"Bekannte Stimmabdrücke?"}
|
CON --> VP{"Bekannte Stimmabdrücke?"}
|
||||||
VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen<br/>(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zum Zweitbesten,<br/>ein Name höchstens 1 Cluster)<br/>→ zusätzliches cannot-link<br/>sichert vor allem den Box-Besitzer"]
|
VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen<br/>(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zur<br/>nächstbesten PERSON, eine Person höchstens 1 Cluster)<br/>→ zusätzliches cannot-link<br/>sichert vor allem den Box-Besitzer"]
|
||||||
VP -- nein --> B
|
VP -- nein --> B
|
||||||
VPC --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
|
VPC --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
|
||||||
GMM --> B
|
GMM --> B
|
||||||
@@ -86,7 +86,7 @@ sequenceDiagram
|
|||||||
D-->>S: Segmente
|
D-->>S: Segmente
|
||||||
S->>S: applyMarkers(): resolveMarkers() → Cluster benennen
|
S->>S: applyMarkers(): resolveMarkers() → Cluster benennen
|
||||||
S->>DB: speaker_names + markers.speaker/conf
|
S->>DB: speaker_names + markers.speaker/conf
|
||||||
S->>DB: upsertVoiceprint(name) je benanntem Sprecher
|
S->>DB: learnVoice(name) je benanntem Sprecher<br/>(neue Probe der PERSON hinter dem Namen)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Auflösung** (`diar.resolveMarkers`): asymmetrisches Fenster um den Marker (25 s zurück / 8 s vorwärts,
|
- **Auflösung** (`diar.resolveMarkers`): asymmetrisches Fenster um den Marker (25 s zurück / 8 s vorwärts,
|
||||||
@@ -130,10 +130,14 @@ Jetzt gehen die Voiceprints **in** die Diarisierung (`diarize(wav, n, {markers,
|
|||||||
sitzt einer sicher darauf, wirkt der Name wie ein Marker-Tag = cannot-link.
|
sitzt einer sicher darauf, wirkt der Name wie ein Marker-Tag = cannot-link.
|
||||||
|
|
||||||
- Vergleich im **RAW**-Raum (Voiceprints sind unzentriert gespeichert), deshalb streng: `DIAR_VP_MIN` 0,55
|
- Vergleich im **RAW**-Raum (Voiceprints sind unzentriert gespeichert), deshalb streng: `DIAR_VP_MIN` 0,55
|
||||||
**und** 0,05 Abstand zum Zweitbesten, ein Name beansprucht höchstens ein Cluster (bester Score gewinnt).
|
**und** 0,05 Abstand zum Zweitbesten, eine **Person** beansprucht höchstens ein Cluster (bester Score gewinnt).
|
||||||
- Namensschlüssel = **Vorname, kleingeschrieben**: derselbe Mensch ist als „Sascha" und „Sascha Wolf"
|
- Schlüssel ist seit 2026-08-07 die **Person** (`diar.identityKey` über `person_names`), nicht mehr der
|
||||||
enrolled (ebenso Kim/Kim Nebendahl, Fabienne/Fabienne Nachtsheim) — zwei Einträge dürfen nicht zu einem
|
kleingeschriebene Vorname. Der Vornamens-Trick sollte „Sascha" und „Sascha Wolf" zusammenhalten, setzte dabei
|
||||||
cannot-link zwischen seinen eigenen Clustern führen.
|
aber auch „Markus Ufer" und „Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen). Siehe „Eine Person,
|
||||||
|
mehrere Namen" unten.
|
||||||
|
- Ebenfalls seit 2026-08-07: Score und Abstand rechnen **pro Person über deren besten Abdruck**. Vorher lief
|
||||||
|
beides über einzelne Abdrücke — zwei Abdrücke desselben Menschen (0,928) rissen die Abstandsprüfung
|
||||||
|
zuverlässig, die Zwangsbedingung war also faktisch abgeschaltet.
|
||||||
- Gegenprobe ohne Regression: #79 → 4 (vorher 3), #70 → 4, #68 → 2, #74 → 20 (unverändert mit und ohne
|
- Gegenprobe ohne Regression: #79 → 4 (vorher 3), #70 → 4, #68 → 2, #74 → 20 (unverändert mit und ohne
|
||||||
Voiceprints). Auf #79 wird Sascha mit cos 0,861 erkannt, auf #74 fünfzehn Personen (Kai 0,656 … Niklas 0,966).
|
Voiceprints). Auf #79 wird Sascha mit cos 0,861 erkannt, auf #74 fünfzehn Personen (Kai 0,656 … Niklas 0,966).
|
||||||
|
|
||||||
@@ -199,6 +203,68 @@ flowchart LR
|
|||||||
726 Turns → 1932 Fenster → **8 Sprecher**, 6 Live-Marker verankert, Diarisierung 13,5 min, keine
|
726 Turns → 1932 Fenster → **8 Sprecher**, 6 Live-Marker verankert, Diarisierung 13,5 min, keine
|
||||||
OOM-Meldung.
|
OOM-Meldung.
|
||||||
|
|
||||||
|
## Eine Person, mehrere Namen (seit 2026-08-07)
|
||||||
|
|
||||||
|
Bis dahin war der **Name** die Identität. Das trägt nicht: derselbe Mensch wird in einer Aufnahme als
|
||||||
|
„Sascha" und in der nächsten als „Sascha Wolf" benannt, und schon existierten zwei Einträge derselben
|
||||||
|
Stimme. Auf Aufnahme #101 (34 s, **zwei** Menschen) hieß das Ergebnis: `Michael`, `Sascha`,
|
||||||
|
`Sprecher 3`, `Sascha Wolf` — vier Sprecher, einer davon doppelt.
|
||||||
|
|
||||||
|
**Messung des Altbestands** (42 Abdrücke, `lab/vpcos.mjs`, Kosinus innerhalb gleicher Vornamen):
|
||||||
|
|
||||||
|
| Paar | cos | Befund |
|
||||||
|
|---|---|---|
|
||||||
|
| `Jens` ~ `Jens Kuppler` | **1,000** | exakte Dublette |
|
||||||
|
| `Sascha` ~ `Sascha Wolf` | 0,928 | dieselbe Stimme, zwei Einträge |
|
||||||
|
| `Kim` ~ `Kim Nebendahl` | 0,899 | dieselbe Stimme |
|
||||||
|
| `Fabienne` ~ `Fabienne Nachtsheim` | 0,785 | dieselbe Stimme |
|
||||||
|
| `Markus` ~ `Markus Witsch` | 0,429 | verschiedene Menschen |
|
||||||
|
| `Fabio` ~ `Fabio Haase` | 0,261 | passt nicht zusammen |
|
||||||
|
| `Johannes` ~ `Johannes Jeckel` | 0,115 | passt nicht zusammen |
|
||||||
|
| `Michael Pastler` ~ `Michael Wagner` | −0,011 | klar verschiedene Menschen |
|
||||||
|
|
||||||
|
Daraus folgen zwei Dinge. Erstens: **automatisch nach Vornamen gruppieren wäre falsch** — genau das tat
|
||||||
|
der alte `normName`-Trick in `diar.js`, und er setzte damit „Markus Ufer" und „Markus Witsch" gleich.
|
||||||
|
Zweitens: ein Teil des Bestands war **kontaminiert** (`Johannes` lag mit 0,912 an *Sascha Wolf*, aber mit
|
||||||
|
0,115 an *Johannes Jeckel*), also aus einem falsch verschmolzenen Cluster gelernt — dasselbe Muster wie
|
||||||
|
bei Merle am 2026-07-29. Der Bestand wurde deshalb geleert (Dump: `backup/voiceprints-2026-08-07.sql`)
|
||||||
|
und wird neu gelernt.
|
||||||
|
|
||||||
|
**Modell jetzt:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
persons ||--|{ person_names : "heißt auch"
|
||||||
|
persons ||--o{ voiceprints : "klingt wie"
|
||||||
|
persons { int id string display_name }
|
||||||
|
person_names { string name int person_id }
|
||||||
|
voiceprints { int id int person_id string vec string model }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `person_names.name` ist Primärschlüssel → ein Name gehört zu **genau einer** Person. Die Kollation
|
||||||
|
`utf8mb4_general_ci` macht das absichtlich case-insensitiv („sascha" = „Sascha").
|
||||||
|
- **Mehrere Proben pro Person** statt eines laufenden Mittels (`AIME_MAX_PRINTS`, Standard 8, älteste
|
||||||
|
fallen raus). Proben aus verschiedenen Sessions und Headsets zu mitteln verwischt gerade das, was die
|
||||||
|
Stimme ausmacht; bewertet wird eine Person mit ihrem **besten** Abdruck.
|
||||||
|
- **Abstand zwischen Personen, nicht zwischen Abdrücken** (`spk.bestMatch`, `diar.clusterWindows`). Vorher
|
||||||
|
rissen zwei Abdrücke desselben Menschen (0,928) die 0,05-Abstandsprüfung — die Zwangsbedingung war
|
||||||
|
damit faktisch abgeschaltet.
|
||||||
|
- Identität überall gleich: `diar.identityKey(name, persons)` bildet Marker-Namen UND Abdrücke auf
|
||||||
|
`p<person_id>` ab, `db.personNameMap()` liefert die Karte. Ohne Personen-Eintrag zählt der **volle**
|
||||||
|
Name (nicht mehr nur der Vorname).
|
||||||
|
- GUI „Stimmen" zeigt Personen mit ihren Namen als Chips: Namen hinzufügen/entfernen, zwei Personen
|
||||||
|
zusammenführen, Proben verwerfen. Vergibt man einen Namen, der schon einer anderen Person gehört,
|
||||||
|
werden beide zusammengeführt — dann ist genau das gemeint.
|
||||||
|
|
||||||
|
**Verifiziert an #101:** „Michael" und „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name an dieselbe
|
||||||
|
Person gehängt → Re-Diarisierung benennt nur noch `{"speaker_00":"Michael","speaker_01":"Sascha"}` statt
|
||||||
|
vorher drei Namen für zwei Menschen, Log: `Cluster 1 = bekannte Stimme "Sascha" (cos 0,917)`.
|
||||||
|
|
||||||
|
⚠️ **Was das NICHT löst:** die Cluster-Anzahl. #101 bleibt bei 4 Clustern für 2 Menschen (17 Roh-Cluster
|
||||||
|
→ 13 schwache gefaltet → 4), die beiden unbenannten heißen weiter „Sprecher 1/2". Das ist die
|
||||||
|
Über-Segmentierung kurzer Clips, diagnostiziert am 2026-07-31 (Mittelwert-Zentrierung erzeugt bei wenigen
|
||||||
|
Fenstern einen negativen Sockel von −1/(n−1)) — ein eigener, noch offener Punkt.
|
||||||
|
|
||||||
## Harte Grenzen / Sackgassen
|
## Harte Grenzen / Sackgassen
|
||||||
|
|
||||||
- **Überlappendes gleichzeitiges Sprechen** ist mit Mono-Mix prinzipiell nicht trennbar.
|
- **Überlappendes gleichzeitiges Sprechen** ist mit Mono-Mix prinzipiell nicht trennbar.
|
||||||
@@ -218,14 +284,16 @@ flowchart LR
|
|||||||
DIAR["Diarization: speaker_00, speaker_01, …"] --> AUTOID["identifySpeakers():<br/>Abgleich gegen gespeicherte Voiceprints"]
|
DIAR["Diarization: speaker_00, speaker_01, …"] --> AUTOID["identifySpeakers():<br/>Abgleich gegen gespeicherte Voiceprints"]
|
||||||
AUTOID --> MERGE2["mergeSpeakerNames():<br/>füllt nur Lücken, manuelle Namen gewinnen"]
|
AUTOID --> MERGE2["mergeSpeakerNames():<br/>füllt nur Lücken, manuelle Namen gewinnen"]
|
||||||
GUI["GUI Sprecher-Editor:<br/>Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"]
|
GUI["GUI Sprecher-Editor:<br/>Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"]
|
||||||
API --> VP[("voiceprints-Tabelle<br/>1 Zentroid pro Name<br/>(laufender Mittelwert, Spalte n)")]
|
API --> PERS[("persons + person_names<br/>ein Mensch, mehrere Namen")]
|
||||||
|
PERS --> VP[("voiceprints-Tabelle<br/>mehrere Proben PRO PERSON<br/>(max. 8, älteste fallen raus)")]
|
||||||
API --> NAMES[("recordings.speaker_names<br/>JSON, manuell gewinnt")]
|
API --> NAMES[("recordings.speaker_names<br/>JSON, manuell gewinnt")]
|
||||||
VP --> AUTOID
|
VP --> AUTOID
|
||||||
```
|
```
|
||||||
|
|
||||||
- Beim Lernen wird der längste zusammenhängende Sprech-Abschnitt (≥ 1,5 s, **max. 20–25 s**) des Sprechers als
|
- Beim Lernen wird der längste zusammenhängende Sprech-Abschnitt (≥ 1,5 s, **max. 20–25 s**) des Sprechers als
|
||||||
TitaNet-Embedding in den Zentroid gefaltet (`db.upsertVoiceprint`) → zukünftige Aufnahmen identifizieren die
|
TitaNet-Embedding **als neue Probe der Person** gespeichert (`db.learnVoice`) → zukünftige Aufnahmen
|
||||||
Person automatisch.
|
identifizieren die Person automatisch. Bis 2026-08-07 wurde stattdessen EIN Zentroid pro Name laufend
|
||||||
|
gemittelt; das verwischt gerade die Merkmale, wenn die Proben aus verschiedenen Sessions und Headsets kommen.
|
||||||
- ⚠️ **Warum die Schnitte kurz sein MÜSSEN (2026-07-27 root-caused):** das TitaNet-ONNX bricht bei langen
|
- ⚠️ **Warum die Schnitte kurz sein MÜSSEN (2026-07-27 root-caused):** das TitaNet-ONNX bricht bei langen
|
||||||
Eingaben ab — reproduziert bei 180 s:
|
Eingaben ab — reproduziert bei 180 s:
|
||||||
`Non-zero status code returned while running Where node … Attempting to broadcast an axis by a dimension
|
`Non-zero status code returned while running Where node … Attempting to broadcast an axis by a dimension
|
||||||
|
|||||||
@@ -3,6 +3,43 @@
|
|||||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||||
Was, Warum, betroffene Dateien/Backups).
|
Was, Warum, betroffene Dateien/Backups).
|
||||||
|
|
||||||
|
## 2026-08-07 (3) — Eine Person darf mehrere Namen haben (und mehrere Stimmproben)
|
||||||
|
|
||||||
|
**Warum:** die Identität war der NAME. Derselbe Mensch, in einer Aufnahme „Sascha" und in der nächsten
|
||||||
|
„Sascha Wolf" genannt, existierte damit zweimal — und beide Einträge konnten je ein Cluster benennen.
|
||||||
|
Aufnahme #101 (34 s, **zwei** Menschen) zeigte deshalb `Michael`, `Sascha`, `Sprecher 3`, `Sascha Wolf`.
|
||||||
|
`diar.js` hat das mit einem Vornamens-Trick überdeckt, der aber „Markus Ufer" und „Markus Witsch"
|
||||||
|
gleichsetzt (Kosinus 0,241 = zwei Menschen).
|
||||||
|
|
||||||
|
**Neu:** Tabellen `persons` + `person_names` (Name = Primärschlüssel, gehört zu genau einer Person),
|
||||||
|
`voiceprints.person_id`. Eine Person darf beliebig viele Namen **und** beliebig viele Stimmproben haben
|
||||||
|
(`AIME_MAX_PRINTS`, Standard 8) — bewertet wird sie mit ihrer **besten** Probe statt mit einem laufenden
|
||||||
|
Mittel über verschiedene Sessions und Headsets. Score UND Abstand rechnen jetzt zwischen **Personen**;
|
||||||
|
vorher rissen zwei Abdrücke desselben Menschen die 0,05-Abstandsprüfung, wodurch die Zwangsbedingung in
|
||||||
|
der Diarisierung faktisch abgeschaltet war. Identität überall: `diar.identityKey()` bildet Marker-Namen
|
||||||
|
und Abdrücke auf `p<person_id>` ab.
|
||||||
|
|
||||||
|
**GUI „Stimmen":** Personen statt Abdrücke — Namen als Chips (hinzufügen/entfernen), zwei Personen
|
||||||
|
zusammenführen, Proben verwerfen. Ein Name, der schon einer anderen Person gehört, führt beide zusammen.
|
||||||
|
|
||||||
|
**Altbestand geleert** (Dump `backup/voiceprints-2026-08-07.sql`, 42 Abdrücke): die Messung mit
|
||||||
|
`lab/vpcos.mjs` zeigte neben echten Dubletten (`Jens` ~ `Jens Kuppler` = **1,000**) auch kontaminierte
|
||||||
|
Einträge — `Johannes` lag mit 0,912 an *Sascha Wolf*, aber mit 0,115 an *Johannes Jeckel*, war also aus
|
||||||
|
einem falsch verschmolzenen Cluster gelernt (wie Merle am 2026-07-29). Neu lernen statt reparieren.
|
||||||
|
|
||||||
|
**Verifiziert an #101:** „Michael" + „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name derselben Person
|
||||||
|
→ Re-Diarisierung benennt `{"speaker_00":"Michael","speaker_01":"Sascha"}` (vorher drei Namen für zwei
|
||||||
|
Menschen), Log `Cluster 1 = bekannte Stimme "Sascha" (cos 0,917)`. GUI mit Playwright geprüft: keine
|
||||||
|
JS-Fehler, zwei Personen, Chips „Sascha" + „Sascha Wolf".
|
||||||
|
|
||||||
|
⚠️ **Nicht behoben, eigener Punkt:** die Cluster-ANZAHL. #101 bleibt 4 Cluster für 2 Menschen; die beiden
|
||||||
|
unbenannten heißen „Sprecher 1/2". Ursache ist die Über-Segmentierung kurzer Clips (Diagnose 2026-07-31,
|
||||||
|
Kap. 04) — dafür liegen drei Optionen vor, keine davon ist gewählt.
|
||||||
|
|
||||||
|
Dateien: `app/db.js`, `app/spk.js`, `app/diar.js`, `app/server.js`, `app/recorder.js`,
|
||||||
|
`app/web/src/main.js`, `app/web/src/style.css` (Board-Backups `.bak-persons`), `app/lab/vpcos.mjs`,
|
||||||
|
Kapitel [04 – Sprechertrennung](04-diarization.md).
|
||||||
|
|
||||||
## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig
|
## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig
|
||||||
|
|
||||||
**Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber
|
**Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber
|
||||||
|
|||||||
Reference in New Issue
Block a user