Diarization v2: Fenster-Clustering mit gelernter Schwelle + Live-Namensmarker

#74 (22 Teilnehmer) ergab automatisch 44 Sprecher, dieselbe Stimme mehrfach.
Ursache gemessen: ein Zentroid pro sherpa-Cluster aus max. 3 Schnitten, fester
Kosinus auf rohen Embeddings (Kreuz-Turn-Kosinus ~0,4 = keine Trennschaerfe).

v2: sherpa liefert nur Turn-Grenzen, Identitaet aus 3-s-Fenster-Embeddings,
Mittelwert-Zentrierung, Average-Linkage-AHC, Weak-Fold, Schwelle pro Aufnahme
per 2-Gauss-EM gelernt. Validiert: #74 44->20, #70 ->4, #68 ->2.

Live-Namensmarker: Namen waehrend der Aufnahme tippen; Zeitstempel benennt
spaeter den Cluster und dient als must-/cannot-link-Zwangsbedingung.
This commit is contained in:
Claude
2026-07-27 17:00:45 +02:00
parent 432c4b14dd
commit 48fa6b4f9e
17 changed files with 1357 additions and 67 deletions
+42
View File
@@ -3,6 +3,48 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-07-27 — Diarization v2 (Fenster-Clustering, gelernte Schwelle) + Live-Namensmarker
- **Problem:** #74 (33-min-Weekly, 22 Teilnehmer + ein Kind) automatisch neu analysiert → **44 Sprecher**,
dieselbe Stimme mehrfach als verschiedene Personen. Ursache (gemessen, nicht geraten): v1 bildete
EIN Zentroid pro sherpa-Cluster aus max. 3 Schnitten und verglich mit fester Kosinus-Schwelle auf
ROHEN Embeddings — unzentrierte TitaNet-Vektoren einer Aufnahme haben aber eine große gemeinsame
Komponente (Kreuz-Turn-Kosinus ≈ 0,4), damit ist keine Schwelle trennscharf. 51 Roh-Cluster → 44.
- **Fix — `diar.js` v2 (`.bak-v2clust`):** sherpa liefert nur noch Turn-GRENZEN
(`--min-duration-off=0`, threshold 0,2 — Labels verworfen). Danach: 3-s-Fenster (Hop 1,5 s, PCM-Slicing
ohne ffmpeg) → TitaNet-Embedding pro Fenster → **L2 / Aufnahme-Mittelwert abziehen / L2**
(Kanal-Kompensation: Kreuz-Kosinus ≈ 0, d' 3,5) → **Average-Linkage-AHC** Stufe A (fein 0,45) →
Weak-Fold (< min(20 s, 2 % Sprechzeit)) → Stufe B bis zur **pro Aufnahme gelernten Schwelle**
(2-Gauß-EM auf den Paar-Ähnlichkeiten, `mergeThreshold()`, Clamp 0,05–0,35) oder bis zur gepinnten
Anzahl. Turn-Label = dauergewichtete Mehrheit seiner Fenster. `DIAR_MODE=legacy` = alter Pfad.
- **Warum gelernte Schwelle:** fest 0,30 → #74 21 Sprecher (gut), #70 aber 9 (statt 4); fest 0,15 → #70 4,
#74 kollabiert auf 14. Die Verteilung ist pro Aufnahme verschieden (gleicher Sprecher 0,21–0,30 je nach
Audioqualität). Gelernt: #74 0,202 · #70 0,070 · #68 0,050.
- **Validierung (Labor + Produktion):** #74 44 → **20** · #70 (4 bekannte Sprecher) → **4** ·
#68 (2 Personen) → **2**. Modell-Benchmark auf #74 (1181 Fenster): TitaNet-large d' 3,75/197 s,
CAM++ 3,72/82 s, **TitaNet-small 3,54/57 s**, wespeaker-VoxCeleb-resnet34 2,43/185 s → durch die
Pipeline geschickt alle innerhalb eines Sprechers → TitaNet-small bleibt (schnell + alle gelernten
Voiceprints bleiben gültig). Voiceprints tragen jetzt die Spalte `model`.
- **NEU: Live-Namensmarker** (Wunsch des Nutzers) — während der Aufnahme den Namen der gerade sprechenden
Person eintippen (Kopfzeilen-Feld, Enter). Tabelle `markers` (t_ms, name, speaker, conf);
`POST/GET/DELETE /api/record/marker(s)` live, `POST /api/recordings/:id/markers` zum Nachtragen an der
Abspielposition, `POST /api/recordings/:id/apply-markers` zum Neu-Zuordnen ohne sherpa-Lauf.
Zuordnung `diar.resolveMarkers()`: asymmetrisches Fenster 25 s zurück / 8 s vorwärts (man tippt NACH
dem Hören), Turn der den Marker überdeckt gewinnt, `conf` high/mid/low. Zusätzlich **Supervision der
Trennung**: gleicher Name = must-link, verschiedene Namen = cannot-link in Stufe B; ein Name auf mehreren
Clustern → Cluster werden zusammengeführt. Benannte Sprecher werden automatisch als Voiceprint gelernt.
- **Re-Diarization löscht jetzt `speaker_names`** (`server.js`): die Cluster werden neu durchnummeriert,
alte Namen zeigten danach auf fremde Stimmen. Marker + Auto-ID füllen sofort neu.
- **Dateien:** `diar.js` (v2 + resolveMarkers), `server.js` (applyMarkers, Marker-Endpunkte,
Voiceprint-Modellbindung), `db.js` (`markers`-Tabelle, `voiceprints.model`), `recorder.js`
(Marker-Puffer während der Aufnahme), `spk.js` (`modelId()`, Modell-Doku), `web/src/main.js` +
`style.css` (Kopfzeilen-Eingabe, Marker-Chips, Marker-Liste im Detail). Backups `.bak-v2clust`,
`.bak-markers`. Bundle `index-q6bHhDgg.js`. Labor-Skripte (Segment-Dump, Fenster-Embeddings, Cluster-Grid, Schwellen-Fit) liegen auf dem Board
unter `/opt/callrec/lab/` und im Repo unter `app/lab/` — damit ist das Tuning reproduzierbar.
- **GOTCHA (Betrieb):** zwei schwere Jobs parallel (Labor-sherpa + App-Analyse, deren `finally` den Takt
auf 2,25 GHz zurückstellt) haben die A76 auf **101,9 °C** getrieben — lüfterloses Board, das ist
Shutdown-Nähe. Schwere Jobs immer serialisieren, Cap 1,4–1,6 GHz.
## 2026-07-17 — Round-Robin-Meetings: Sektions-Pfad statt Whole-Pass ab 5 Sprechern (Recency-Bias-Fix)
- **Problem:** #74 (33-min-Weekly, 13 Sprecher, 31k Zeichen) lief knapp unter `WHOLE_MAX_CHARS` als