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.
|
||||
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`);
|
||||
// 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"
|
||||
// heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped
|
||||
// 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;
|
||||
}
|
||||
|
||||
export async function addVoiceprint(name, vec, model) {
|
||||
const [res] = await pool.execute(
|
||||
'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)',
|
||||
[name, vec.length, JSON.stringify(vec), model || null]);
|
||||
return res.insertId;
|
||||
// --- Personen (Identität) und ihre Stimmabdrücke ------------------------------
|
||||
// Höchstzahl Abdrücke je Person und Modell. Mehrere Proben sind der Sinn der
|
||||
// Sache (verschiedene Sessions), aber unbegrenzt wachsen darf es nicht: jede
|
||||
// Probe kostet bei jeder Identifikation einen Kosinus. Älteste fallen raus.
|
||||
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
|
||||
// centroid (mean of all samples). This is how the app "learns who is who": every
|
||||
// time a speaker is named in a recording, their voice is folded into the centroid,
|
||||
// so future recordings match them more reliably. New name -> fresh row.
|
||||
export async function upsertVoiceprint(name, vec, model) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1',
|
||||
[name, model || null, model || null]);
|
||||
if (!rows.length) {
|
||||
const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n, model) VALUES (?,?,?,1,?)',
|
||||
[name, vec.length, JSON.stringify(vec), model || null]);
|
||||
return res.insertId;
|
||||
// Weiteren Namen an eine Person hängen ("Sascha" und "Sascha Wolf" = ein Mensch).
|
||||
// Gehört der Name schon einer ANDEREN Person, werden die beiden zusammengeführt —
|
||||
// das ist die Absicht des Nutzers, wenn er denselben Namen zweimal vergibt.
|
||||
export async function addPersonName(personId, name) {
|
||||
const n = String(name || '').trim();
|
||||
if (!n) throw new Error('Name fehlt');
|
||||
const [rows] = await pool.execute('SELECT person_id FROM person_names WHERE name=?', [n]);
|
||||
if (rows.length) {
|
||||
if (rows[0].person_id === +personId) return { merged: false };
|
||||
await mergePersons(personId, rows[0].person_id);
|
||||
return { merged: true };
|
||||
}
|
||||
const old = rows[0];
|
||||
const on = old.n || 1;
|
||||
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;
|
||||
await pool.execute('INSERT INTO person_names (name, person_id) VALUES (?,?)', [n, personId]);
|
||||
return { merged: false };
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
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
|
||||
? await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints WHERE model=? ORDER BY name', [model])
|
||||
: await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints ORDER BY name');
|
||||
? await pool.query(`${sql} WHERE v.model=? ORDER BY name, v.created_at`, [model])
|
||||
: await pool.query(`${sql} ORDER BY name, v.created_at`);
|
||||
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
|
||||
}
|
||||
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
|
||||
|
||||
Reference in New Issue
Block a user