# 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,
ABER nur wenn die Cluster akustisch passen
(cos ≥ 0,6 × gelernte Schwelle) —
sonst Marker verwerfen (falsch getippter Zeitpunkt)
cannot-link: verschiedene Namen verschmelzen NIE"] MK -- nein --> VP CON --> VP{"Bekannte Stimmabdrücke?"} VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen
(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zur
nächstbesten PERSON, eine Person höchstens 1 Cluster)
→ zusätzliches cannot-link
sichert vor allem den Box-Besitzer"] VP -- nein --> B VPC --> 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: learnVoice(name) je benanntem Sprecher
(neue Probe der PERSON hinter dem Namen) ``` - **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, eine **Person** beansprucht höchstens ein Cluster (bester Score gewinnt). - Schlüssel ist seit 2026-08-07 die **Person** (`diar.identityKey` über `person_names`), nicht mehr der kleingeschriebene Vorname. Der Vornamens-Trick sollte „Sascha" und „Sascha Wolf" zusammenhalten, setzte dabei aber auch „Markus Ufer" und „Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen). Siehe „Eine Person, mehrere Namen" unten. - Ebenfalls seit 2026-08-07: Score und Abstand rechnen **pro Person über deren besten Abdruck**. Vorher lief beides über einzelne Abdrücke — zwei Abdrücke desselben Menschen (0,928) rissen die Abstandsprüfung zuverlässig, die Zwangsbedingung war also faktisch abgeschaltet. - 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
je ±5 s Kontext
(pyannote schaut über 10-s-Fenster)"] CUT --> S1["sherpa Abschnitt 1
0–600 s"] CUT --> S2["sherpa Abschnitt 2
600–1200 s"] CUT --> SN["… Abschnitt n"] S1 --> CLIP["nur Turns im EIGENEN Bereich behalten,
auf ihn beschneiden, + t0 verschieben"] S2 --> CLIP SN --> CLIP CLIP --> ALL["Turn-Grenzen der ganzen Aufnahme
(#99: 726 Turns)"] ALL --> V2["weiter wie Pipeline v2
(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. ## Eine Person, mehrere Namen (seit 2026-08-07) Bis dahin war der **Name** die Identität. Das trägt nicht: derselbe Mensch wird in einer Aufnahme als „Sascha" und in der nächsten als „Sascha Wolf" benannt, und schon existierten zwei Einträge derselben Stimme. Auf Aufnahme #101 (34 s, **zwei** Menschen) hieß das Ergebnis: `Michael`, `Sascha`, `Sprecher 3`, `Sascha Wolf` — vier Sprecher, einer davon doppelt. **Messung des Altbestands** (42 Abdrücke, `lab/vpcos.mjs`, Kosinus innerhalb gleicher Vornamen): | Paar | cos | Befund | |---|---|---| | `Jens` ~ `Jens Kuppler` | **1,000** | exakte Dublette | | `Sascha` ~ `Sascha Wolf` | 0,928 | dieselbe Stimme, zwei Einträge | | `Kim` ~ `Kim Nebendahl` | 0,899 | dieselbe Stimme | | `Fabienne` ~ `Fabienne Nachtsheim` | 0,785 | dieselbe Stimme | | `Markus` ~ `Markus Witsch` | 0,429 | verschiedene Menschen | | `Fabio` ~ `Fabio Haase` | 0,261 | passt nicht zusammen | | `Johannes` ~ `Johannes Jeckel` | 0,115 | passt nicht zusammen | | `Michael Pastler` ~ `Michael Wagner` | −0,011 | klar verschiedene Menschen | Daraus folgen zwei Dinge. Erstens: **automatisch nach Vornamen gruppieren wäre falsch** — genau das tat der alte `normName`-Trick in `diar.js`, und er setzte damit „Markus Ufer" und „Markus Witsch" gleich. Zweitens: ein Teil des Bestands war **kontaminiert** (`Johannes` lag mit 0,912 an *Sascha Wolf*, aber mit 0,115 an *Johannes Jeckel*), also aus einem falsch verschmolzenen Cluster gelernt — dasselbe Muster wie bei Merle am 2026-07-29. Der Bestand wurde deshalb geleert (Dump: `backup/voiceprints-2026-08-07.sql`) und wird neu gelernt. **Modell jetzt:** ```mermaid erDiagram persons ||--|{ person_names : "heißt auch" persons ||--o{ voiceprints : "klingt wie" persons { int id string display_name } person_names { string name int person_id } voiceprints { int id int person_id string vec string model } ``` - `person_names.name` ist Primärschlüssel → ein Name gehört zu **genau einer** Person. Die Kollation `utf8mb4_general_ci` macht das absichtlich case-insensitiv („sascha" = „Sascha"). - **Mehrere Proben pro Person** statt eines laufenden Mittels (`AIME_MAX_PRINTS`, Standard 8, älteste fallen raus). Proben aus verschiedenen Sessions und Headsets zu mitteln verwischt gerade das, was die Stimme ausmacht; bewertet wird eine Person mit ihrem **besten** Abdruck. - **Abstand zwischen Personen, nicht zwischen Abdrücken** (`spk.bestMatch`, `diar.clusterWindows`). Vorher rissen zwei Abdrücke desselben Menschen (0,928) die 0,05-Abstandsprüfung — die Zwangsbedingung war damit faktisch abgeschaltet. - Identität überall gleich: `diar.identityKey(name, persons)` bildet Marker-Namen UND Abdrücke auf `p` ab, `db.personNameMap()` liefert die Karte. Ohne Personen-Eintrag zählt der **volle** Name (nicht mehr nur der Vorname). - GUI „Stimmen" zeigt Personen mit ihren Namen als Chips: Namen hinzufügen/entfernen, zwei Personen zusammenführen, Proben verwerfen. Vergibt man einen Namen, der schon einer anderen Person gehört, werden beide zusammengeführt — dann ist genau das gemeint. **Verifiziert an #101:** „Michael" und „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name an dieselbe Person gehängt → Re-Diarisierung benennt nur noch `{"speaker_00":"Michael","speaker_01":"Sascha"}` statt vorher drei Namen für zwei Menschen, Log: `Cluster 1 = bekannte Stimme "Sascha" (cos 0,917)`. ⚠️ **Was das NICHT löst:** die Cluster-Anzahl. #101 bleibt bei 4 Clustern für 2 Menschen (17 Roh-Cluster → 13 schwache gefaltet → 4), die beiden unbenannten heißen weiter „Sprecher 1/2". Das ist die Über-Segmentierung kurzer Clips, diagnostiziert am 2026-07-31 (Mittelwert-Zentrierung erzeugt bei wenigen Fenstern einen negativen Sockel von −1/(n−1)) — ein eigener, noch offener Punkt. ## 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():
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 --> PERS[("persons + person_names
ein Mensch, mehrere Namen")] PERS --> VP[("voiceprints-Tabelle
mehrere Proben PRO PERSON
(max. 8, älteste fallen raus)")] API --> NAMES[("recordings.speaker_names
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 **als neue Probe der Person** gespeichert (`db.learnVoice`) → zukünftige Aufnahmen identifizieren die Person automatisch. Bis 2026-08-07 wurde stattdessen EIN Zentroid pro Name laufend gemittelt; das verwischt gerade die Merkmale, wenn die Proben aus verschiedenen Sessions und Headsets kommen. - ⚠️ **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.