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:
@@ -15,16 +15,21 @@ sequenceDiagram
|
||||
participant LLM as llm.js (Gemma, NPU)
|
||||
participant DB as MariaDB
|
||||
|
||||
GUI->>SRV: (während der Aufnahme) POST /api/record/marker {name}
|
||||
SRV->>REC: addMarker() → Namensmarker mit Offset im Puffer
|
||||
GUI->>SRV: Stop
|
||||
SRV->>REC: stopAndSave()
|
||||
REC->>DB: WAV + MP3 + Zeile (status=transcribing)
|
||||
SRV->>DB: addMarkers() → Tabelle markers
|
||||
Note over REC: kehrt nach ~1,5 s zurück — GUI frei
|
||||
REC->>REC: A76-Takt kappen (1,6 GHz)
|
||||
REC->>WH: transcribeWav() [BatchedInferencePipeline, batch=8]
|
||||
WH-->>REC: Wörter + Zeitstempel
|
||||
REC->>DI: diarize() [Timeout = Dauer × 2,5, min. 20 min]
|
||||
REC->>DI: diarize(…, {markers}) [Timeout = Dauer × 2,5, min. 20 min]
|
||||
Note over DI: v2: Fenster-Embeddings + gelernte Schwelle,<br/>Marker als must-/cannot-link (Kap. 04)
|
||||
DI-->>REC: Sprecher-Segmente
|
||||
REC->>DB: setTranscript (status=done)
|
||||
SRV->>SRV: applyMarkers(): Cluster benennen + Stimmen lernen
|
||||
REC->>REC: finally: Takt zurück
|
||||
SRV->>LLM: analyzeRecording() (fire-and-forget, analysis_status=pending)
|
||||
LLM->>DB: Titel/Summary/Teilnehmer/Todos (analysis_status=done, analysis_ms)
|
||||
|
||||
+91
-32
@@ -3,50 +3,109 @@
|
||||
**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
|
||||
## 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"] --> SHERPA["sherpa-onnx Clustering<br/>liefert VIELE Roh-Cluster<br/>(z. B. konstant 27 bei einem 3-Personen-Call — deterministisch)"]
|
||||
SHERPA --> EMB["Jedes Cluster neu einbetten<br/>TitaNet-Embedding (spk.embed)"]
|
||||
EMB --> SORT["Cluster nach Länge sortieren<br/>(längste zuerst = stabile Reihenfolge)"]
|
||||
SORT --> ANCHOR{"Cluster ≥ 3 s?<br/>(DIAR_MIN_SPEAKER_SEC)"}
|
||||
ANCHOR -- "ja (Anchor)" --> JOIN{"Kosinus ≥ 0,5 zu<br/>bestehendem Sprecher?<br/>(DIAR_MERGE_THRESHOLD)"}
|
||||
JOIN -- ja --> MERGE["dem Sprecher zuordnen"]
|
||||
JOIN -- nein --> NEW["neuen Sprecher eröffnen"]
|
||||
ANCHOR -- "nein (kurz/Streu)" --> NEAREST["IMMER zum nächstliegenden Anchor<br/>(darf NIE neuen Sprecher eröffnen)"]
|
||||
MERGE --> FOLD
|
||||
NEW --> FOLD
|
||||
NEAREST --> FOLD{"Stufe 3: finale Sprecher mit<br/>< 20 s Gesamtredezeit?<br/>(DIAR_MIN_TOTAL_SEC)"}
|
||||
FOLD -- "ja (Phantom)" --> WEAK["in akustisch nächsten<br/>starken Sprecher falten"]
|
||||
FOLD -- nein --> OUT["finale Sprecher (z. B. 50 → 4)"]
|
||||
WEAK --> OUT
|
||||
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)"]
|
||||
```
|
||||
|
||||
## Warum Greedy-Anchor-Merge (Geschichte der Fixes)
|
||||
### Gemessene Ergebnisse (2026-07-27)
|
||||
|
||||
1. **Schwellwert-Tuning nutzlos:** Sweep auf 2-Personen-Clip: Threshold 0,7 → 12 Sprecher … 0,95 → 8. Nie 2.
|
||||
2. **Union-Find (1. Konsolidierung) instabil:** paarweises Mergen bildet transitive Ketten (A~B~C zieht alles
|
||||
zusammen). Ein Grenzfall-Link um Kosinus ≈ 0,5 kippte je nach FP-Rauschen (Multi-Thread-TitaNet unter
|
||||
CPU-Last) → derselbe Call ergab 2 / 3 / 7 Sprecher in drei Läufen.
|
||||
3. **Greedy-Anchor-Merge (`diar.js consolidateSpeakers`):** nur lange Cluster (verlässliche
|
||||
Embeddings) dürfen Sprecher eröffnen, Abarbeitung längste-zuerst (ordnungsstabil), kurze Cluster können nur
|
||||
zugeordnet werden → Rauschen kann nichts mehr fragmentieren. Verifiziert: 5/5 Läufe konstant 27 → 3.
|
||||
4. **Stufe 3: Weak-Fold (2026-07-16, `.bak-weakfold`):** ein einzelner ≥ 3-s-Streu-Cluster kann trotzdem
|
||||
einen Anchor eröffnen, wenn sein (kurzes, unzuverlässiges) Embedding unter Kosinus 0,5 zu allen echten
|
||||
Sprechern landet — Aufnahme #70 (26 min, 4 Personen) bekam so **3 Phantom-Sprecher mit 2,5–4,8 s**
|
||||
Gesamtredezeit neben echten mit 53–736 s. Fix: jeder FINALE Sprecher unter `DIAR_MIN_TOTAL_SEC` (20 s,
|
||||
gemessen auf Segment-Zeiten, die ~20–40 % über Wort-Summen liegen — 10 s ließ einen 9,2-s-Phantom durch)
|
||||
wird in den akustisch nächsten starken Sprecher gefaltet. Verifiziert: #70 konstant 50 → **exakt 4**.
|
||||
Tradeoff: wer im ganzen Gespräch < 20 s redet, wird einem anderen Sprecher zugeschlagen.
|
||||
| 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.
|
||||
- Diarization-Timeout skaliert mit Dauer (`diarTimeoutMs()` = Dauer × 2,5, min. 20 min) — der feste
|
||||
10-min-Timeout hat 46-min-Aufnahmen abgebrochen. (`parse-options.cc:373`-Ausgabe ist harmloses argv-Echo.)
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user