Files
Aime/docs/04-diarization.md
T
Sascha 2228c57522 feat(persons): eine Person, mehrere Namen und mehrere Stimmproben
Die Identitaet war der NAME. Derselbe Mensch, einmal "Sascha" und einmal
"Sascha Wolf" benannt, existierte zweimal und konnte zwei Cluster benennen
-- Aufnahme #101 (34 s, zwei Menschen) zeigte Michael/Sascha/Sprecher 3/
Sascha Wolf. Der Vornamens-Trick in diar.js hat das ueberdeckt, setzte aber
auch "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241).

Neu: persons + person_names (Name = PK, gehoert genau einer Person),
voiceprints.person_id. Mehrere Proben pro Person (max. 8) statt eines
laufenden Mittels; bewertet wird die beste Probe. Score UND Abstand rechnen
zwischen Personen -- vorher rissen zwei Abdruecke desselben Menschen (0,928)
die Abstandspruefung, womit die Zwangsbedingung faktisch aus war.
diar.identityKey bildet Marker-Namen und Abdruecke auf p<person_id> ab.

GUI "Stimmen" zeigt Personen mit Namens-Chips: Namen hinzufuegen/entfernen,
Personen zusammenfuehren, Proben verwerfen.

Altbestand geleert (Dump in backup/), weil lab/vpcos.mjs neben echten
Dubletten (Jens ~ Jens Kuppler = 1,000) auch kontaminierte Abdruecke zeigte:
Johannes lag mit 0,912 an Sascha Wolf, aber 0,115 an Johannes Jeckel.

Verifiziert an #101: Sascha Wolf als zweiter Name derselben Person ->
Re-Diarisierung benennt nur noch Michael + Sascha statt drei Namen fuer zwei
Menschen. GUI mit Playwright geprueft, keine JS-Fehler.
2026-08-07 16:18:33 +02:00

20 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 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.

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 zur<br/>nächstbesten PERSON, eine Person 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.

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: learnVoice(name) je benanntem Sprecher<br/>(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).

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.

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:

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<person_id> 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)

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 --> PERS[("persons + person_names<br/>ein Mensch, mehrere Namen")]
    PERS --> VP[("voiceprints-Tabelle<br/>mehrere Proben PRO PERSON<br/>(max. 8, älteste fallen raus)")]
    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 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.