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:
2026-08-07 16:18:33 +02:00
parent 084401624b
commit 2228c57522
9 changed files with 1883 additions and 1498 deletions
+54 -8
View File
@@ -213,17 +213,63 @@ async function viewVoices() {
</div>
<div id="vmsg" class="muted"></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');
async function load() {
try {
const vps = await api.get('/api/voiceprints');
listEl.innerHTML = vps.length ? vps.map(p => `
<div class="item"><div class="grow"><b>👤 ${escapeHtml(p.name)}</b>
<div class="muted">${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim</div></div>
<button class="btn danger" data-del="${p.id}">🗑</button></div>`).join('')
: '<div class="muted">Noch keine Stimmen. Oben anlernen.</div>';
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); });
const persons = await api.get('/api/persons');
const opts = persons.map(p => `<option value="${p.id}">${escapeHtml(p.display_name)}</option>`).join('');
listEl.innerHTML = persons.length ? persons.map(p => `
<div class="item person" data-pid="${p.id}">
<div class="grow">
<b>👤 ${escapeHtml(p.display_name)}</b>
<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>'; }
}
await load();
+577 -560
View File
File diff suppressed because it is too large Load Diff