feat: komplette Codebase vom Board ins Repo gespiegelt
app/ = /opt/callrec (Node-Backend, Vite-Frontend, Python-Inferenz, Audio-Skripte, rkllm_server.cpp), system/ = systemd-Units, WirePlumber/PipeWire-Drop-ins, sudoers. Modelle, Binaries, Aufnahmen und node_modules bleiben draussen (.gitignore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+59
@@ -0,0 +1,59 @@
|
||||
// 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';
|
||||
const EMB = process.env.DIAR_EMB || '/opt/sherpa/nemo_titanet_small.onnx';
|
||||
export const THRESHOLD = +(process.env.SPK_THRESHOLD || 0.5);
|
||||
|
||||
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.
|
||||
export function embed(wavPath) {
|
||||
chain = chain.then(async () => {
|
||||
await start();
|
||||
return await new Promise((res, rej) => { pending = { resolve: res, reject: rej }; proc.stdin.write(wavPath + '\n'); });
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
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, present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; }
|
||||
Reference in New Issue
Block a user