# 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 ```mermaid flowchart TB WAV["Mono-WAV"] --> SHERPA["sherpa-onnx Clustering
liefert VIELE Roh-Cluster
(z. B. konstant 27 bei einem 3-Personen-Call — deterministisch)"] SHERPA --> EMB["Jedes Cluster neu einbetten
TitaNet-Embedding (spk.embed)"] EMB --> SORT["Cluster nach Länge sortieren
(längste zuerst = stabile Reihenfolge)"] SORT --> ANCHOR{"Cluster ≥ 3 s?
(DIAR_MIN_SPEAKER_SEC)"} ANCHOR -- "ja (Anchor)" --> JOIN{"Kosinus ≥ 0,5 zu
bestehendem Sprecher?
(DIAR_MERGE_THRESHOLD)"} JOIN -- ja --> MERGE["dem Sprecher zuordnen"] JOIN -- nein --> NEW["neuen Sprecher eröffnen"] ANCHOR -- "nein (kurz/Streu)" --> NEAREST["IMMER zum nächstliegenden Anchor
(darf NIE neuen Sprecher eröffnen)"] MERGE --> FOLD NEW --> FOLD NEAREST --> FOLD{"Stufe 3: finale Sprecher mit
< 20 s Gesamtredezeit?
(DIAR_MIN_TOTAL_SEC)"} FOLD -- "ja (Phantom)" --> WEAK["in akustisch nächsten
starken Sprecher falten"] FOLD -- nein --> OUT["finale Sprecher (z. B. 50 → 4)"] WEAK --> OUT ``` ## Warum Greedy-Anchor-Merge (Geschichte der Fixes) 1. **Schwellwert-Tuning nutzlos:** Sweep auf 2-Personen-Clip: Threshold 0,7 → 12 Sprecher … 0,95 → 8. Nie 2. 2. **Union-Find (1. Konsolidierung) instabil:** paarweises Mergen bildet transitive Ketten (A~B~C zieht alles zusammen). Ein Grenzfall-Link um Kosinus ≈ 0,5 kippte je nach FP-Rauschen (Multi-Thread-TitaNet unter CPU-Last) → derselbe Call ergab 2 / 3 / 7 Sprecher in drei Läufen. 3. **Greedy-Anchor-Merge (`diar.js consolidateSpeakers`):** nur lange Cluster (verlässliche Embeddings) dürfen Sprecher eröffnen, Abarbeitung längste-zuerst (ordnungsstabil), kurze Cluster können nur zugeordnet werden → Rauschen kann nichts mehr fragmentieren. Verifiziert: 5/5 Läufe konstant 27 → 3. 4. **Stufe 3: Weak-Fold (2026-07-16, `.bak-weakfold`):** ein einzelner ≥ 3-s-Streu-Cluster kann trotzdem einen Anchor eröffnen, wenn sein (kurzes, unzuverlässiges) Embedding unter Kosinus 0,5 zu allen echten Sprechern landet — Aufnahme #70 (26 min, 4 Personen) bekam so **3 Phantom-Sprecher mit 2,5–4,8 s** Gesamtredezeit neben echten mit 53–736 s. Fix: jeder FINALE Sprecher unter `DIAR_MIN_TOTAL_SEC` (20 s, gemessen auf Segment-Zeiten, die ~20–40 % über Wort-Summen liegen — 10 s ließ einen 9,2-s-Phantom durch) wird in den akustisch nächsten starken Sprecher gefaltet. Verifiziert: #70 konstant 50 → **exakt 4**. Tradeoff: wer im ganzen Gespräch < 20 s redet, wird einem anderen Sprecher zugeschlagen. ## 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. - Diarization-Timeout skaliert mit Dauer (`diarTimeoutMs()` = Dauer × 2,5, min. 20 min) — der feste 10-min-Timeout hat 46-min-Aufnahmen abgebrochen. (`parse-options.cc:373`-Ausgabe ist harmloses argv-Echo.) ## 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) des Sprechers als TitaNet-Embedding in den Zentroid gefaltet (`db.upsertVoiceprint`) → zukünftige Aufnahmen identifizieren die Person automatisch. - 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.