From 61531fc50a910974f9c217b2062fd3b01cb1355a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:30:43 +0200 Subject: [PATCH] Fix: Voice-Auto-Naming tot (TitaNet bricht bei langen Schnitten ab + vergiftete Promise-Kette) identifySpeakers() embettete den laengsten Lauf ungekuerzt; TitaNet-ONNX bricht ab 180 s ab (broadcast 12288 by 17998) und riss den spk_embed-Helfer mit. Da spk.js die Aufrufe ueber eine Promise-Kette serialisiert, schlugen danach ALLE weiteren Sprecher mit 'spk beendet' fehl. - ID_CUT_SEC=25s in identifySpeakers - Kette in spk.js und tdChain in recorder.js failure-isoliert - pro Name gewinnt nur der beste Kosinus (Fabio war auf 2 Clustern) --- app/recorder.js | 5 +++-- app/server.js | 21 ++++++++++++++++++--- app/spk.js | 13 +++++++++++-- docs/04-diarization.md | 18 ++++++++++++++++-- docs/08-changelog.md | 8 ++++++++ 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/recorder.js b/app/recorder.js index c96fced..8afaa6a 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -147,11 +147,12 @@ function startTd() { } // One request at a time (single warm process), chained like spk.embed. function runDaemonTranscribe(wavPath) { - tdChain = tdChain.then(async () => { + const run = tdChain.then(async () => { await startTd(); return await new Promise((res, rej) => { tdPending = { resolve: res, reject: rej }; tdProc.stdin.write(wavPath + '\n'); }); }); - return tdChain; + tdChain = run.then(() => {}, () => {}); // a failed call must not poison the queue + return run; } // Wav duration from file size (16 kHz mono s16 = 32000 B/s, 44 B header). function wavDurationSec(wavPath) { diff --git a/app/server.js b/app/server.js index 763517c..35f9095 100644 --- a/app/server.js +++ b/app/server.js @@ -65,18 +65,33 @@ async function identifySpeakers(recData) { const len = cur.end - cur.start; if (!best[cur.speaker] || len > best[cur.speaker].len) best[cur.speaker] = { start: cur.start, end: cur.end, len }; } - const map = {}; + // Cut at most ID_CUT_SEC: onnxruntime ABORTS the embedding helper on very long + // inputs (reproduced: 180 s -> "Non-zero status code returned while running + // Where"; 20/60 s fine). On #74 the longest run of a moderator was minutes + // long, which killed the helper on the first speaker and — through the then + // poisoned promise chain in spk.js — every speaker after it ("spk beendet"), + // i.e. voice auto-naming was dead for the whole recording. + const ID_CUT_SEC = 25; + const hits = []; for (const sp of speakers) { const r = best[sp]; if (!r) continue; const seg = `/tmp/idseg_${recData.id}_${sp}.wav`; + const from = r.start / 1000; + const to = Math.min(r.end / 1000, from + ID_CUT_SEC); try { - await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(r.start / 1000), '-to', String(r.end / 1000), '-ar', '16000', '-ac', '1', seg]); + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(from), '-to', String(to), '-ar', '16000', '-ac', '1', seg]); const emb = await spk.embed(seg); const m = spk.bestMatch(emb, vps); - if (m) map[sp] = m.name; + if (m) hits.push({ speaker: sp, name: m.name, score: m.score }); } catch (e) { console.error('identify', sp, e.message); } finally { try { fs.unlinkSync(seg); } catch {} } } + // One enrolled person can only BE one cluster: keep the best-scoring match per + // name (#74 handed "Fabio" to two clusters at once). + const bestByName = {}; + for (const h of hits) if (!bestByName[h.name] || h.score > bestByName[h.name].score) bestByName[h.name] = h; + const map = {}; + for (const h of Object.values(bestByName)) map[h.speaker] = h.name; return map; } diff --git a/app/spk.js b/app/spk.js index bb4c0d0..9c77fc9 100644 --- a/app/spk.js +++ b/app/spk.js @@ -47,12 +47,21 @@ function start() { } // Extract the voiceprint for a 16 kHz mono wav file. +// Calls are serialized through `chain`, but a FAILED call must not poison it: +// .then() on a rejected promise skips the callback, so ONE crash used to make +// every later embed() fail instantly with the same "spk beendet" — that is why +// speaker auto-naming was dead for a whole recording after the first bad cut +// (root-caused 2026-07-27). The queue is continued from a swallowed copy. +// NOTE for callers: keep cuts short. onnxruntime ABORTS the helper process on +// very long inputs — reproduced at 180 s ("Non-zero status code returned while +// running Where"), fine at 20/60 s. export function embed(wavPath) { - chain = chain.then(async () => { + const run = chain.then(async () => { await start(); return await new Promise((res, rej) => { pending = { resolve: res, reject: rej }; proc.stdin.write(wavPath + '\n'); }); }); - return chain; + chain = run.then(() => {}, () => {}); + return run; } export function cosine(a, b) { diff --git a/docs/04-diarization.md b/docs/04-diarization.md index d9d8ed5..7091e08 100644 --- a/docs/04-diarization.md +++ b/docs/04-diarization.md @@ -119,8 +119,22 @@ flowchart LR VP --> AUTOID ``` -- Beim Lernen wird der längste zusammenhängende Sprech-Abschnitt (≥ 1,5 s) des Sprechers als TitaNet-Embedding - in den Zentroid gefaltet (`db.upsertVoiceprint`) → zukünftige Aufnahmen identifizieren die Person automatisch. +- 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. diff --git a/docs/08-changelog.md b/docs/08-changelog.md index a626931..aa7d322 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -41,6 +41,14 @@ Was, Warum, betroffene Dateien/Backups). `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. +- **Nachtrag gleicher Tag — „Voice-Auto-Naming tot" (offen seit 2026-07-16) GELÖST:** TitaNet-ONNX bricht bei + langen Eingaben ab (reproduziert 180 s → `Non-zero status code … broadcast … 12288 by 17998`, 20/60 s ok). + `identifySpeakers()` embettete den LÄNGSTEN Lauf ungekürzt (auf #74 minutenlang) → Helfer stirbt beim + ersten Sprecher; da `spk.js` über eine Promise-Kette serialisiert und `.then()` auf einer rejected Promise + den Callback überspringt, schlugen ALLE weiteren Sprecher sofort mit „spk beendet" fehl. Fixes: + `ID_CUT_SEC = 25 s` in `identifySpeakers()`, Kette in `spk.js` + `tdChain` in `recorder.js` failure-isoliert, + und pro Name gewinnt nur der beste Kosinus (#74 hatte „Fabio" auf zwei Clustern). Verifiziert per + Testskript: 180-s-Schnitt scheitert, direkt danach liefert ein 20-s-Schnitt wieder ein Embedding. - **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.