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

4.6 KiB
Raw Blame History

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

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 (ABC 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)

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.