Files
Aime/docs/04-diarization.md
Sascha 2228c57522 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.
2026-08-07 16:18:33 +02:00

318 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04 — Sprechertrennung (Diarization) & Stimm-Profile
**Anforderung:** akustische Sprecher-ID ist Pflicht (Raumspinne: 3–4 Personen auf einem BT-Kanal).
Sprecheranzahl soll **dynamisch** erkannt werden (Personen kommen/gehen) — kein festes Pinnen nötig.
## Pipeline v2 (seit 2026-07-27)
sherpa liefert nur noch die **Turn-Grenzen** (pyannote-Segmentierung); die Identität — wer ist wer —
berechnet Aime selbst auf **Fenster**-Ebene. Grund: siehe „Warum v2" unten.
```mermaid
flowchart TB
WAV["Mono-WAV 16 kHz"] --> SHERPA["sherpa-onnx<br/>Segmentierung (pyannote)<br/>--min-duration-off=0, threshold 0.2<br/>Labels werden VERWORFEN"]
SHERPA --> TURNS["Turns (z. B. 428 bei 33 min)"]
TURNS --> WIN["Fenster schneiden: 3 s, Hop 1,5 s<br/>(PCM-Slicing ohne ffmpeg, adaptiver Hop bis max. 4000 Fenster)"]
WIN --> EMB["TitaNet-Embedding pro Fenster<br/>(spk.embed, ~1200 Fenster ≈ 60 s)"]
EMB --> CENTER["L2 → Aufnahme-Mittelwert abziehen → L2<br/>(Kanal-/Raum-Kompensation:<br/>Kreuz-Kosinus ≈ 0 statt ≈ 0,4; d' 3,5)"]
CENTER --> GMM["Schwelle aus DIESER Aufnahme lernen:<br/>2-Gauß-Mischung (EM) auf Paar-Ähnlichkeiten<br/>→ Schnittpunkt = Merge-Schwelle"]
CENTER --> A["Stufe A: Average-Linkage-AHC<br/>fein 0,45 → absichtliche Über-Clusterung"]
A --> FOLD{"Cluster-Redezeit<br/>< min(20 s, 2 % der Sprechzeit)?"}
FOLD -- ja --> WEAK["in akustisch nächsten<br/>starken Cluster falten<br/>(Übersprechen, Türgeräusche)"]
FOLD -- nein --> STRONG["starker Cluster"]
WEAK --> MK
STRONG --> MK{"Live-Namensmarker vorhanden?"}
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 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
B --> LAB["Turn-Label = dauergewichtete<br/>Mehrheit seiner Fenster"]
LAB --> OUT["finale Sprecher<br/>(#74: 44 → 20)"]
```
### Gemessene Ergebnisse (2026-07-27)
| Aufnahme | Wahrheit | alt (v1) | neu (v2) | gelernte Schwelle |
|---|---|---|---|---|
| #74 Weekly, 33 min | 22–23 Personen | **44 Sprecher** (gleiche Stimme mehrfach) | **20** | 0,202 |
| #70 Call, 26 min | 4 | 4 | **4** | 0,070 |
| #68 Call, 2 min | 2 | 2 | **2** | 0,050 (Clamp-Minimum) |
## Warum v2 (Messungen statt Raten)
1. **v1-Diagnose an #74:** ein Zentroid pro (schon falschem) sherpa-Cluster aus max. 3 Schnitten ist zu grob,
um 20+ Stimmen zu trennen; dazu kam eine feste Kosinus-Schwelle auf ROHEN Embeddings, die prinzipiell nicht
funktioniert: unzentrierte TitaNet-Vektoren einer Aufnahme teilen eine große gemeinsame Komponente
(Kreuz-Turn-Kosinus ≈ 0,4). Ergebnis: 51 Roh-Cluster → 44 „Sprecher".
2. **Mittelwert-Zentrierung ist der größte Einzelgewinn:** Kreuz-Turn-Kosinus geht auf ≈ 0,
Trennschärfe d' (gleicher vs. anderer Sprecher) steigt auf 3,5.
3. **Average-Linkage statt Greedy-First-Match:** alle Paare eines Clusters entscheiden über den Merge,
nicht ein einzelner Grenzfall-Link (der v1 kippen ließ: derselbe Call ergab 2/3/7 Sprecher).
4. **Embedding-Modell ist NICHT der Hebel** (Benchmark auf #74, 1181 Fenster, zentriert):
TitaNet-large d' 3,75 / 197 s · CAM++ (3D-Speaker) d' 3,72 / 82 s · **TitaNet-small d' 3,54 / 57 s** ·
wespeaker VoxCeleb resnet34 d' 2,43 / 185 s. Durch die v2-Pipeline geschickt landen die drei guten
Modelle innerhalb eines Sprechers (21/22/21 bei 23 Personen) → TitaNet-small bleibt Default
(schnellste, und alle bereits gelernten Voiceprints bleiben gültig). `SPK_MODEL` schaltet um;
Voiceprints werden ab jetzt pro Modell gespeichert (Spalte `model`), weil Kosinus über Modelle hinweg
Rauschen ist.
5. **Eine feste Merge-Schwelle kann es nicht geben:** 0,30 ergab auf #74 21 Sprecher (gut), auf #70 aber 9
(statt 4); 0,15 fixte #70 und ließ #74 auf 14 kollabieren. Die Paar-Ähnlichkeits-Verteilung EINER Aufnahme
ist zweigipflig („anderer Sprecher" ≈ 0, „gleicher Sprecher" je nach Audioqualität 0,21–0,30) — deshalb
wird die Schwelle pro Aufnahme per 2-Gauß-EM gelernt (`mergeThreshold()`), geklemmt auf 0,05–0,35.
`DIAR_COS_FINAL` pinnt sie zum Debuggen.
## Live-Namensmarker (seit 2026-07-27)
Während der Aufnahme tippt man den Namen der Person, die GERADE spricht (Kopfzeile, Enter). Der Zeitstempel
(Offset zum Aufnahmestart) landet in der Tabelle `markers`. Das ist bei 20+ Teilnehmern der einzige
verlässliche Weg zu echten Namen — und zusätzlich **Supervision für die Trennung**.
```mermaid
sequenceDiagram
participant U as Nutzer (GUI)
participant S as server.js
participant R as recorder.js
participant D as diar.js
participant DB as MariaDB
U->>S: POST /api/record/marker {name}
S->>R: addMarker(name) → t_ms = jetzt - Start
U->>S: POST /api/record/stop
S->>DB: addMarkers(recId, [{t_ms,name}])
S->>D: diarize(wav, n, {markers})
Note over D: Marker → Anker-Turn<br/>(deckt t_ms ab, sonst bis 25 s davor / 8 s danach)<br/>must-link + cannot-link in Stufe B
D-->>S: Segmente
S->>S: applyMarkers(): resolveMarkers() → Cluster benennen
S->>DB: speaker_names + markers.speaker/conf
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,
Exponential-Gewichtung), weil man den Namen erst tippt, nachdem man die Stimme gehört hat. Ein Turn, der
den Marker überdeckt, gewinnt; `conf` = high / mid / low.
- **Cluster-Zusammenführung:** landet EIN Name sicher auf MEHREREN Clustern, war es dieselbe Person →
`applyMarkers()` führt sie zusammen (nutzergestützter Fix für Rest-Übersegmentierung).
- **Nachtragen:** im Detail einer Aufnahme kann an der aktuellen Abspielposition ein Marker gesetzt werden
(`POST /api/recordings/:id/markers`), „↻ Zuordnen" rechnet die Zuordnung neu (`apply-markers`, ohne sherpa).
- Marker-Namen sind manuell = autoritativ; die akustische Auto-Identifikation füllt nur Lücken —
**mit einer Ausnahme, siehe unten** (unsicher verankerter Marker verliert gegen einen Stimmabdruck).
## Ein falsch getippter Marker kostete einen Sprecher (2026-07-29)
**Symptom (Aufnahme #79, 23-min-Jour-fixe, 4 Personen):** nur 3 Sprecher, Sascha fehlte komplett.
**Ursache, gemessen im Labor:** Stufe A + Faltung lieferten korrekt **4 starke Cluster**. Es gab 4 Marker
mit 3 Namen — „Merle" war zweimal getippt worden, der zweite davon landete auf **Saschas** Cluster. Das
alte must-link verschmolz beide „Merle"-Cluster bedingungslos, also Sascha in Merle hinein. Ihre
Kosinus-Ähnlichkeit war **−0,727**, d. h. das Signal war eindeutig da — es wurde nur nicht ausgewertet.
**Drei Absicherungen:**
| Ebene | vorher | jetzt |
|---|---|---|
| Anker-Gewicht (`markerAnchors`) | jeder Marker zählt 1 | Marker IM Turn zählt 1,0, bei 25 s Tippverzögerung 0,2 |
| must-link (`clusterWindows`) | verschmilzt immer | nur bei `cos ≥ 0,6 × gelernter Schwelle`, sonst Marker verwerfen + loggen |
| Cluster-Fold (`applyMarkers`, server.js) | ab `conf ≠ low` | **nur `conf = high`**; `mid` darf noch benennen, aber nicht mehr verschmelzen |
Zusätzlich: trägt EIN Cluster Marker mehrerer Namen, war er schon vor Stufe B verschmolzen — das wird
jetzt als Warnung geloggt statt still den lauteren Vote zu nehmen.
## Stimmabdrücke als Trennkriterium — der Besitzer zählt immer (2026-07-29)
Die Stimm-Identifikation lief bisher **nach** dem Clustering (`identifySpeakers`): sie konnte benennen,
was herauskam, aber nie verhindern, dass zwei bekannte Personen in einem Cluster landen. Gerade der
Besitzer der Box ist enrolled und spricht in JEDER Aufnahme — genau er verschwand in #79.
Jetzt gehen die Voiceprints **in** die Diarisierung (`diarize(wav, n, {markers, voiceprints})`, gefüllt in
`rediarize()` und `transcribeAndAnalyze()`). Jedes noch namenlose Cluster wird gegen die Abdrücke geprüft;
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, 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).
**Nachwirkung im server:** ein Name, der aus einem Marker mit `conf ≠ high` stammt, verliert gegen einen
widersprechenden Stimmabdruck (`identify #id: Stimmabdruck "X" schlägt unsicheren Marker "Y"`). In #79
hatte der Marker bei 6,6 s Saschas Cluster „Markus" getauft.
## Abschnittsweiser sherpa-Lauf (seit 2026-08-07) — der RAM-Bedarf wuchs quadratisch
**Symptom:** Aufnahme #99 (53:56) endete mit „Transkription fehlgeschlagen“, die GUI zeigte
„failed to fetch“. In Wahrheit war die Transkription längst fertig (8666 Wörter, RTF 0,394);
gestorben ist der sherpa-Lauf danach:
```
Out of memory: Killed process 3107072 (sherpa-onnx-off) total-vm:8387852kB, anon-rss:5463416kB
callrec.service: A process of this unit has been killed by the OOM killer.
callrec.service: Failed with result 'oom-kill'.
```
**Gemessen** (gleiche Aufnahme, Anfangsstücke verschiedener Länge, A76 auf 1,608 GHz, `VmHWM`):
| Audio | Spitzen-RSS | Turns | Laufzeit |
|---|---|---|---|
| 10 min | 349 MB | 130 | 130 s |
| 20 min | 1389 MB | 242 | 300 s |
| 30 min | 2643 MB | 361 | 459 s |
| 54 min | **5,4 GB → OOM** | — | Abbruch nach ~14 min |
Doppelte Länge = **vierfacher** Speicher: sherpa hält das ganze Gespräch plus seinen Clustering-Zustand
im RAM (O(n²) über die Turns). Auf 8 GB ohne echten Swap (nur zram) ist damit bei ~45 min Schluss.
**Fix:** v2 wirft sherpas **Labels** ohnehin weg und braucht nur die **Turn-Grenzen** — also läuft der
Lauf jetzt in Abschnitten von 600 s (`DIAR_CHUNK_SEC`, `0` schaltet ab) und die Grenzen werden mit
Zeit-Offset zusammengesetzt. Der Speicher bleibt damit auf dem Niveau eines Abschnitts, **unabhängig
von der Gesprächslänge** — und weil die Kosten quadratisch waren, ist es zusätzlich schneller
(6 × ~150 s statt eines Laufs, der nach 14 min noch nicht fertig war).
```mermaid
flowchart LR
WAV["WAV 54 min"] --> CUT["PCM-Slicing in 600-s-Abschnitte<br/>je ±5 s Kontext<br/>(pyannote schaut über 10-s-Fenster)"]
CUT --> S1["sherpa Abschnitt 1<br/>0–600 s"]
CUT --> S2["sherpa Abschnitt 2<br/>600–1200 s"]
CUT --> SN["… Abschnitt n"]
S1 --> CLIP["nur Turns im EIGENEN Bereich behalten,<br/>auf ihn beschneiden, + t0 verschieben"]
S2 --> CLIP
SN --> CLIP
CLIP --> ALL["Turn-Grenzen der ganzen Aufnahme<br/>(#99: 726 Turns)"]
ALL --> V2["weiter wie Pipeline v2<br/>(Fenster → Embeddings → AHC)"]
```
- Kontext von ±5 s pro Abschnitt, damit die Naht die Grenzerkennung nicht verwischt; die Turns des
Kontexts gehören dem Nachbar-Abschnitt und werden verworfen.
- **Preis:** ein Turn, der genau auf einer Naht liegt, wird in zwei geteilt. Das Re-Clustering vergibt
beiden Hälften ohnehin denselben Sprecher — bei 600 s Abschnittslänge sind das ≤ 5 Nähte pro Stunde.
- Der **Legacy-Pfad** (`DIAR_MODE=legacy`) läuft weiter unzerteilt: er braucht sherpas eigenes,
globales Clustering und lässt sich nicht abschnittsweise rechnen.
- **Zweite Sicherung:** `OOMPolicy=continue` (Drop-in `system/etc/systemd/system/callrec.service.d/oom.conf`).
systemd stoppt eine Unit per Default komplett, sobald *ein* Kindprozess OOM-getötet wird — genau das
riss die App mit (daher „failed to fetch“). Diarisierung und Analyse sind im Code bereits nicht-fatal,
also überlebt die App jetzt einen OOM-getöteten Helfer und behält Transkript + Aufnahme.
**Verifiziert** an #99 (Re-Diarisierung 2026-08-07): 6 Abschnitte, Spitzen-RSS **363 MB** statt 5,4 GB,
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.
- Geclipptes Audio (0 dBFS) zerstört Embeddings → Fantasie-Sprecher (deshalb Gain-Staging, Kap. 02).
- sherpa-Quantisierung q5_0: langsamer UND schlechter auf diesem aarch64-Build — tot.
- Wer im ganzen Gespräch weniger als `min(20 s, 2 % der Sprechzeit)` redet, wird einem anderen Sprecher
zugeschlagen (Weak-Fold) — sonst überleben Übersprech-Fragmente als „Sprecher".
- Diarization-Timeout skaliert mit Dauer (`timeoutForSec()` = Dauer × 2,5, min. 20 min — bei
abschnittsweisem Lauf pro Abschnitt). (`parse-options.cc:373`-Ausgabe ist harmloses argv-Echo.)
- **v1 ist nicht weg:** `DIAR_MODE=legacy` schaltet auf die alte Anchor-Merge-Konsolidierung zurück
(`consolidateSpeakers()` bleibt als Fallback, wenn die v2-Fenster-Embeddings scheitern).
## Sprecher-Namen & Stimm-Profile (Enrollment)
```mermaid
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 --> 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 **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
other than 1. 12288 by 17998` → der `spk_embed`-Helfer stirbt (20 s / 60 s laufen sauber). Auf #74 war der
längste Lauf eines Moderators minutenlang; damit starb der Helfer beim ERSTEN Sprecher und — weil
`spk.js` seine Aufrufe über eine Promise-Kette serialisiert und `.then()` auf einer *rejected* Promise den
Callback überspringt — jeder weitere Sprecher mit „spk beendet". Das war die Ursache des seit 2026-07-16
offenen Rätsels „Voice-Auto-Naming tot trotz freiem RAM". Zwei Fixes: `identifySpeakers()` schneidet auf
`ID_CUT_SEC = 25 s`, und die Kette in `spk.js` (ebenso `tdChain` in `recorder.js`) wird aus einer
geschluckten Kopie fortgeführt → ein Absturz kostet nur noch diesen einen Aufruf (verifiziert:
180-s-Schnitt scheitert, der nächste 20-s-Schnitt liefert wieder ein Embedding).
- Ein enrolltes Profil kann nur EINEN Cluster benennen: pro Name gewinnt der beste Kosinus
(#74 hatte „Fabio" gleichzeitig auf zwei Clustern).
- Teilnehmerliste der Analyse kommt seit 2026-07-15 **aus der Diarization** (Namen wo bekannt, sonst
„Sprecher N“) — nicht mehr vom LLM (das listete alle bloß *erwähnten* Namen, 11 statt 3). Siehe Kap. 05.
## Re-Diarization
- GUI „↻ Analyse neu“ → Prompt: leer = automatisch (konsolidiert), Zahl = Anzahl gepinnt
(`--clustering.num-clusters`). Läuft immer unter A76-Takt-Cap (lüfterlos!).
- `db.replaceWords()` tauscht nur die Sprecher-Zuordnung; Text/Zeitstempel bleiben.