Files
Aime/docs/04-diarization.md
T
Claude 61531fc50a Fix: Voice-Auto-Naming tot (TitaNet bricht bei langen Schnitten ab + vergiftete Promise-Kette)
identifySpeakers() embettete den laengsten Lauf ungekuerzt; TitaNet-ONNX bricht
ab 180 s ab (broadcast 12288 by 17998) und riss den spk_embed-Helfer mit. Da
spk.js die Aufrufe ueber eine Promise-Kette serialisiert, schlugen danach ALLE
weiteren Sprecher mit 'spk beendet' fehl.

- ID_CUT_SEC=25s in identifySpeakers
- Kette in spk.js und tdChain in recorder.js failure-isoliert
- pro Name gewinnt nur der beste Kosinus (Fabio war auf 2 Clustern)
2026-07-27 17:30:43 +02:00

146 lines
9.3 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 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<br/>Segmentierung (pyannote)<br/>--min-duration-off=0, threshold 0.2<br/>Labels werden VERWORFEN"]
SHERPA --> TURNS["Turns (z. B. 428 bei 33 min)"]
TURNS --> WIN["Fenster schneiden: 3 s, Hop 1,5 s<br/>(PCM-Slicing ohne ffmpeg, adaptiver Hop bis max. 4000 Fenster)"]
WIN --> EMB["TitaNet-Embedding pro Fenster<br/>(spk.embed, ~1200 Fenster ≈ 60 s)"]
EMB --> CENTER["L2 → Aufnahme-Mittelwert abziehen → L2<br/>(Kanal-/Raum-Kompensation:<br/>Kreuz-Kosinus ≈ 0 statt ≈ 0,4; d' 3,5)"]
CENTER --> GMM["Schwelle aus DIESER Aufnahme lernen:<br/>2-Gauß-Mischung (EM) auf Paar-Ähnlichkeiten<br/>→ Schnittpunkt = Merge-Schwelle"]
CENTER --> A["Stufe A: Average-Linkage-AHC<br/>fein 0,45 → absichtliche Über-Clusterung"]
A --> FOLD{"Cluster-Redezeit<br/>< min(20 s, 2 % der Sprechzeit)?"}
FOLD -- ja --> WEAK["in akustisch nächsten<br/>starken Cluster falten<br/>(Ü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<br/>cannot-link: verschiedene Namen<br/>dürfen NIE verschmelzen"]
MK -- nein --> B
CON --> B["Stufe B: weiter mergen bis Schwelle<br/>(GMM-Wert) ODER bis gepinnte Anzahl"]
GMM --> B
B --> LAB["Turn-Label = dauergewichtete<br/>Mehrheit seiner Fenster"]
LAB --> OUT["finale Sprecher<br/>(#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<br/>(deckt t_ms ab, sonst bis 25 s davor / 8 s danach)<br/>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():<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, **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.