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:
@@ -0,0 +1,156 @@
|
||||
// Lab: offline clustering experiments on window embeddings from lab-embed.mjs.
|
||||
// Pipeline under test (mirrors the planned diar.js v2):
|
||||
// L2 → subtract recording mean → L2
|
||||
// stage A: average-linkage AHC at a FINE threshold (deliberate over-clustering)
|
||||
// fold: clusters with < minTotal s of turn time → nearest strong centroid
|
||||
// stage B: continue average-linkage merging of the strong clusters until
|
||||
// thrFinal (or until k clusters remain)
|
||||
// Usage: node cluster-lab.mjs <emb.json> [--expect N] [--fine 0.35] [--final a,b,c]
|
||||
// [--k N] [--minTotal 15] [--center 0|1] [--quiet]
|
||||
import fs from 'node:fs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const file = args[0];
|
||||
const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };
|
||||
const has = k => args.includes('--' + k);
|
||||
const EXPECT = +(opt('expect', 0));
|
||||
const CENTER = opt('center', '1') === '1';
|
||||
const FINE = +(opt('fine', 0.35));
|
||||
const MIN_TOTAL = +(opt('minTotal', 15));
|
||||
const KFIX = +(opt('k', 0));
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const turns = data.turns;
|
||||
const wins = data.wins.filter(w => w.v);
|
||||
const f = x => (x === undefined || x === null || Number.isNaN(x) ? 'NaN' : x.toFixed(3));
|
||||
|
||||
const norm = v => { let s = 0; for (const x of v) s += x * x; s = Math.sqrt(s) + 1e-9; return v.map(x => x / s); };
|
||||
let X = wins.map(w => norm(w.v));
|
||||
if (CENTER) {
|
||||
const dim = X[0].length, mu = new Array(dim).fill(0);
|
||||
for (const v of X) for (let i = 0; i < dim; i++) mu[i] += v[i] / X.length;
|
||||
X = X.map(v => norm(v.map((x, i) => x - mu[i])));
|
||||
}
|
||||
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
||||
const n = X.length;
|
||||
const S = new Float32Array(n * n);
|
||||
for (let i = 0; i < n; i++) { S[i * n + i] = 1; for (let j = i + 1; j < n; j++) { const s = dot(X[i], X[j]); S[i * n + j] = s; S[j * n + i] = s; } }
|
||||
|
||||
console.log(`# ${data.model.split('/').pop()} dim=${data.dim} windows=${n}/${data.wins.length} turns=${turns.length} center=${CENTER}`);
|
||||
|
||||
// ---- separability diagnostics ---------------------------------------------
|
||||
const byTurn = new Map();
|
||||
wins.forEach((w, i) => { (byTurn.get(w.ti) || byTurn.set(w.ti, []).get(w.ti)).push(i); });
|
||||
const pos = [];
|
||||
for (const idxs of byTurn.values())
|
||||
for (let a = 0; a < idxs.length; a++) for (let b = a + 1; b < idxs.length; b++)
|
||||
if (Math.abs(wins[idxs[a]].s - wins[idxs[b]].s) >= 3) pos.push(S[idxs[a] * n + idxs[b]]);
|
||||
const rnd = [];
|
||||
for (let k = 0; k < 40000; k++) {
|
||||
const i = (k * 7919) % n, j = (k * 104729 + 13) % n;
|
||||
if (i !== j && wins[i].ti !== wins[j].ti) rnd.push(S[i * n + j]);
|
||||
}
|
||||
const stat = a => {
|
||||
const m = a.reduce((x, y) => x + y, 0) / a.length;
|
||||
const sd = Math.sqrt(a.reduce((x, y) => x + (y - m) ** 2, 0) / a.length);
|
||||
const s = [...a].sort((x, y) => x - y);
|
||||
return { n: a.length, mean: m, sd, p05: s[(s.length * 0.05) | 0], p50: s[(s.length * 0.5) | 0], p95: s[(s.length * 0.95) | 0] };
|
||||
};
|
||||
const sp = stat(pos), sr = stat(rnd);
|
||||
console.log(`# same-turn mean=${f(sp.mean)} sd=${f(sp.sd)} p05=${f(sp.p05)} cross-turn mean=${f(sr.mean)} sd=${f(sr.sd)} p95=${f(sr.p95)} d'=${f((sp.mean - sr.mean) / Math.sqrt((sp.sd ** 2 + sr.sd ** 2) / 2))}`);
|
||||
|
||||
// ---- average-linkage AHC, optionally continuing from given groups -----------
|
||||
function ahc(threshold, kStop = 0, initGroups = null) {
|
||||
const groups = initGroups ? initGroups.map(g => [...g]) : X.map((_, i) => [i]);
|
||||
const m = groups.length;
|
||||
const A = new Float32Array(m * m);
|
||||
for (let i = 0; i < m; i++) {
|
||||
A[i * m + i] = 1;
|
||||
for (let j = i + 1; j < m; j++) {
|
||||
let s = 0;
|
||||
for (const a of groups[i]) for (const b of groups[j]) s += S[a * n + b];
|
||||
s /= groups[i].length * groups[j].length;
|
||||
A[i * m + j] = s; A[j * m + i] = s;
|
||||
}
|
||||
}
|
||||
const cnt = new Int32Array(m); groups.forEach((g, i) => cnt[i] = g.length);
|
||||
const alive = new Uint8Array(m).fill(1);
|
||||
const bestJ = new Int32Array(m).fill(-1), bestS = new Float32Array(m).fill(-Infinity);
|
||||
const rescan = i => {
|
||||
let bj = -1, bs = -Infinity;
|
||||
for (let j = 0; j < m; j++) { if (j === i || !alive[j]) continue; const s = A[i * m + j]; if (s > bs) { bs = s; bj = j; } }
|
||||
bestJ[i] = bj; bestS[i] = bs;
|
||||
};
|
||||
for (let i = 0; i < m; i++) rescan(i);
|
||||
let k = m;
|
||||
while (k > 1) {
|
||||
let a = -1, bs = -Infinity;
|
||||
for (let i = 0; i < m; i++) if (alive[i] && bestS[i] > bs) { bs = bestS[i]; a = i; }
|
||||
if (a < 0) break;
|
||||
const b = bestJ[a]; if (b < 0) break;
|
||||
if (kStop > 0 ? k <= kStop : bs < threshold) break;
|
||||
const ca = cnt[a], cb = cnt[b];
|
||||
for (let c = 0; c < m; c++) {
|
||||
if (!alive[c] || c === a || c === b) continue;
|
||||
const v = (A[a * m + c] * ca + A[b * m + c] * cb) / (ca + cb);
|
||||
A[a * m + c] = v; A[c * m + a] = v;
|
||||
}
|
||||
cnt[a] = ca + cb; alive[b] = 0; k--;
|
||||
groups[a] = groups[a].concat(groups[b]); groups[b] = [];
|
||||
rescan(a);
|
||||
for (let i = 0; i < m; i++) if (alive[i] && (bestJ[i] === b || bestJ[i] === a)) rescan(i);
|
||||
}
|
||||
return groups.filter(g => g.length);
|
||||
}
|
||||
|
||||
// ---- turn labels + per-cluster talk time -----------------------------------
|
||||
function turnLabels(groups) {
|
||||
const of = new Int32Array(n).fill(-1);
|
||||
groups.forEach((g, gi) => g.forEach(i => of[i] = gi));
|
||||
const acc = new Map();
|
||||
wins.forEach((w, i) => {
|
||||
const mm = acc.get(w.ti) || acc.set(w.ti, new Map()).get(w.ti);
|
||||
mm.set(of[i], (mm.get(of[i]) || 0) + (w.e - w.s));
|
||||
});
|
||||
const lab = new Array(turns.length).fill(-1);
|
||||
for (const [ti, mm] of acc) { let bl = -1, bd = -1; for (const [l, d] of mm) if (d > bd) { bd = d; bl = l; } lab[ti] = bl; }
|
||||
const talk = new Map();
|
||||
turns.forEach((t, ti) => { if (lab[ti] >= 0) talk.set(lab[ti], (talk.get(lab[ti]) || 0) + (t.end - t.start)); });
|
||||
return { lab, talk };
|
||||
}
|
||||
const centroid = g => norm(g.reduce((acc, i) => (X[i].forEach((x, d) => acc[d] += x / g.length), acc), new Array(X[0].length).fill(0)));
|
||||
|
||||
// ---- the full pipeline -----------------------------------------------------
|
||||
function run(tag, { fine = FINE, final = null, k = 0, minTotal = MIN_TOTAL }) {
|
||||
let groups = ahc(fine);
|
||||
const { talk } = turnLabels(groups);
|
||||
// fold weak clusters into the nearest strong centroid
|
||||
const strongIdx = groups.map((g, i) => i).filter(i => (talk.get(i) || 0) >= minTotal);
|
||||
let folded = 0;
|
||||
if (strongIdx.length && strongIdx.length < groups.length) {
|
||||
const cents = new Map(strongIdx.map(i => [i, centroid(groups[i])]));
|
||||
const merged = new Map(strongIdx.map(i => [i, [...groups[i]]]));
|
||||
groups.forEach((g, i) => {
|
||||
if (merged.has(i)) return;
|
||||
const c = centroid(g);
|
||||
let best = strongIdx[0], bs = -Infinity;
|
||||
for (const s of strongIdx) { const v = dot(c, cents.get(s)); if (v > bs) { bs = v; best = s; } }
|
||||
merged.get(best).push(...g); folded++;
|
||||
});
|
||||
groups = [...merged.values()];
|
||||
}
|
||||
const afterFold = groups.length;
|
||||
if (final !== null || k > 0) groups = ahc(final === null ? 0 : final, k, groups);
|
||||
const { lab, talk: t2 } = turnLabels(groups);
|
||||
const order = [...t2.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const switches = lab.filter((l, i) => i > 0 && l >= 0 && lab[i - 1] >= 0 && l !== lab[i - 1]).length;
|
||||
const first = new Map(), last = new Map();
|
||||
turns.forEach((t, ti) => { const l = lab[ti]; if (l < 0) return; if (!first.has(l)) first.set(l, t.start); last.set(l, t.end); });
|
||||
console.log(`${tag}: fine=${f(fine)}${final !== null ? ` final=${f(final)}` : ''}${k ? ` k=${k}` : ''} → afterFine=${afterFold + folded} folded=${folded} speakers=${groups.length} switches=${switches}${EXPECT ? ` expect=${EXPECT}` : ''}`);
|
||||
if (!has('quiet')) console.log(' ' + order.map(([l, d]) => `${d.toFixed(0)}s[${(first.get(l) / 60).toFixed(1)}-${(last.get(l) / 60).toFixed(1)}]`).join(' '));
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
if (KFIX) run('pinned', { k: KFIX });
|
||||
for (const t of (opt('final', '') ? opt('final').split(',').map(Number) : [0.10, 0.15, 0.20, 0.25, 0.30]))
|
||||
run('auto', { final: t });
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Lab: dump RAW sherpa diarization turns for a recording, at maximum temporal
|
||||
# granularity (min-duration-off=0 -> no adjacent-same-label merging, small
|
||||
# cluster threshold -> fine labels). We only reuse the TURN BOUNDARIES; the
|
||||
# labels get thrown away and re-clustered offline by cluster-lab.js.
|
||||
# Usage: lab-dump-segs.sh <wav> <out.txt> [cluster-threshold]
|
||||
DIR=/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu
|
||||
BIN=$DIR/bin/sherpa-onnx-offline-speaker-diarization
|
||||
SEG=/opt/sherpa/sherpa-onnx-pyannote-segmentation-3-0/model.onnx
|
||||
EMB=${EMB:-/opt/sherpa/nemo_titanet_small.onnx}
|
||||
WAV="$1"; OUT="$2"; THR="${3:-0.2}"
|
||||
/opt/callrec/aime-cpucap.sh 1608000
|
||||
export LD_LIBRARY_PATH=$DIR/lib
|
||||
echo "start $(date -Is)" > "$OUT.log"
|
||||
taskset -c 4-7 "$BIN" \
|
||||
--segmentation.pyannote-model=$SEG --segmentation.num-threads=4 \
|
||||
--embedding.model=$EMB --embedding.num-threads=4 \
|
||||
--clustering.cluster-threshold=$THR \
|
||||
--min-duration-on=0.2 --min-duration-off=0.0 \
|
||||
"$WAV" > "$OUT" 2>> "$OUT.log"
|
||||
echo "end $(date -Is) rc=$?" >> "$OUT.log"
|
||||
/opt/callrec/aime-cpucap.sh 2256000
|
||||
grep -c 'speaker_' "$OUT" >> "$OUT.log"
|
||||
@@ -0,0 +1,117 @@
|
||||
// Lab: window-level speaker embeddings for a recording.
|
||||
// Reads sherpa's raw turn boundaries, slices the 16k mono wav into fixed windows
|
||||
// (no ffmpeg: direct PCM slicing), embeds each window with a given sherpa
|
||||
// embedding model via the spk_embed helper, and dumps JSON for offline
|
||||
// clustering experiments (cluster-lab.mjs).
|
||||
//
|
||||
// Usage: node lab-embed.mjs <segs.txt> <wav> <model.onnx> <out.json> [winSec] [hopSec]
|
||||
import fs from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
import os from 'node:os';
|
||||
|
||||
const [segsPath, wavPath, model, outPath, winSecArg, hopSecArg] = process.argv.slice(2);
|
||||
const WIN = +(winSecArg || 3.0), HOP = +(hopSecArg || 1.5);
|
||||
const MIN_WIN = 0.6; // shortest slice we bother embedding
|
||||
const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu';
|
||||
const TMP = fs.mkdtempSync(`${os.tmpdir()}/labemb_`);
|
||||
|
||||
// ---- wav (PCM s16 mono) ----------------------------------------------------
|
||||
function loadWav(p) {
|
||||
const b = fs.readFileSync(p);
|
||||
if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('not RIFF/WAVE');
|
||||
let off = 12, fmt = null, data = null;
|
||||
while (off + 8 <= b.length) {
|
||||
const id = b.toString('ascii', off, off + 4), size = b.readUInt32LE(off + 4);
|
||||
if (id === 'fmt ') fmt = { ch: b.readUInt16LE(off + 10), rate: b.readUInt32LE(off + 12), bits: b.readUInt16LE(off + 22) };
|
||||
if (id === 'data') { data = { start: off + 8, size: Math.min(size, b.length - off - 8) }; break; }
|
||||
off += 8 + size + (size & 1);
|
||||
}
|
||||
if (!fmt || !data) throw new Error('wav chunks missing');
|
||||
if (fmt.bits !== 16) throw new Error('need s16, got ' + fmt.bits);
|
||||
return { buf: b, fmt, data };
|
||||
}
|
||||
function writeSlice(w, startSec, endSec, path) {
|
||||
const bytesPerFrame = 2 * w.fmt.ch;
|
||||
const s = Math.max(0, Math.floor(startSec * w.fmt.rate) * bytesPerFrame);
|
||||
const e = Math.min(w.data.size, Math.ceil(endSec * w.fmt.rate) * bytesPerFrame);
|
||||
const n = Math.max(0, e - s);
|
||||
const h = Buffer.alloc(44);
|
||||
h.write('RIFF', 0); h.writeUInt32LE(36 + n, 4); h.write('WAVE', 8);
|
||||
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20);
|
||||
h.writeUInt16LE(w.fmt.ch, 22); h.writeUInt32LE(w.fmt.rate, 24);
|
||||
h.writeUInt32LE(w.fmt.rate * bytesPerFrame, 28); h.writeUInt16LE(bytesPerFrame, 32); h.writeUInt16LE(16, 34);
|
||||
h.write('data', 36); h.writeUInt32LE(n, 40);
|
||||
fs.writeFileSync(path, Buffer.concat([h, w.buf.subarray(w.data.start + s, w.data.start + e)]));
|
||||
return n / (w.fmt.rate * bytesPerFrame);
|
||||
}
|
||||
|
||||
// ---- spk_embed pipe --------------------------------------------------------
|
||||
function embedder(modelPath) {
|
||||
const proc = spawn('taskset', ['-c', '4-7', '/opt/callrec/spk_embed', modelPath],
|
||||
{ env: { ...process.env, LD_LIBRARY_PATH: `${DIR}/lib` }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let pending = null;
|
||||
const rl = readline.createInterface({ input: proc.stdout });
|
||||
const ready = new Promise((res, rej) => {
|
||||
const to = setTimeout(() => rej(new Error('READY timeout')), 180000);
|
||||
rl.on('line', line => {
|
||||
if (line.startsWith('READY')) { clearTimeout(to); return res(); }
|
||||
if (!pending) return;
|
||||
const p = pending; pending = null;
|
||||
if (line.startsWith('OK ')) p.res(line.slice(3).split(',').map(Number));
|
||||
else p.rej(new Error(line));
|
||||
});
|
||||
});
|
||||
proc.stderr.on('data', d => process.stderr.write('[spk] ' + d));
|
||||
let chain = ready;
|
||||
return {
|
||||
embed(path) {
|
||||
chain = chain.then(() => new Promise((res, rej) => { pending = { res, rej }; proc.stdin.write(path + '\n'); }));
|
||||
return chain;
|
||||
},
|
||||
close() { try { proc.kill(); } catch {} },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- main ------------------------------------------------------------------
|
||||
const turns = [];
|
||||
for (const line of fs.readFileSync(segsPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/([\d.]+)\s*--\s*([\d.]+)\s*(speaker_\d+)/);
|
||||
if (m) turns.push({ start: +m[1], end: +m[2], raw: m[3] });
|
||||
}
|
||||
turns.sort((a, b) => a.start - b.start);
|
||||
console.log(`turns=${turns.length} rawLabels=${new Set(turns.map(t => t.raw)).size} speech=${turns.reduce((a, t) => a + t.end - t.start, 0).toFixed(0)}s`);
|
||||
|
||||
const wav = loadWav(wavPath);
|
||||
console.log('wav', wav.fmt, (wav.data.size / (wav.fmt.rate * 2 * wav.fmt.ch)).toFixed(1) + 's');
|
||||
|
||||
const wins = [];
|
||||
turns.forEach((t, ti) => {
|
||||
const dur = t.end - t.start;
|
||||
if (dur < MIN_WIN) return;
|
||||
if (dur <= WIN) { wins.push({ ti, start: t.start, end: t.end }); return; }
|
||||
for (let s = t.start; s + MIN_WIN <= t.end; s += HOP) wins.push({ ti, start: s, end: Math.min(s + WIN, t.end) });
|
||||
});
|
||||
console.log(`windows=${wins.length}`);
|
||||
|
||||
const emb = embedder(model);
|
||||
const t0 = Date.now();
|
||||
let done = 0;
|
||||
for (const w of wins) {
|
||||
const p = `${TMP}/w.wav`;
|
||||
writeSlice(wav, w.start, w.end, p);
|
||||
try { w.vec = await emb.embed(p); } catch (e) { w.err = e.message; }
|
||||
if (++done % 100 === 0) {
|
||||
const rate = done / ((Date.now() - t0) / 1000);
|
||||
console.log(` ${done}/${wins.length} ${rate.toFixed(1)}/s eta ${((wins.length - done) / rate).toFixed(0)}s`);
|
||||
}
|
||||
}
|
||||
emb.close();
|
||||
const ok = wins.filter(w => w.vec);
|
||||
fs.writeFileSync(outPath, JSON.stringify({
|
||||
model, wav: wavPath, win: WIN, hop: HOP,
|
||||
dim: ok.length ? ok[0].vec.length : 0,
|
||||
turns, wins: wins.map(w => ({ ti: w.ti, s: +w.start.toFixed(3), e: +w.end.toFixed(3), v: w.vec ? w.vec.map(x => +x.toFixed(5)) : null })),
|
||||
}));
|
||||
console.log(`done ok=${ok.length}/${wins.length} dim=${ok.length ? ok[0].vec.length : 0} in ${((Date.now() - t0) / 1000).toFixed(0)}s -> ${outPath}`);
|
||||
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Replay the REAL production clustering (diar.js clusterWindows) on cached
|
||||
// window embeddings, so thresholds can be tuned without re-running sherpa or the
|
||||
// embedding pass. Usage:
|
||||
// [DIAR_COS_FINE=..] [DIAR_COS_FINAL=..] node lab-eval.mjs <emb.json> [expect] [numSpeakers] [markers.json]
|
||||
import fs from 'node:fs';
|
||||
import { clusterWindows } from './diar-v2.js';
|
||||
|
||||
const [file, expect, numSp, markerFile] = process.argv.slice(2);
|
||||
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const ok = d.wins.filter(w => w.v).map(w => ({ ti: w.ti, start: w.s, end: w.e, vec: w.v }));
|
||||
const markers = markerFile ? JSON.parse(fs.readFileSync(markerFile, 'utf8')) : [];
|
||||
const segs = clusterWindows(d.turns, ok, +(numSp || 0), markers);
|
||||
|
||||
const talk = new Map(), first = new Map(), last = new Map();
|
||||
for (const s of segs) {
|
||||
talk.set(s.speaker, (talk.get(s.speaker) || 0) + (s.end - s.start));
|
||||
if (!first.has(s.speaker)) first.set(s.speaker, s.start);
|
||||
last.set(s.speaker, s.end);
|
||||
}
|
||||
const order = [...talk.entries()].sort((a, b) => b[1] - a[1]);
|
||||
console.log(`RESULT ${d.model.split('/').pop()} fine=${process.env.DIAR_COS_FINE || 0.35} final=${process.env.DIAR_COS_FINAL || 0.30}` +
|
||||
` → speakers=${order.length}${expect ? ` (expect ${expect})` : ''} segs=${segs.length}`);
|
||||
console.log(' ' + order.map(([sp, t]) => `${sp.replace('speaker_', '')}:${t.toFixed(0)}s[${(first.get(sp) / 60).toFixed(1)}-${(last.get(sp) / 60).toFixed(1)}]`).join(' '));
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Embed the same window set with every candidate speaker-embedding model, so the
|
||||
# clustering lab can compare their speaker separability on OUR audio.
|
||||
cd /opt/callrec/lab
|
||||
/opt/callrec/aime-cpucap.sh 1800000
|
||||
for m in nemo_titanet_small wespeaker_en_voxceleb_resnet34_LM 3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced nemo_en_titanet_large 3dspeaker_speech_eres2netv2_sv_zh-cn_16k-common; do
|
||||
out=emb74.$m.json
|
||||
[ -f "$out" ] && { echo "skip $m"; continue; }
|
||||
echo "=== $m $(date -Is)"
|
||||
taskset -c 4-7 node lab-embed.mjs segs74.txt /opt/callrec/recordings/rec_1784280581035.wav \
|
||||
/opt/sherpa/$m.onnx "$out" 3.0 1.5 2>&1 | tail -12
|
||||
done
|
||||
/opt/callrec/aime-cpucap.sh 2256000
|
||||
echo "ALLDONE $(date -Is)"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Regression set: the two recordings whose true speaker count we know
|
||||
# (#70 = 4 speakers, #71 = 2). Same pipeline as #74 so the chosen thresholds can
|
||||
# be checked against small calls, not just the 23-person weekly.
|
||||
cd /opt/callrec/lab
|
||||
/opt/callrec/aime-cpucap.sh 1416000
|
||||
run() { # $1 = tag, $2 = wav
|
||||
[ -f segs$1.txt ] || ./lab-dump-segs.sh "$2" segs$1.txt 0.2
|
||||
for m in nemo_titanet_small 3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced nemo_en_titanet_large; do
|
||||
out=emb$1.$m.json
|
||||
[ -f "$out" ] && continue
|
||||
echo "=== $1 $m $(date -Is)"
|
||||
taskset -c 4-7 node lab-embed.mjs segs$1.txt "$2" /opt/sherpa/$m.onnx "$out" 3.0 1.5 2>&1 | tail -3
|
||||
done
|
||||
}
|
||||
run 70 /opt/callrec/recordings/rec_1784192426082.wav
|
||||
run 71 /opt/callrec/recordings/rec_1784205293914.wav
|
||||
/opt/callrec/aime-cpucap.sh 2256000
|
||||
echo "ALLDONE2 $(date -Is)"
|
||||
@@ -0,0 +1,52 @@
|
||||
// Which merge threshold does a recording itself suggest? Fits a 2-component
|
||||
// Gaussian mixture (EM) to the pairwise window-similarity histogram — the
|
||||
// "different speaker" and "same speaker" modes — and takes the point where the
|
||||
// two weighted densities cross. Compares that with the fixed thresholds.
|
||||
import fs from 'node:fs';
|
||||
const f = x => x.toFixed(3);
|
||||
const norm = v => { let s = 0; for (const x of v) s += x * x; s = Math.sqrt(s) + 1e-9; return v.map(x => x / s); };
|
||||
const dot = (a, b) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; };
|
||||
|
||||
for (const file of process.argv.slice(2)) {
|
||||
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const wins = d.wins.filter(w => w.v);
|
||||
let X = wins.map(w => norm(w.v));
|
||||
const dim = X[0].length, mu = new Array(dim).fill(0);
|
||||
for (const v of X) for (let i = 0; i < dim; i++) mu[i] += v[i] / X.length;
|
||||
X = X.map(v => norm(v.map((x, i) => x - mu[i])));
|
||||
const n = X.length;
|
||||
// sample pairs (deterministic stride, no RNG)
|
||||
const vals = [];
|
||||
for (let k = 1; k < 400000 && vals.length < 60000; k++) {
|
||||
const i = (k * 7919) % n, j = (k * 104729 + 13) % n;
|
||||
if (i !== j) vals.push(dot(X[i], X[j]));
|
||||
}
|
||||
// EM for 2 Gaussians
|
||||
let m1 = -0.1, m2 = 0.5, s1 = 0.15, s2 = 0.15, w1 = 0.8;
|
||||
const N = (x, m, s) => Math.exp(-((x - m) ** 2) / (2 * s * s)) / (s * Math.sqrt(2 * Math.PI));
|
||||
for (let it = 0; it < 100; it++) {
|
||||
let sw1 = 0, sm1 = 0, sm2 = 0, sv1 = 0, sv2 = 0, sw2 = 0;
|
||||
for (const x of vals) {
|
||||
const a = w1 * N(x, m1, s1), b = (1 - w1) * N(x, m2, s2);
|
||||
const r = a / (a + b + 1e-300);
|
||||
sw1 += r; sw2 += 1 - r; sm1 += r * x; sm2 += (1 - r) * x;
|
||||
}
|
||||
const nm1 = sm1 / sw1, nm2 = sm2 / sw2;
|
||||
for (const x of vals) {
|
||||
const a = w1 * N(x, m1, s1), b = (1 - w1) * N(x, m2, s2);
|
||||
const r = a / (a + b + 1e-300);
|
||||
sv1 += r * (x - nm1) ** 2; sv2 += (1 - r) * (x - nm2) ** 2;
|
||||
}
|
||||
m1 = nm1; m2 = nm2;
|
||||
s1 = Math.max(0.02, Math.sqrt(sv1 / sw1)); s2 = Math.max(0.02, Math.sqrt(sv2 / sw2));
|
||||
w1 = sw1 / vals.length;
|
||||
}
|
||||
// crossing point between the means: w1*N1(x) = w2*N2(x)
|
||||
let lo = m1, hi = m2, t = (lo + hi) / 2;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
t = (lo + hi) / 2;
|
||||
const g = w1 * N(t, m1, s1) - (1 - w1) * N(t, m2, s2);
|
||||
if (g > 0) lo = t; else hi = t;
|
||||
}
|
||||
console.log(`${file.split('.').slice(0, 2).join('.')}: diff N(${f(m1)},${f(s1)}) w=${f(w1)} | same N(${f(m2)},${f(s2)}) w=${f(1 - w1)} → crossing=${f(t)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user