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
+77 -9
View File
@@ -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 -- nein --> VP
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
VPC --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
GMM --> B
@@ -86,7 +86,7 @@ sequenceDiagram
D-->>S: Segmente
S->>S: applyMarkers(): resolveMarkers() → Cluster benennen
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,
@@ -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.
- 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).
- Namensschlüssel = **Vorname, kleingeschrieben**: derselbe Mensch ist als „Sascha" und „Sascha Wolf"
enrolled (ebenso Kim/Kim Nebendahl, Fabienne/Fabienne Nachtsheim) — zwei Einträge dürfen nicht zu einem
cannot-link zwischen seinen eigenen Clustern führen.
**und** 0,05 Abstand zum Zweitbesten, eine **Person** beansprucht höchstens ein Cluster (bester Score gewinnt).
- Schlüssel ist seit 2026-08-07 die **Person** (`diar.identityKey` über `person_names`), nicht mehr der
kleingeschriebene Vorname. Der Vornamens-Trick sollte „Sascha" und „Sascha Wolf" zusammenhalten, setzte dabei
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
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
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
- **Ü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"]
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}"]
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")]
VP --> AUTOID
```
- 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
Person automatisch.
TitaNet-Embedding **als neue Probe der Person** gespeichert (`db.learnVoice`) → zukünftige Aufnahmen
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
Eingaben ab — reproduziert bei 180 s:
`Non-zero status code returned while running Where node … Attempting to broadcast an axis by a dimension