Die Identitaet war der NAME. Derselbe Mensch, einmal "Sascha" und einmal "Sascha Wolf" benannt, existierte zweimal und konnte zwei Cluster benennen -- Aufnahme #101 (34 s, zwei Menschen) zeigte Michael/Sascha/Sprecher 3/ Sascha Wolf. Der Vornamens-Trick in diar.js hat das ueberdeckt, setzte aber auch "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241). Neu: persons + person_names (Name = PK, gehoert genau einer Person), voiceprints.person_id. Mehrere Proben pro Person (max. 8) statt eines laufenden Mittels; bewertet wird die beste Probe. Score UND Abstand rechnen zwischen Personen -- vorher rissen zwei Abdruecke desselben Menschen (0,928) die Abstandspruefung, womit die Zwangsbedingung faktisch aus war. diar.identityKey bildet Marker-Namen und Abdruecke auf p<person_id> ab. GUI "Stimmen" zeigt Personen mit Namens-Chips: Namen hinzufuegen/entfernen, Personen zusammenfuehren, Proben verwerfen. Altbestand geleert (Dump in backup/), weil lab/vpcos.mjs neben echten Dubletten (Jens ~ Jens Kuppler = 1,000) auch kontaminierte Abdruecke zeigte: Johannes lag mit 0,912 an Sascha Wolf, aber 0,115 an Johannes Jeckel. Verifiziert an #101: Sascha Wolf als zweiter Name derselben Person -> Re-Diarisierung benennt nur noch Michael + Sascha statt drei Namen fuer zwei Menschen. GUI mit Playwright geprueft, keine JS-Fehler.
824 lines
42 KiB
JavaScript
824 lines
42 KiB
JavaScript
// Offline acoustic speaker diarization via sherpa-onnx (arm64 CPU, no pytorch).
|
||
// Splits a 16 kHz mono wav into speaker segments; AUTO speaker count (cluster
|
||
// threshold) because a conference phone ("Raumspinne") can put an unknown number
|
||
// of people on the single BT channel. Pinned to the A76 cores.
|
||
import { execFile } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
import fs from 'node:fs';
|
||
import * as spk from './spk.js';
|
||
const pexec = promisify(execFile);
|
||
|
||
// Auto-mode over-segmentation fix: sherpa's clustering splits ONE speaker into
|
||
// many clusters (e.g. 8-13 on a 2-person call, 61 on a 46-min one), and raising
|
||
// the cluster threshold barely helps. So after sherpa, we re-embed each cluster
|
||
// with the SAME TitaNet model used for voiceprint identity and MERGE clusters
|
||
// whose centroids are cosine-similar — i.e. the same voice that got split. This
|
||
// keeps the count dynamic (speakers can join/leave) without a fixed guess.
|
||
const MERGE_THRESHOLD = +(process.env.DIAR_MERGE_THRESHOLD || 0.5);
|
||
const REP_SEGMENTS = 3; // longest segments per cluster averaged into its centroid
|
||
const MIN_REP_SEC = 0.4; // skip ultra-short cuts for embedding when a longer one exists
|
||
// Stage 2: sub-second stray clusters (overlap crosstalk, noise) survive the
|
||
// centroid merge because they're too short to embed reliably. Any speaker with
|
||
// less than this much total speech is folded into the temporally-nearest real
|
||
// speaker. A genuine join/leave participant speaks well over this.
|
||
const MIN_SPEAKER_SEC = +(process.env.DIAR_MIN_SPEAKER_SEC || 3);
|
||
// Stage 3: a single >=MIN_SPEAKER_SEC stray cluster can still OPEN an anchor
|
||
// when its (short, unreliable) embedding lands below MERGE_THRESHOLD to every
|
||
// real speaker (#70: 3 phantom speakers with 2.5-4.8s total talk next to real
|
||
// ones with 53-736s). Total talk time over the whole call separates real
|
||
// participants from such noise: any FINAL speaker below this is folded into
|
||
// the acoustically nearest strong speaker. NOTE: measured on SEGMENT times
|
||
// (incl. intra-segment pauses), which run ~20-40% above summed word times —
|
||
// 10s let a 9.2s-word-time phantom through on #70, hence 20.
|
||
const MIN_TOTAL_SEC = +(process.env.DIAR_MIN_TOTAL_SEC || 20);
|
||
|
||
// Diarization on the capped A76 cores runs at ~1x wall-clock (a 46-min wav ≈
|
||
// 40 min). The old fixed 10-min timeout killed every recording over ~11 min and
|
||
// surfaced as an opaque "Command failed" (only sherpa's argv echo in stderr).
|
||
// Scale the timeout with the audio length: ~2.5x its duration, floor 20 min.
|
||
function wavSec(wavPath) {
|
||
try { return Math.max(0, (fs.statSync(wavPath).size - 44) / 32000); } catch { return 0; } // 16kHz mono s16
|
||
}
|
||
function diarTimeoutMs(wavPath) { return timeoutForSec(wavSec(wavPath)); }
|
||
function timeoutForSec(sec) { return Math.max(20 * 60 * 1000, Math.ceil(sec * 2.5) * 1000); }
|
||
|
||
const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu';
|
||
const BIN = `${DIR}/bin/sherpa-onnx-offline-speaker-diarization`;
|
||
const SEG = process.env.DIAR_SEG || '/opt/sherpa/sherpa-onnx-pyannote-segmentation-3-0/model.onnx';
|
||
const EMB = process.env.DIAR_EMB || '/opt/sherpa/nemo_titanet_small.onnx';
|
||
// 0.7 (not 0.5): a lower threshold over-segments — a single (esp. child) voice
|
||
// split into several clusters. 0.7 merges more → fewer spurious speakers while
|
||
// still auto-detecting the count (needed for a conference phone / "Raumspinne").
|
||
const THRESHOLD = process.env.DIAR_THRESHOLD || '0.7';
|
||
|
||
function env() { return { ...process.env, LD_LIBRARY_PATH: `${DIR}/lib` }; }
|
||
|
||
// --- v2 pipeline (default) --------------------------------------------------
|
||
// The old path let sherpa cluster its own window embeddings and then tried to
|
||
// repair the result by merging whole clusters. On a 23-person weekly (#74) that
|
||
// produced 44 speakers with the same voice spread over several of them: one
|
||
// centroid per (already wrong) cluster from at most 3 cuts is too coarse to
|
||
// separate 20+ voices, and a single fixed cosine threshold on RAW embeddings
|
||
// can't work at all — untouched TitaNet vectors of one channel share a large
|
||
// common component (mean cross-turn cosine ≈ 0.4).
|
||
//
|
||
// v2 therefore ignores sherpa's labels and only keeps its (pyannote) turn
|
||
// BOUNDARIES, then does the identity part itself:
|
||
// 1. slice every turn into 3 s windows (1.5 s hop),
|
||
// 2. embed each window,
|
||
// 3. subtract the per-recording mean and L2-normalise (channel/room
|
||
// compensation — this alone moves cross-turn cosine to ≈ 0 and lifts the
|
||
// same/different separation to d' ≈ 3.5),
|
||
// 4. average-linkage agglomerative clustering (all pairs decide a merge, not
|
||
// one greedy first-match link as before) at a FINE threshold,
|
||
// 5. fold clusters with almost no talk time into the nearest real speaker,
|
||
// 6. keep merging the remaining (strong) clusters up to the FINAL threshold,
|
||
// or until a user-pinned speaker count is reached,
|
||
// 7. duration-weighted majority label per turn.
|
||
// Live name markers (see resolveMarkers) additionally constrain step 4/6:
|
||
// windows around markers with the SAME name must land in one cluster, windows
|
||
// around markers with DIFFERENT names must never be merged.
|
||
// DIAR_MODE=legacy restores the old behaviour.
|
||
const MODE = process.env.DIAR_MODE || 'v2';
|
||
const WIN_SEC = +(process.env.DIAR_WIN_SEC || 3.0);
|
||
const HOP_SEC = +(process.env.DIAR_HOP_SEC || 1.5);
|
||
const MIN_WIN_SEC = +(process.env.DIAR_MIN_WIN_SEC || 0.6);
|
||
const MAX_WINDOWS = +(process.env.DIAR_MAX_WINDOWS || 4000);
|
||
// Merge thresholds on MEAN-CENTRED cosine (see cluster-lab measurements).
|
||
// Stage A over-clusters at a fixed high threshold (0.45 measured stable on #74).
|
||
const COS_FINE = +(process.env.DIAR_COS_FINE || 0.45);
|
||
// Stage B's threshold is derived PER RECORDING (see mergeThreshold): a fixed one
|
||
// cannot serve both a clean 23-person conference and a noisy 4-person call —
|
||
// 0.30 gave 21 speakers on #74 (~right) but 9 on #70 (truly 4), while 0.15 fixed
|
||
// #70 and collapsed #74 to 14. DIAR_COS_FINAL pins it for debugging.
|
||
const COS_FINAL_ENV = process.env.DIAR_COS_FINAL ? +process.env.DIAR_COS_FINAL : null;
|
||
const COS_FINAL_MIN = +(process.env.DIAR_COS_FINAL_MIN || 0.05);
|
||
const COS_FINAL_MAX = +(process.env.DIAR_COS_FINAL_MAX || 0.35);
|
||
// sherpa cluster threshold for the v2 pass: its labels are discarded, we only
|
||
// want fine turn boundaries (and min-duration-off=0 so adjacent turns of the
|
||
// same label are NOT glued together).
|
||
const FINE_THRESHOLD = process.env.DIAR_FINE_THRESHOLD || '0.2';
|
||
// sherpa keeps the whole recording plus its clustering state in RAM, and the
|
||
// peak grows QUADRATICALLY with the audio length — measured on #99 at 1.6 GHz:
|
||
// 10 min → 349 MB (130 turns, 130 s wall)
|
||
// 20 min → 1389 MB (242 turns, 300 s wall) = 4x RAM for 2x audio
|
||
// 30 min → 2643 MB (361 turns, 459 s wall)
|
||
// so the 54-min #99 hit 5.4 GB, the kernel OOM-killer took sherpa out and
|
||
// systemd's default OOMPolicy=stop tore the whole app down with it (GUI:
|
||
// "Transkription fehlgeschlagen" + "failed to fetch").
|
||
// v2 throws sherpa's LABELS away and keeps only the pyannote turn BOUNDARIES, so
|
||
// the pass can run on slices whose boundaries are stitched back with an offset —
|
||
// the footprint then stays at one slice's level no matter how long the call is,
|
||
// and because the cost is quadratic it is also FASTER (6x 130 s beats one
|
||
// 54-min run). Cost: a turn crossing a seam is cut in two; the re-clustering
|
||
// gives both halves the same speaker anyway. DIAR_CHUNK_SEC=0 disables chunking.
|
||
const CHUNK_SEC = process.env.DIAR_CHUNK_SEC != null ? +process.env.DIAR_CHUNK_SEC : 600;
|
||
const CHUNK_CTX_SEC = 5;
|
||
|
||
// One sherpa call over a whole wav file. `fine` = boundaries only (v2), else the
|
||
// legacy call with its own clustering (threshold or pinned count).
|
||
async function sherpaRun(wavPath, clusterArg, fine, timeoutMs) {
|
||
const args = [
|
||
'taskset', '-c', '4-7', BIN,
|
||
`--segmentation.pyannote-model=${SEG}`,
|
||
`--segmentation.num-threads=4`,
|
||
`--embedding.model=${EMB}`,
|
||
`--embedding.num-threads=4`,
|
||
clusterArg,
|
||
...(fine ? ['--min-duration-on=0.2', '--min-duration-off=0.0'] : []),
|
||
wavPath,
|
||
];
|
||
const { stdout } = await pexec(args[0], args.slice(1), { env: env(), maxBuffer: 1 << 22, timeout: timeoutMs });
|
||
const segs = [];
|
||
for (const line of stdout.split('\n')) {
|
||
const m = line.match(/([\d.]+)\s*--\s*([\d.]+)\s*(speaker_\d+)/);
|
||
if (m) segs.push({ start: +m[1], end: +m[2], speaker: m[3] });
|
||
}
|
||
segs.sort((a, b) => a.start - b.start);
|
||
return segs;
|
||
}
|
||
|
||
// Raw sherpa turns, chunked on long recordings (see CHUNK_SEC).
|
||
async function sherpaTurns(wavPath, numSpeakers, fine) {
|
||
const clusterArg = fine ? `--clustering.cluster-threshold=${FINE_THRESHOLD}`
|
||
: (numSpeakers > 0 ? `--clustering.num-clusters=${numSpeakers}` : `--clustering.cluster-threshold=${THRESHOLD}`);
|
||
const dur = wavSec(wavPath);
|
||
// The legacy pass needs ONE global clustering, so it can never be chunked.
|
||
if (!fine || !CHUNK_SEC || dur <= CHUNK_SEC * 1.25) {
|
||
return sherpaRun(wavPath, clusterArg, fine, timeoutForSec(dur));
|
||
}
|
||
const wav = loadWav(wavPath);
|
||
const tmp = `/tmp/diarchunk_${process.pid}.wav`;
|
||
const out = [];
|
||
try {
|
||
for (let t0 = 0; t0 < dur; t0 += CHUNK_SEC) {
|
||
const t1 = Math.min(dur, t0 + CHUNK_SEC);
|
||
// Context on both sides: pyannote decides on a sliding 10 s window, so a
|
||
// hard cut would blur the turns right at the seam.
|
||
const a = Math.max(0, t0 - CHUNK_CTX_SEC), b = Math.min(dur, t1 + CHUNK_CTX_SEC);
|
||
writeSlice(wav, a, b, tmp);
|
||
const segs = await sherpaRun(tmp, clusterArg, fine, timeoutForSec(b - a));
|
||
let kept = 0;
|
||
for (const s of segs) {
|
||
// Keep only what belongs to THIS chunk's own range (the context is
|
||
// already covered by its neighbour) and shift into absolute time.
|
||
const start = Math.max(t0, s.start + a), end = Math.min(t1, s.end + a);
|
||
if (end - start >= 0.1) { out.push({ start, end, speaker: s.speaker }); kept++; }
|
||
}
|
||
console.log(`diar: sherpa-Abschnitt ${Math.round(t0)}-${Math.round(t1)}s → ${kept} Turns`);
|
||
}
|
||
} finally { try { fs.unlinkSync(tmp); } catch {} }
|
||
out.sort((x, y) => x.start - y.start);
|
||
return out;
|
||
}
|
||
|
||
// Returns [{ start, end, speaker }] (seconds, speaker = "speaker_00"…).
|
||
// numSpeakers > 0 fixes the count when the user knows it, otherwise the count
|
||
// is found automatically (people can join/leave a conference call).
|
||
export async function diarize(wavPath, numSpeakers, opts = {}) {
|
||
const n = parseInt(numSpeakers, 10) > 0 ? parseInt(numSpeakers, 10) : 0;
|
||
if (MODE === 'v2') {
|
||
const turns = await sherpaTurns(wavPath, n, true);
|
||
if (turns.length) {
|
||
try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || [], opts.persons || {}); }
|
||
catch (e) { console.log('diar: v2 re-clustering failed, falling back to legacy:', e.message); }
|
||
}
|
||
}
|
||
const segs = await sherpaTurns(wavPath, n, false);
|
||
// Only auto mode over-segments; a user-pinned count is already trusted.
|
||
if (!n && segs.length) {
|
||
try { return await consolidateSpeakers(wavPath, segs); }
|
||
catch (e) { console.log('diar: consolidation failed, using raw clusters:', e.message); }
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
// --- wav slicing (16 kHz mono s16, no ffmpeg per window) --------------------
|
||
function loadWav(p) {
|
||
const b = fs.readFileSync(p);
|
||
if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('kein RIFF/WAVE: ' + p);
|
||
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 fehlen');
|
||
if (fmt.bits !== 16) throw new Error('wav ist nicht s16 (' + fmt.bits + ')');
|
||
return { buf: b, fmt, data };
|
||
}
|
||
function writeSlice(w, startSec, endSec, outPath) {
|
||
const bpf = 2 * w.fmt.ch;
|
||
const s = Math.max(0, Math.floor(startSec * w.fmt.rate) * bpf);
|
||
const e = Math.min(w.data.size, Math.ceil(endSec * w.fmt.rate) * bpf);
|
||
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 * bpf, 28); h.writeUInt16LE(bpf, 32); h.writeUInt16LE(16, 34);
|
||
h.write('data', 36); h.writeUInt32LE(n, 40);
|
||
fs.writeFileSync(outPath, Buffer.concat([h, w.buf.subarray(w.data.start + s, w.data.start + e)]));
|
||
return n / (w.fmt.rate * bpf);
|
||
}
|
||
|
||
// --- vector helpers ---------------------------------------------------------
|
||
function l2(v) { let s = 0; for (const x of v) s += x * x; s = Math.sqrt(s) + 1e-9; return v.map(x => x / s); }
|
||
function meanVec(vs) {
|
||
const d = vs[0].length, m = new Array(d).fill(0);
|
||
for (const v of vs) for (let i = 0; i < d; i++) m[i] += v[i] / vs.length;
|
||
return m;
|
||
}
|
||
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; }
|
||
|
||
// Per-recording merge threshold. The pairwise window-similarity histogram of one
|
||
// recording is bimodal: a big "different speaker" mode around 0 and a "same
|
||
// speaker" mode further right. How far right depends entirely on the audio — on
|
||
// a clean conference recording the same-speaker mode sits at ~0.30, on a noisy
|
||
// BT call at ~0.21 with much more spread. A 2-Gaussian mixture (EM) is fitted to
|
||
// a deterministic sample of the pairs and the point where the two weighted
|
||
// densities cross becomes the merge threshold: measured 0.20 for #74 (→ 20
|
||
// speakers for 23 participants) and 0.07 for #70 (→ exactly the 4 known ones),
|
||
// where no single fixed value could do both.
|
||
function mergeThreshold(X) {
|
||
const n = X.length;
|
||
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]));
|
||
}
|
||
if (vals.length < 50) return (COS_FINAL_MIN + COS_FINAL_MAX) / 2;
|
||
const N = (x, m, s) => Math.exp(-((x - m) ** 2) / (2 * s * s)) / (s * Math.sqrt(2 * Math.PI));
|
||
let m1 = -0.1, m2 = 0.5, s1 = 0.15, s2 = 0.15, w1 = 0.8;
|
||
for (let it = 0; it < 100; it++) {
|
||
let sw1 = 0, sw2 = 0, sm1 = 0, sm2 = 0, sv1 = 0, sv2 = 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;
|
||
}
|
||
if (!(m2 > m1)) return (COS_FINAL_MIN + COS_FINAL_MAX) / 2;
|
||
let lo = m1, hi = m2, t = (lo + hi) / 2;
|
||
for (let i = 0; i < 60; i++) {
|
||
t = (lo + hi) / 2;
|
||
if (w1 * N(t, m1, s1) - (1 - w1) * N(t, m2, s2) > 0) lo = t; else hi = t;
|
||
}
|
||
return Math.min(COS_FINAL_MAX, Math.max(COS_FINAL_MIN, t));
|
||
}
|
||
|
||
// Average-linkage agglomerative clustering over unit vectors X.
|
||
// Stops when the best average-linkage cosine drops below `threshold`, or when
|
||
// `kStop` clusters remain (user-pinned speaker count). Best-match caching keeps
|
||
// this ~O(n^2) for the few-thousand windows of a long recording.
|
||
// initGroups: continue from an existing clustering (arrays of X indices)
|
||
// names: per-init-group name tag; two clusters that both carry a (then
|
||
// necessarily different) name are never merged — the live markers'
|
||
// cannot-link constraint.
|
||
// Returns the surviving groups (arrays of X indices) and their name tags.
|
||
function ahcAverage(X, threshold, kStop = 0, initGroups = null, names = null) {
|
||
const groups = (initGroups || X.map((_, i) => [i])).map(g => [...g]);
|
||
const tag = groups.map((_, i) => (names && names[i]) || null);
|
||
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 += dot(X[a], X[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);
|
||
const bestS = new Float32Array(m).fill(-Infinity);
|
||
const blocked = (i, j) => tag[i] && tag[j] && tag[i] !== tag[j];
|
||
const rescan = i => {
|
||
let bj = -1, bs = -Infinity;
|
||
for (let j = 0; j < m; j++) {
|
||
if (j === i || !alive[j] || blocked(i, 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] && bestJ[i] >= 0 && bestS[i] > bs) { bs = bestS[i]; a = i; }
|
||
if (a < 0) break;
|
||
const b = bestJ[a];
|
||
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] = [];
|
||
const gained = !tag[a] && !!tag[b];
|
||
if (gained) tag[a] = tag[b];
|
||
rescan(a);
|
||
// A cluster that just gained a name blocks pairs that were allowed before,
|
||
// so every cached best-match has to be re-checked; otherwise only those
|
||
// pointing at the two merged clusters.
|
||
for (let i = 0; i < m; i++)
|
||
if (alive[i] && (gained || bestJ[i] === b || bestJ[i] === a)) rescan(i);
|
||
}
|
||
const out = [], outTags = [];
|
||
for (let i = 0; i < m; i++) if (alive[i]) { out.push(groups[i]); outTags.push(tag[i]); }
|
||
return { groups: out, tags: outTags };
|
||
}
|
||
|
||
// v2 entry: embed the windows, then cluster them.
|
||
async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) {
|
||
const ok = await embedWindows(wavPath, turns);
|
||
return clusterWindows(turns, ok, numSpeakers, markers, voiceprints, persons);
|
||
}
|
||
|
||
// Map each live marker to the turn the named person was speaking in: the turn
|
||
// covering the marker, else the last one ending shortly before it (the name is
|
||
// typed a few seconds AFTER the voice was heard), else the next one starting
|
||
// right after. Returns { name -> [turnIndex…] }.
|
||
const ANCHOR_BACK_SEC = +(process.env.MARK_PRE_MS || 25000) / 1000;
|
||
const ANCHOR_FWD_SEC = +(process.env.MARK_POST_MS || 8000) / 1000;
|
||
const ANCHOR_MIN_SEC = 1.0; // too short to identify a voice from
|
||
// Each anchor carries how it was found: `d` = 0 when the marker fell INSIDE the
|
||
// turn (certain), else the seconds of typing lag (the further back, the more
|
||
// likely the name belongs to somebody else who spoke in between). The vote
|
||
// weight below is derived from it — a covering anchor must outweigh a 20-second
|
||
// guess, otherwise one mistyped marker decides a whole cluster (#79).
|
||
function markerAnchors(turns, markers, persons) {
|
||
const byName = new Map();
|
||
const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC);
|
||
for (const mk of markers || []) {
|
||
const t = (mk.t_ms || 0) / 1000;
|
||
let pick = -1, bd = Infinity;
|
||
for (const c of usable) {
|
||
const back = t - c.end; // >0: turn ended before the marker
|
||
const fwd = c.start - t; // >0: turn starts after the marker
|
||
let d = Infinity;
|
||
if (back <= 0 && fwd <= 0) d = 0; // covers it
|
||
else if (back > 0 && back <= ANCHOR_BACK_SEC) d = back; // typing lag
|
||
else if (fwd > 0 && fwd <= ANCHOR_FWD_SEC) d = fwd * (ANCHOR_BACK_SEC / Math.max(1, ANCHOR_FWD_SEC));
|
||
if (d < bd) { bd = d; pick = c.i; }
|
||
}
|
||
if (pick < 0) continue;
|
||
const key = identityKey(mk.name, persons);
|
||
(byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd });
|
||
}
|
||
return byName;
|
||
}
|
||
// Zwangsbedingungs-Schlüssel = die IDENTITÄT hinter einem Namen, nicht der Name.
|
||
// Seit 2026-08-07 ist das die Person aus `person_names`: "Sascha" und "Sascha Wolf"
|
||
// sind derselbe Mensch, ihre Cluster dürfen also verschmelzen. Ohne Personen-
|
||
// Eintrag zählt der VOLLE Name — der alte Trick, nur den Vornamen zu nehmen, setzte
|
||
// "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen).
|
||
const identityKey = (s, persons) => {
|
||
const n = String(s || '').trim().toLowerCase();
|
||
if (!n) return '';
|
||
const pid = persons && persons[n];
|
||
return pid ? `p${pid}` : n;
|
||
};
|
||
// Anchor vote weight: 1.0 for a marker inside the turn, decaying to 0.2 at the
|
||
// far end of the look-back window.
|
||
const anchorWeight = (d) => d <= 0 ? 1 : Math.max(0.2, 1 - 0.8 * (d / ANCHOR_BACK_SEC));
|
||
// A name typed twice may legitimately point at two stage-A clusters of the SAME
|
||
// voice (that is the must-link the markers exist for) — but only if the two
|
||
// clusters actually sound alike. Below this fraction of the per-recording merge
|
||
// threshold the second anchor is treated as mistyped/mis-anchored instead of
|
||
// gluing two different people together (#79: "Merle" typed once while Sascha
|
||
// was talking merged Sascha into Merle → 3 speakers instead of 4).
|
||
const MARK_LINK_REL = +(process.env.DIAR_MARK_LINK_REL || 0.6);
|
||
// Enrolled voiceprints as a SECOND cannot-link source. Voice identification used
|
||
// to run only AFTER the clustering (identifySpeakers), so it could name what came
|
||
// out but never prevent two known people from being merged into one cluster —
|
||
// and the box owner, who is enrolled and speaks in every recording, silently
|
||
// disappeared into somebody else's cluster (#79: Sascha inside "Merle"). Matching
|
||
// happens in RAW embedding space (voiceprints are stored un-centred), so it needs
|
||
// a strict score AND a margin over the runner-up before it is allowed to
|
||
// constrain anything.
|
||
const VP_MIN = +(process.env.DIAR_VP_MIN || 0.55);
|
||
const VP_MARGIN = +(process.env.DIAR_VP_MARGIN || 0.05);
|
||
|
||
// Slice + embed every window of every turn (the only part that touches audio).
|
||
export async function embedWindows(wavPath, turns) {
|
||
const wav = loadWav(wavPath);
|
||
// Adaptive hop: keep the window count (and the O(n^2) clustering) bounded on
|
||
// very long recordings.
|
||
const winCount = (hop) => turns.reduce((a, t) => {
|
||
const d = t.end - t.start;
|
||
return a + (d < MIN_WIN_SEC ? 0 : d <= WIN_SEC ? 1 : Math.max(1, Math.ceil((d - MIN_WIN_SEC) / hop)));
|
||
}, 0);
|
||
let hop = HOP_SEC;
|
||
while (winCount(hop) > MAX_WINDOWS && hop < 60) hop *= 1.5;
|
||
|
||
const wins = [];
|
||
turns.forEach((t, ti) => {
|
||
const d = t.end - t.start;
|
||
if (d < MIN_WIN_SEC) return;
|
||
if (d <= WIN_SEC) { wins.push({ ti, start: t.start, end: t.end }); return; }
|
||
for (let s = t.start; s + MIN_WIN_SEC <= t.end; s += hop) wins.push({ ti, start: s, end: Math.min(s + WIN_SEC, t.end) });
|
||
});
|
||
if (!wins.length) throw new Error('keine Fenster für Sprecher-Embeddings');
|
||
|
||
const tmp = `/tmp/diarwin_${process.pid}.wav`;
|
||
const t0 = Date.now();
|
||
for (const w of wins) {
|
||
writeSlice(wav, w.start, w.end, tmp);
|
||
try { w.vec = await spk.embed(tmp); } catch (e) { w.err = e.message; }
|
||
}
|
||
try { fs.unlinkSync(tmp); } catch {}
|
||
const ok = wins.filter(w => Array.isArray(w.vec) && w.vec.length);
|
||
if (ok.length < 2) throw new Error('Embedding fehlgeschlagen (' + (wins[0] && wins[0].err || 'unbekannt') + ')');
|
||
console.log(`diar: ${ok.length}/${wins.length} Fenster-Embeddings (hop ${hop.toFixed(1)}s) in ${((Date.now() - t0) / 1000).toFixed(0)}s`);
|
||
return ok;
|
||
}
|
||
|
||
// The heart of v2: window embeddings → clusters → per-turn speaker labels.
|
||
// Split from the audio/embedding part so the clustering can be replayed offline
|
||
// on cached embeddings (lab/lab-eval.mjs) — the tuning ran against THIS code.
|
||
export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) {
|
||
// Tags sind Identitäts-Schlüssel (siehe identityKey); fürs Log brauchen wir den
|
||
// Klarnamen dahinter.
|
||
const keyLabel = new Map();
|
||
for (const mk of markers || []) { const k = identityKey(mk.name, persons); if (k && !keyLabel.has(k)) keyLabel.set(k, mk.name); }
|
||
for (const vp of voiceprints || []) {
|
||
const k = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons);
|
||
if (k && !keyLabel.has(k)) keyLabel.set(k, vp.name);
|
||
}
|
||
const disp = k => keyLabel.get(k) || k;
|
||
// Channel compensation: L2 → subtract the recording mean → L2 again.
|
||
let X = ok.map(w => l2(w.vec));
|
||
const mu = meanVec(X);
|
||
X = X.map(v => l2(v.map((x, i) => x - mu[i])));
|
||
|
||
// Windows of a turn, and the turn label from its windows (duration-weighted).
|
||
const winsOfTurn = new Map();
|
||
ok.forEach((w, i) => { (winsOfTurn.get(w.ti) || winsOfTurn.set(w.ti, []).get(w.ti)).push(i); });
|
||
const labelTurns = (groups) => {
|
||
const of = new Int32Array(ok.length).fill(-1);
|
||
groups.forEach((g, gi) => g.forEach(i => of[i] = gi));
|
||
const lab = new Array(turns.length).fill(-1);
|
||
for (const [ti, idxs] of winsOfTurn) {
|
||
const m = new Map();
|
||
for (const i of idxs) m.set(of[i], (m.get(of[i]) || 0) + (ok[i].end - ok[i].start));
|
||
let bl = -1, bd = -1;
|
||
for (const [l, d] of m) 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 centroidOf = g => l2(meanVec(g.map(i => X[i])));
|
||
|
||
const cosFinal = COS_FINAL_ENV !== null ? COS_FINAL_ENV : mergeThreshold(X);
|
||
|
||
// Stage A: deliberate over-clustering — every real speaker ends up in one or
|
||
// more clusters, but (almost) no cluster mixes two speakers.
|
||
let { groups } = ahcAverage(X, COS_FINE, 0);
|
||
const fineCount = groups.length;
|
||
|
||
// Fold clusters with almost no talk time (overlap, crosstalk, door noise) into
|
||
// the acoustically nearest real speaker; they must not survive as "speakers"
|
||
// and must not be merge partners in stage B. The floor scales with the length
|
||
// of the recording — a flat 20 s would swallow a real second speaker on a
|
||
// 2-minute call, while 3 s would let noise through on a 33-minute conference.
|
||
const speechSec = turns.reduce((a, t) => a + (t.end - t.start), 0);
|
||
const minTotal = Math.min(MIN_TOTAL_SEC, Math.max(3, speechSec * 0.02));
|
||
const { talk } = labelTurns(groups);
|
||
const strongIdx = groups.map((_, 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, centroidOf(groups[i])]));
|
||
const keep = new Map(strongIdx.map(i => [i, [...groups[i]]]));
|
||
groups.forEach((g, i) => {
|
||
if (keep.has(i)) return;
|
||
const c = centroidOf(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; } }
|
||
keep.get(best).push(...g);
|
||
folded++;
|
||
});
|
||
groups = [...keep.values()];
|
||
}
|
||
|
||
// Live markers → must-link (same name in one group) + cannot-link (different
|
||
// names never merge). Applied to the folded stage-A clusters: the cluster a
|
||
// marker's turn landed in carries that name.
|
||
const anchors = markerAnchors(turns, markers, persons);
|
||
let tags = null;
|
||
if (anchors.size) {
|
||
const { lab } = labelTurns(groups);
|
||
const nameOfGroup = new Map(); // groupIdx -> name (strongest vote wins)
|
||
const votes = new Map();
|
||
for (const [name, hits] of anchors) for (const { ti, d } of hits) {
|
||
const g = lab[ti];
|
||
if (g < 0) continue;
|
||
const key = g + '\0' + name;
|
||
votes.set(key, (votes.get(key) || 0) + anchorWeight(d));
|
||
}
|
||
const bestPerGroup = new Map();
|
||
for (const [key, v] of votes) {
|
||
const [g, name] = key.split('\0');
|
||
const cur = bestPerGroup.get(g);
|
||
if (!cur || v > cur.v) bestPerGroup.set(g, { name, v });
|
||
else cur.rival = true;
|
||
}
|
||
for (const [g, { name, rival }] of bestPerGroup) {
|
||
nameOfGroup.set(+g, name);
|
||
// Two different names anchored in ONE cluster means stage A already glued
|
||
// two people together — the cannot-link can no longer separate them, so at
|
||
// least say so instead of silently keeping the louder vote.
|
||
if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Personen → "${disp(name)}" gewinnt (Sprecher evtl. verschmolzen)`);
|
||
}
|
||
// must-link: groups carrying the SAME name become one — but only as far as
|
||
// they sound alike (see MARK_LINK_REL). The strongest-vote group is the seed;
|
||
// an acoustically distant namesake stays separate and UNTAGGED, so stage B
|
||
// may still merge it on its own evidence.
|
||
const linkMin = cosFinal * MARK_LINK_REL;
|
||
const byName = new Map();
|
||
for (const [g, name] of nameOfGroup) (byName.get(name) || byName.set(name, []).get(name)).push(g);
|
||
const used = new Set();
|
||
const merged = [], mtags = [];
|
||
let anchored = 0, rejected = 0;
|
||
for (const [name, gs] of byName) {
|
||
const ranked = [...gs].sort((a, b) => (bestPerGroup.get(String(b))?.v || 0) - (bestPerGroup.get(String(a))?.v || 0));
|
||
const seed = ranked[0];
|
||
const seedCent = centroidOf(groups[seed]);
|
||
const take = [seed];
|
||
for (const g of ranked.slice(1)) {
|
||
const c = dot(seedCent, centroidOf(groups[g]));
|
||
if (c >= linkMin) { take.push(g); continue; }
|
||
rejected++;
|
||
console.log(`diar v2: Marker "${disp(name)}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`);
|
||
}
|
||
merged.push(take.flatMap(g => groups[g]));
|
||
mtags.push(name);
|
||
take.forEach(g => used.add(g));
|
||
anchored += take.length;
|
||
}
|
||
groups.forEach((g, i) => { if (!used.has(i)) { merged.push(g); mtags.push(null); } });
|
||
groups = merged; tags = mtags;
|
||
console.log(`diar v2: ${anchors.size} Marker-Name(n) als Zwangsbedingung (${anchored} Cluster verankert, ${rejected} verworfen)`);
|
||
}
|
||
|
||
// Known voices → cannot-link for every cluster the markers did NOT already
|
||
// claim. Typed markers stay authoritative: a name that is already a marker tag
|
||
// is skipped here, and one enrolled person can claim at most one cluster (best
|
||
// score wins), so a shaky second match cannot split a speaker in two.
|
||
if ((voiceprints || []).length) {
|
||
if (!tags) tags = groups.map(() => null);
|
||
const rawCent = g => l2(meanVec(g.map(i => ok[i].vec)));
|
||
const taken = new Set(tags.filter(Boolean));
|
||
const cand = [];
|
||
groups.forEach((g, gi) => {
|
||
if (tags[gi]) return;
|
||
const c = rawCent(g);
|
||
// Pro PERSON ihr bester Abdruck, und der Abstand gilt zur nächstbesten
|
||
// PERSON. Vorher lief beides über einzelne Abdrücke: zwei Abdrücke desselben
|
||
// Menschen ("Sascha" 0,928 zu "Sascha Wolf") rissen die Abstandsprüfung, die
|
||
// Zwangsbedingung war damit faktisch aus.
|
||
const perId = new Map();
|
||
for (const vp of voiceprints) {
|
||
const key = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons);
|
||
const s = spk.cosine(c, vp.vec);
|
||
if (!perId.has(key) || s > perId.get(key)) perId.set(key, s);
|
||
}
|
||
let best = null, s1 = -Infinity, s2 = -Infinity;
|
||
for (const [key, s] of perId) {
|
||
if (s > s1) { s2 = s1; s1 = s; best = key; }
|
||
else if (s > s2) s2 = s;
|
||
}
|
||
if (best && s1 >= VP_MIN && (s1 - s2) >= VP_MARGIN && !taken.has(best)) cand.push({ gi, name: best, s: s1 });
|
||
});
|
||
cand.sort((a, b) => b.s - a.s);
|
||
let vpTags = 0;
|
||
for (const c of cand) {
|
||
if (taken.has(c.name) || tags[c.gi]) continue;
|
||
tags[c.gi] = c.name; taken.add(c.name); vpTags++;
|
||
console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${disp(c.name)}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`);
|
||
}
|
||
if (!vpTags) console.log(`diar v2: keine Stimme sicher genug erkannt (${voiceprints.length} Abdrücke, Schwelle ${VP_MIN})`);
|
||
}
|
||
|
||
// Stage B: merge the remaining strong clusters up to the final threshold — or
|
||
// down to the user-pinned speaker count. Marker constraints are respected.
|
||
const stageB = ahcAverage(X, cosFinal, numSpeakers, groups, tags);
|
||
groups = stageB.groups;
|
||
|
||
const { lab: turnLab } = labelTurns(groups);
|
||
// Turns too short to embed inherit the temporally nearest labelled turn.
|
||
for (let i = 0; i < turns.length; i++) {
|
||
if (turnLab[i] >= 0) continue;
|
||
let best = -1, bd = Infinity;
|
||
for (let j = 0; j < turns.length; j++) {
|
||
if (turnLab[j] < 0) continue;
|
||
const d = Math.min(Math.abs(turns[j].end - turns[i].start), Math.abs(turns[i].end - turns[j].start));
|
||
if (d < bd) { bd = d; best = turnLab[j]; }
|
||
}
|
||
turnLab[i] = best >= 0 ? best : 0;
|
||
}
|
||
|
||
// Renumber by first appearance and glue adjacent turns of the same speaker
|
||
// (gap <= 0.5 s — what sherpa's min-duration-off would have done).
|
||
const rename = new Map(); let nSp = 0;
|
||
const out = [];
|
||
turns.forEach((t, ti) => {
|
||
const l = turnLab[ti];
|
||
if (!rename.has(l)) rename.set(l, `speaker_${String(nSp++).padStart(2, '0')}`);
|
||
const sp = rename.get(l);
|
||
const last = out[out.length - 1];
|
||
if (last && last.speaker === sp && t.start - last.end <= 0.5) last.end = Math.max(last.end, t.end);
|
||
else out.push({ start: t.start, end: t.end, speaker: sp });
|
||
});
|
||
console.log(`diar v2: ${turns.length} Turns, ${ok.length} Fenster → ${fineCount} Cluster (fein ${COS_FINE})` +
|
||
` → ${folded} schwache <${minTotal.toFixed(0)}s eingefaltet → ${nSp} Sprecher` +
|
||
(numSpeakers ? ` [fix ${numSpeakers}]` : ` (final ${cosFinal.toFixed(3)}${COS_FINAL_ENV === null ? ' automatisch' : ''})`));
|
||
return out;
|
||
}
|
||
|
||
// Average voiceprint of a cluster: embed its longest few segments and mean them
|
||
// (short cuts give noisy embeddings, so prefer the longest and average out noise).
|
||
async function clusterCentroid(wavPath, clusterSegs) {
|
||
const sorted = [...clusterSegs].sort((a, b) => (b.end - b.start) - (a.end - a.start));
|
||
const pick = sorted.filter(s => s.end - s.start >= MIN_REP_SEC).slice(0, REP_SEGMENTS);
|
||
const use = pick.length ? pick : sorted.slice(0, 1);
|
||
const vecs = [];
|
||
for (const s of use) {
|
||
const seg = `/tmp/diarcons_${process.pid}_${Math.round(s.start * 1000)}.wav`;
|
||
try {
|
||
await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(s.start),
|
||
'-to', String(Math.min(s.end, s.start + 8)), '-ar', '16000', '-ac', '1', seg]);
|
||
const v = await spk.embed(seg);
|
||
if (Array.isArray(v) && v.length) vecs.push(v);
|
||
} catch { /* skip this cut */ }
|
||
finally { try { fs.unlinkSync(seg); } catch {} }
|
||
}
|
||
if (!vecs.length) return null;
|
||
const dim = vecs[0].length, c = new Array(dim).fill(0);
|
||
for (const v of vecs) for (let i = 0; i < dim; i++) c[i] += v[i];
|
||
for (let i = 0; i < dim; i++) c[i] /= vecs.length;
|
||
return c;
|
||
}
|
||
|
||
// Merge sherpa's over-segmented clusters (anchor-based, see fn body),
|
||
// then renumber the surviving speakers by first appearance. Segments whose
|
||
// centroid couldn't be embedded keep their own label (safe: never lose a turn).
|
||
export async function consolidateSpeakers(wavPath, segs) {
|
||
const labels = [...new Set(segs.map(s => s.speaker))];
|
||
if (labels.length <= 1) return segs;
|
||
|
||
// Centroid + total speech duration per raw sherpa cluster.
|
||
const cents = {}, dur = {};
|
||
for (const lab of labels) {
|
||
const cs = segs.filter(s => s.speaker === lab);
|
||
cents[lab] = await clusterCentroid(wavPath, cs);
|
||
dur[lab] = cs.reduce((a, s) => a + (s.end - s.start), 0);
|
||
}
|
||
|
||
// ANCHOR-based merge (replaces the old pairwise union-find, which chained
|
||
// transitively — A~B, B~C folded A and C together — so one borderline link
|
||
// near the 0.5 cosine could cascade and the count flipped run-to-run under
|
||
// load/thermal-cap FP noise: the SAME 2-3 person call came out 2, 3, or 7).
|
||
// Only long clusters (reliable embeddings) become anchors and, processed
|
||
// LONGEST-FIRST, either join an existing speaker or open a new one — a stable,
|
||
// order-deterministic decision. Every short/stray cluster is then assigned to
|
||
// its NEAREST anchor (never opens a speaker), so noise can't fragment.
|
||
const anchors = labels
|
||
.filter(l => cents[l] && dur[l] >= MIN_SPEAKER_SEC)
|
||
.sort((a, b) => (dur[b] - dur[a]) || (a < b ? -1 : 1));
|
||
|
||
const speakerOf = {}; // rawLabel -> canonical speaker rep (a rawLabel)
|
||
const reps = []; // accepted speaker representatives
|
||
for (const a of anchors) {
|
||
let joined = null;
|
||
for (const r of reps) if (spk.cosine(cents[a], cents[r]) >= MERGE_THRESHOLD) { joined = r; break; }
|
||
if (joined) speakerOf[a] = joined; else { speakerOf[a] = a; reps.push(a); }
|
||
}
|
||
|
||
const nearestRep = (lab) => {
|
||
if (!reps.length) return lab; // no anchors -> keep own label
|
||
if (!cents[lab]) return reps[0]; // unembeddable -> dominant speaker
|
||
let best = reps[0], bs = -Infinity;
|
||
for (const r of reps) { const c = spk.cosine(cents[lab], cents[r]); if (c > bs) { bs = c; best = r; } }
|
||
return best;
|
||
};
|
||
|
||
const merged = segs.map(s => ({ ...s, speaker: speakerOf[s.speaker] || nearestRep(s.speaker) }));
|
||
|
||
// Stage 3: fold weak FINAL speakers (< MIN_TOTAL_SEC total talk over the
|
||
// whole call) into the acoustically nearest strong one — see MIN_TOTAL_SEC.
|
||
const total = {};
|
||
for (const s of merged) total[s.speaker] = (total[s.speaker] || 0) + (s.end - s.start);
|
||
const strong = reps.filter(r => (total[r] || 0) >= MIN_TOTAL_SEC);
|
||
if (strong.length && strong.length < reps.length) {
|
||
const fold = {};
|
||
for (const r of Object.keys(total)) {
|
||
if (strong.includes(r)) continue;
|
||
let best = strong[0], bs = -Infinity;
|
||
for (const t of strong) {
|
||
const c = (cents[r] && cents[t]) ? spk.cosine(cents[r], cents[t]) : -1;
|
||
if (c > bs) { bs = c; best = t; }
|
||
}
|
||
fold[r] = best;
|
||
}
|
||
for (const s of merged) if (fold[s.speaker]) s.speaker = fold[s.speaker];
|
||
console.log(`diar: folded ${Object.keys(fold).length} weak speaker(s) <${MIN_TOTAL_SEC}s total talk`);
|
||
}
|
||
|
||
// Renumber surviving speakers by first appearance.
|
||
const rootMap = {}; let n = 0;
|
||
for (const s of merged) if (!(s.speaker in rootMap)) rootMap[s.speaker] = `speaker_${String(n++).padStart(2, '0')}`;
|
||
if (n !== labels.length) console.log(`diar: consolidated ${labels.length} -> ${n} speakers (anchors ${reps.length}, merge>=${MERGE_THRESHOLD}, floor ${MIN_SPEAKER_SEC}s)`);
|
||
return merged.map(s => ({ ...s, speaker: rootMap[s.speaker] }));
|
||
}
|
||
|
||
// Assign each word (t_start_ms/t_end_ms) the speaker whose segment overlaps it
|
||
// most. Words with no overlap inherit the previous word's speaker.
|
||
export function assignSpeakers(words, segs) {
|
||
if (!segs.length) return words;
|
||
let last = segs[0].speaker;
|
||
for (const w of words) {
|
||
const ws = w.t_start_ms / 1000, we = w.t_end_ms / 1000;
|
||
let best = null, bestOv = 0;
|
||
for (const s of segs) {
|
||
const ov = Math.min(we, s.end) - Math.max(ws, s.start);
|
||
if (ov > bestOv) { bestOv = ov; best = s.speaker; }
|
||
}
|
||
w.speaker = best || last;
|
||
last = w.speaker;
|
||
}
|
||
return words;
|
||
}
|
||
|
||
// --- live name markers ------------------------------------------------------
|
||
// Contiguous runs of one speaker over the word list (ms).
|
||
export function speakerRuns(words) {
|
||
const runs = [];
|
||
for (const w of words || []) {
|
||
if (!w.speaker) continue;
|
||
const last = runs[runs.length - 1];
|
||
if (last && last.speaker === w.speaker) last.end = w.t_end_ms;
|
||
else runs.push({ speaker: w.speaker, start: w.t_start_ms, end: w.t_end_ms });
|
||
}
|
||
return runs;
|
||
}
|
||
|
||
// Resolve names the user typed DURING the recording to diarization clusters.
|
||
// A marker is stamped when the name is submitted, i.e. typically a few seconds
|
||
// AFTER that person started talking (hear voice → type → Enter), occasionally
|
||
// just before they start. So we score every speaker run in an asymmetric window
|
||
// around the marker by overlap × proximity, and prefer the run that covers the
|
||
// marker itself. Returns [{ ...marker, speaker, conf }] (conf: high | low | null).
|
||
const MARK_PRE = +(process.env.MARK_PRE_MS || 25000); // look back (typing lag)
|
||
const MARK_POST = +(process.env.MARK_POST_MS || 8000); // look ahead (typed early)
|
||
const MARK_DECAY = +(process.env.MARK_DECAY_MS || 10000);
|
||
export function resolveMarkers(words, markers) {
|
||
const runs = speakerRuns(words);
|
||
return (markers || []).map(m => {
|
||
const t = m.t_ms || 0, lo = t - MARK_PRE, hi = t + MARK_POST;
|
||
const score = {}, covering = {};
|
||
for (const r of runs) {
|
||
const ov = Math.min(hi, r.end) - Math.max(lo, r.start);
|
||
if (ov <= 0) continue;
|
||
const dist = r.start > t ? r.start - t : (r.end < t ? t - r.end : 0);
|
||
score[r.speaker] = (score[r.speaker] || 0) + ov * Math.exp(-dist / MARK_DECAY);
|
||
if (r.start <= t && r.end >= t) covering[r.speaker] = Math.max(covering[r.speaker] || 0, r.end - r.start);
|
||
}
|
||
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
|
||
if (!ranked.length) return { ...m, speaker: null, conf: null };
|
||
const [sp, s1] = ranked[0];
|
||
const s2 = ranked[1] ? ranked[1][1] : 0;
|
||
const conf = (covering[sp] >= 1500 && s1 >= 1.5 * s2) ? 'high'
|
||
: (s1 >= 2 * s2 || ranked.length === 1) ? 'mid' : 'low';
|
||
return { ...m, speaker: sp, conf };
|
||
});
|
||
}
|
||
|
||
// Group consecutive same-speaker words into turns [{ speaker, text }].
|
||
export function toTurns(words) {
|
||
const turns = [];
|
||
for (const w of words) {
|
||
const sp = w.speaker || 'speaker_00';
|
||
if (!turns.length || turns[turns.length - 1].speaker !== sp) turns.push({ speaker: sp, text: w.word });
|
||
else turns[turns.length - 1].text += ' ' + w.word;
|
||
}
|
||
return turns;
|
||
}
|