Files
Aime/docs/04-diarization.md
T
Sascha c5daee25af fix(diar): sherpa abschnittsweise laufen lassen (OOM bei langen Aufnahmen)
Der sherpa-Diarisierungslauf brauchte RAM quadratisch zur Gesprächslänge
(gemessen 349 MB bei 10 min, 1389 MB bei 20 min, 2643 MB bei 30 min). Die
54-min-Aufnahme #99 riss 5,4 GB, wurde OOM-getötet und nahm wegen
OOMPolicy=stop die ganze App mit — in der GUI "Transkription fehlgeschlagen"
und "failed to fetch".

Die v2-Pipeline verwirft sherpas Labels und nutzt nur die Turn-Grenzen, also
laeuft der Pass jetzt in 600-s-Abschnitten (je +-5 s Kontext) und die Grenzen
werden mit Zeit-Offset zusammengesetzt. Zusätzlich OOMPolicy=continue, damit
ein OOM-getöteter Helfer die App nicht mehr stoppt.

Verifiziert an #99: sherpa-Spitze 363 MB, 726 Turns, 8 Sprecher, Analyse
vollstaendig (done/done).
2026-08-07 13:50:01 +02:00

250 lines
16 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/>ABER nur wenn die Cluster akustisch passen<br/>(cos ≥ 0,6 × gelernte Schwelle) —<br/>sonst Marker verwerfen (falsch getippter Zeitpunkt)<br/>cannot-link: verschiedene Namen verschmelzen NIE"]
MK -- nein --> VP
CON --> VP{"Bekannte Stimmabdrücke?"}
VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen<br/>(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zum Zweitbesten,<br/>ein Name höchstens 1 Cluster)<br/>→ zusätzliches cannot-link<br/>sichert vor allem den Box-Besitzer"]
VP -- nein --> B
VPC --> 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 —
**mit einer Ausnahme, siehe unten** (unsicher verankerter Marker verliert gegen einen Stimmabdruck).
## Ein falsch getippter Marker kostete einen Sprecher (2026-07-29)
**Symptom (Aufnahme #79, 23-min-Jour-fixe, 4 Personen):** nur 3 Sprecher, Sascha fehlte komplett.
**Ursache, gemessen im Labor:** Stufe A + Faltung lieferten korrekt **4 starke Cluster**. Es gab 4 Marker
mit 3 Namen — „Merle" war zweimal getippt worden, der zweite davon landete auf **Saschas** Cluster. Das
alte must-link verschmolz beide „Merle"-Cluster bedingungslos, also Sascha in Merle hinein. Ihre
Kosinus-Ähnlichkeit war **−0,727**, d. h. das Signal war eindeutig da — es wurde nur nicht ausgewertet.
**Drei Absicherungen:**
| Ebene | vorher | jetzt |
|---|---|---|
| Anker-Gewicht (`markerAnchors`) | jeder Marker zählt 1 | Marker IM Turn zählt 1,0, bei 25 s Tippverzögerung 0,2 |
| must-link (`clusterWindows`) | verschmilzt immer | nur bei `cos ≥ 0,6 × gelernter Schwelle`, sonst Marker verwerfen + loggen |
| Cluster-Fold (`applyMarkers`, server.js) | ab `conf ≠ low` | **nur `conf = high`**; `mid` darf noch benennen, aber nicht mehr verschmelzen |
Zusätzlich: trägt EIN Cluster Marker mehrerer Namen, war er schon vor Stufe B verschmolzen — das wird
jetzt als Warnung geloggt statt still den lauteren Vote zu nehmen.
## Stimmabdrücke als Trennkriterium — der Besitzer zählt immer (2026-07-29)
Die Stimm-Identifikation lief bisher **nach** dem Clustering (`identifySpeakers`): sie konnte benennen,
was herauskam, aber nie verhindern, dass zwei bekannte Personen in einem Cluster landen. Gerade der
Besitzer der Box ist enrolled und spricht in JEDER Aufnahme — genau er verschwand in #79.
Jetzt gehen die Voiceprints **in** die Diarisierung (`diarize(wav, n, {markers, voiceprints})`, gefüllt in
`rediarize()` und `transcribeAndAnalyze()`). Jedes noch namenlose Cluster wird gegen die Abdrücke geprüft;
sitzt einer sicher darauf, wirkt der Name wie ein Marker-Tag = cannot-link.
- Vergleich im **RAW**-Raum (Voiceprints sind unzentriert gespeichert), deshalb streng: `DIAR_VP_MIN` 0,55
**und** 0,05 Abstand zum Zweitbesten, ein Name beansprucht höchstens ein Cluster (bester Score gewinnt).
- Namensschlüssel = **Vorname, kleingeschrieben**: derselbe Mensch ist als „Sascha" und „Sascha Wolf"
enrolled (ebenso Kim/Kim Nebendahl, Fabienne/Fabienne Nachtsheim) — zwei Einträge dürfen nicht zu einem
cannot-link zwischen seinen eigenen Clustern führen.
- Gegenprobe ohne Regression: #79 → 4 (vorher 3), #70 → 4, #68 → 2, #74 → 20 (unverändert mit und ohne
Voiceprints). Auf #79 wird Sascha mit cos 0,861 erkannt, auf #74 fünfzehn Personen (Kai 0,656 … Niklas 0,966).
**Nachwirkung im server:** ein Name, der aus einem Marker mit `conf ≠ high` stammt, verliert gegen einen
widersprechenden Stimmabdruck (`identify #id: Stimmabdruck "X" schlägt unsicheren Marker "Y"`). In #79
hatte der Marker bei 6,6 s Saschas Cluster „Markus" getauft.
## Abschnittsweiser sherpa-Lauf (seit 2026-08-07) — der RAM-Bedarf wuchs quadratisch
**Symptom:** Aufnahme #99 (53:56) endete mit „Transkription fehlgeschlagen“, die GUI zeigte
„failed to fetch“. In Wahrheit war die Transkription längst fertig (8666 Wörter, RTF 0,394);
gestorben ist der sherpa-Lauf danach:
```
Out of memory: Killed process 3107072 (sherpa-onnx-off) total-vm:8387852kB, anon-rss:5463416kB
callrec.service: A process of this unit has been killed by the OOM killer.
callrec.service: Failed with result 'oom-kill'.
```
**Gemessen** (gleiche Aufnahme, Anfangsstücke verschiedener Länge, A76 auf 1,608 GHz, `VmHWM`):
| Audio | Spitzen-RSS | Turns | Laufzeit |
|---|---|---|---|
| 10 min | 349 MB | 130 | 130 s |
| 20 min | 1389 MB | 242 | 300 s |
| 30 min | 2643 MB | 361 | 459 s |
| 54 min | **5,4 GB → OOM** | — | Abbruch nach ~14 min |
Doppelte Länge = **vierfacher** Speicher: sherpa hält das ganze Gespräch plus seinen Clustering-Zustand
im RAM (O(n²) über die Turns). Auf 8 GB ohne echten Swap (nur zram) ist damit bei ~45 min Schluss.
**Fix:** v2 wirft sherpas **Labels** ohnehin weg und braucht nur die **Turn-Grenzen** — also läuft der
Lauf jetzt in Abschnitten von 600 s (`DIAR_CHUNK_SEC`, `0` schaltet ab) und die Grenzen werden mit
Zeit-Offset zusammengesetzt. Der Speicher bleibt damit auf dem Niveau eines Abschnitts, **unabhängig
von der Gesprächslänge** — und weil die Kosten quadratisch waren, ist es zusätzlich schneller
(6 × ~150 s statt eines Laufs, der nach 14 min noch nicht fertig war).
```mermaid
flowchart LR
WAV["WAV 54 min"] --> CUT["PCM-Slicing in 600-s-Abschnitte<br/>je ±5 s Kontext<br/>(pyannote schaut über 10-s-Fenster)"]
CUT --> S1["sherpa Abschnitt 1<br/>0–600 s"]
CUT --> S2["sherpa Abschnitt 2<br/>600–1200 s"]
CUT --> SN["… Abschnitt n"]
S1 --> CLIP["nur Turns im EIGENEN Bereich behalten,<br/>auf ihn beschneiden, + t0 verschieben"]
S2 --> CLIP
SN --> CLIP
CLIP --> ALL["Turn-Grenzen der ganzen Aufnahme<br/>(#99: 726 Turns)"]
ALL --> V2["weiter wie Pipeline v2<br/>(Fenster → Embeddings → AHC)"]
```
- Kontext von ±5 s pro Abschnitt, damit die Naht die Grenzerkennung nicht verwischt; die Turns des
Kontexts gehören dem Nachbar-Abschnitt und werden verworfen.
- **Preis:** ein Turn, der genau auf einer Naht liegt, wird in zwei geteilt. Das Re-Clustering vergibt
beiden Hälften ohnehin denselben Sprecher — bei 600 s Abschnittslänge sind das ≤ 5 Nähte pro Stunde.
- Der **Legacy-Pfad** (`DIAR_MODE=legacy`) läuft weiter unzerteilt: er braucht sherpas eigenes,
globales Clustering und lässt sich nicht abschnittsweise rechnen.
- **Zweite Sicherung:** `OOMPolicy=continue` (Drop-in `system/etc/systemd/system/callrec.service.d/oom.conf`).
systemd stoppt eine Unit per Default komplett, sobald *ein* Kindprozess OOM-getötet wird — genau das
riss die App mit (daher „failed to fetch“). Diarisierung und Analyse sind im Code bereits nicht-fatal,
also überlebt die App jetzt einen OOM-getöteten Helfer und behält Transkript + Aufnahme.
**Verifiziert** an #99 (Re-Diarisierung 2026-08-07): 6 Abschnitte, Spitzen-RSS **363 MB** statt 5,4 GB,
726 Turns → 1932 Fenster → **8 Sprecher**, 6 Live-Marker verankert, Diarisierung 13,5 min, keine
OOM-Meldung.
## 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 (`timeoutForSec()` = Dauer × 2,5, min. 20 min — bei
abschnittsweisem Lauf pro Abschnitt). (`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.