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)
This commit is contained in:
Claude
2026-07-27 17:30:43 +02:00
parent 48fa6b4f9e
commit 61531fc50a
5 changed files with 56 additions and 9 deletions
+3 -2
View File
@@ -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) {
+18 -3
View File
@@ -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;
}
+11 -2
View File
@@ -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) {
+16 -2
View File
@@ -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.
+8
View File
@@ -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.