# 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
cannot-link: verschiedene Namen
dürfen NIE verschmelzen"] MK -- nein --> B CON --> 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. ## 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) 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.