Files
Aime/app/spk.js
T
Claude 61531fc50a 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)
2026-07-27 17:30:43 +02:00

81 lines
4.2 KiB
JavaScript

// Speaker voiceprints: talks to the persistent spk_embed process (sherpa-onnx
// NeMo TitaNet embedding on CPU). embed(wav) → 192-dim vector; cosine match a
// diarization cluster against enrolled voiceprints → a name. All offline.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import readline from 'node:readline';
const BIN = '/opt/callrec/spk_embed';
const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu';
// Identity/clustering model. Benchmarked 2026-07-27 on our own conference audio
// (#74, 1181 mean-centred windows, same-turn vs cross-turn cosine):
// 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
// Fed through the full v2 pipeline the three good models ended up within one
// speaker of each other (21/22/21 on a 23-person weekly), so TitaNet-small stays
// the default: it is the fastest AND keeps every already-learned voiceprint
// valid. SPK_MODEL switches models — prints are then stored per model (see
// db.upsertVoiceprint), because cosine across models is meaningless.
const EMB = process.env.SPK_MODEL || '/opt/sherpa/nemo_titanet_small.onnx';
export const THRESHOLD = +(process.env.SPK_THRESHOLD || 0.5);
// Voiceprints are only comparable within the SAME embedding model — stored with
// every print so a model swap can't silently match against stale vectors.
export function modelId() { return EMB.split('/').pop().replace(/\.onnx$/, ''); }
let proc = null, ready = null, rl = null, pending = null, chain = Promise.resolve();
function start() {
if (proc) return ready;
if (!fs.existsSync(EMB)) return Promise.reject(new Error('Embedding-Modell fehlt: ' + EMB));
proc = spawn('taskset', ['-c', '4-7', BIN, EMB],
{ env: { ...process.env, LD_LIBRARY_PATH: `${DIR}/lib` }, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stderr.on('data', d => process.stderr.write('[spk] ' + d));
ready = new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error('spk READY timeout')), 60000);
rl = readline.createInterface({ input: proc.stdout });
rl.on('line', line => {
if (line.startsWith('READY')) { clearTimeout(to); res(); return; }
if (!pending) return;
const p = pending; pending = null;
if (line.startsWith('OK ')) p.resolve(line.slice(3).split(',').map(Number));
else p.reject(new Error(line.replace(/^ERR\s*/, '') || 'spk error'));
});
});
const cleanup = () => { proc = null; ready = null; rl = null; if (pending) { pending.reject(new Error('spk beendet')); pending = null; } };
proc.on('exit', cleanup); proc.on('error', cleanup);
return ready;
}
// 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) {
const run = chain.then(async () => {
await start();
return await new Promise((res, rej) => { pending = { resolve: res, reject: rej }; proc.stdin.write(wavPath + '\n'); });
});
chain = run.then(() => {}, () => {});
return run;
}
export function cosine(a, b) {
let d = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return d / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}
// Best enrolled voiceprint for an embedding, or null if below threshold.
export function bestMatch(emb, voiceprints, threshold = THRESHOLD) {
let best = null, bs = -1;
for (const v of voiceprints) { const s = cosine(emb, v.vec); if (s > bs) { bs = s; best = v; } }
return (best && bs >= threshold) ? { id: best.id, name: best.name, score: bs } : null;
}
export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; }