# 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
Segmentierung (pyannote)
--min-duration-off=0, threshold 0.2
Labels werden VERWORFEN"]
SHERPA --> TURNS["Turns (z. B. 428 bei 33 min)"]
TURNS --> WIN["Fenster schneiden: 3 s, Hop 1,5 s
(PCM-Slicing ohne ffmpeg, adaptiver Hop bis max. 4000 Fenster)"]
WIN --> EMB["TitaNet-Embedding pro Fenster
(spk.embed, ~1200 Fenster ≈ 60 s)"]
EMB --> CENTER["L2 → Aufnahme-Mittelwert abziehen → L2
(Kanal-/Raum-Kompensation:
Kreuz-Kosinus ≈ 0 statt ≈ 0,4; d' 3,5)"]
CENTER --> GMM["Schwelle aus DIESER Aufnahme lernen:
2-Gauß-Mischung (EM) auf Paar-Ähnlichkeiten
→ Schnittpunkt = Merge-Schwelle"]
CENTER --> A["Stufe A: Average-Linkage-AHC
fein 0,45 → absichtliche Über-Clusterung"]
A --> FOLD{"Cluster-Redezeit
< min(20 s, 2 % der Sprechzeit)?"}
FOLD -- ja --> WEAK["in akustisch nächsten
starken Cluster falten
(Ü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,
ABER nur wenn die Cluster akustisch passen
(cos ≥ 0,6 × gelernte Schwelle) —
sonst Marker verwerfen (falsch getippter Zeitpunkt)
cannot-link: verschiedene Namen verschmelzen NIE"]
MK -- nein --> VP
CON --> VP{"Bekannte Stimmabdrücke?"}
VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen
(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zum Zweitbesten,
ein Name höchstens 1 Cluster)
→ zusätzliches cannot-link
sichert vor allem den Box-Besitzer"]
VP -- nein --> B
VPC --> B["Stufe B: weiter mergen bis Schwelle
(GMM-Wert) ODER bis gepinnte Anzahl"]
GMM --> B
B --> LAB["Turn-Label = dauergewichtete
Mehrheit seiner Fenster"]
LAB --> OUT["finale Sprecher
(#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
(deckt t_ms ab, sonst bis 25 s davor / 8 s danach)
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: upsertVoiceprint(name) je benanntem Sprecher
```
- **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, 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.
- 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.
## 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 (`diarTimeoutMs()` = Dauer × 2,5, min. 20 min).
(`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():
Abgleich gegen gespeicherte Voiceprints"]
AUTOID --> MERGE2["mergeSpeakerNames():
füllt nur Lücken, manuelle Namen gewinnen"]
GUI["GUI Sprecher-Editor:
Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"]
API --> VP[("voiceprints-Tabelle
1 Zentroid pro Name
(laufender Mittelwert, Spalte n)")]
API --> NAMES[("recordings.speaker_names
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.
- ⚠️ **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.