Files
Aime/docs/04-diarization.md
T
schakal00783andClaude Fable 5 2f00ddeec6 #70-Debug: Diar Weak-Fold (7->4 Sprecher), LLM-Robustheit + Whole-Pass-Modellgrenze, Thermal-Cap fuer analyzeRecording
- diar.js: Stufe-3 Weak-Fold (DIAR_MIN_TOTAL_SEC 20s) gegen Phantom-Sprecher
- llm.js: End-Instruktion, JSON-Repair, Sanity+Fallback, Chunk-Retry, Quote-Sanitizer, gelabelter/gewichteter Reduce; WHOLE_MAX 20000 (Gemma-4B-Grenze)
- rkllm_server.cpp: max_context_len 8192->12288
- server.js: analyzeRecording unter A76-Takt-Cap (95-97 Grad ungedeckelt)
- Doku Kap. 03/04/05 + Changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
2026-07-16 14:37:07 +02:00

73 lines
4.6 KiB
Markdown
Raw 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
```mermaid
flowchart TB
WAV["Mono-WAV"] --> SHERPA["sherpa-onnx Clustering<br/>liefert VIELE Roh-Cluster<br/>(z. B. konstant 27 bei einem 3-Personen-Call — deterministisch)"]
SHERPA --> EMB["Jedes Cluster neu einbetten<br/>TitaNet-Embedding (spk.embed)"]
EMB --> SORT["Cluster nach Länge sortieren<br/>(längste zuerst = stabile Reihenfolge)"]
SORT --> ANCHOR{"Cluster ≥ 3 s?<br/>(DIAR_MIN_SPEAKER_SEC)"}
ANCHOR -- "ja (Anchor)" --> JOIN{"Kosinus ≥ 0,5 zu<br/>bestehendem Sprecher?<br/>(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<br/>(darf NIE neuen Sprecher eröffnen)"]
MERGE --> FOLD
NEW --> FOLD
NEAREST --> FOLD{"Stufe 3: finale Sprecher mit<br/>&lt; 20 s Gesamtredezeit?<br/>(DIAR_MIN_TOTAL_SEC)"}
FOLD -- "ja (Phantom)" --> WEAK["in akustisch nächsten<br/>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():<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 --> NAMES[("recordings.speaker_names<br/>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.