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:
Claude
2026-07-27 17:00:45 +02:00
parent 432c4b14dd
commit 48fa6b4f9e
17 changed files with 1357 additions and 67 deletions
+29 -6
View File
@@ -158,13 +158,35 @@ function wavDurationSec(wavPath) {
try { return Math.max(0, (fs.statSync(wavPath).size - 44) / 32000); } catch { return 0; }
}
let current = null; // { proc, wavPath, startTs, source, title }
let current = null; // { proc, wavPath, startTs, source, title, markers }
export function status() {
return current ? { recording: true, startedAt: current.startTs, source: current.source } : { recording: false };
return current ? { recording: true, startedAt: current.startTs, source: current.source,
markers: current.markers } : { recording: false };
}
export function isRecording() { return !!current; }
// --- live speaker markers ---------------------------------------------------
// While recording, the user types the name of whoever is talking. We stamp the
// offset from recording start; after diarization the name is attached to the
// acoustic cluster that was speaking around that offset (see diar.resolveMarkers).
// This is the only reliable way to name 20+ participants of a conference call.
export function addMarker(name) {
if (!current) throw new Error('not recording');
const n = String(name || '').trim();
if (!n) throw new Error('Name fehlt');
const m = { t_ms: Date.now() - current.startTs, name: n.slice(0, 128) };
current.markers.push(m);
return { ...m, index: current.markers.length - 1, markers: current.markers };
}
export function listMarkers() { return current ? current.markers : []; }
export function removeMarker(index) {
if (!current) throw new Error('not recording');
const i = +index;
if (i >= 0 && i < current.markers.length) current.markers.splice(i, 1);
return current.markers;
}
// List real capture devices (one entry per recording device). A "source" in
// PulseAudio/PipeWire includes monitor sources (the loopback of every OUTPUT)
// and both A2DP/HFP variants of a BT card — confusing for the user. We only
@@ -263,7 +285,7 @@ export async function startRecording({ source, title, codec, numSpeakers } = {})
// in the list (mSBC 16 kHz vs CVSD 8 kHz).
const codecTag = hfp && hfp.profile ? (/cvsd/i.test(hfp.profile) ? ' [CVSD 8k]' : ' [mSBC 16k]') : '';
current = {
proc, wavPath, startTs: ts, source: source || 'default', numSpeakers,
proc, wavPath, startTs: ts, source: source || 'default', numSpeakers, markers: [],
title: (title || `Aufnahme ${new Date(ts).toLocaleString('de-DE')}`) + codecTag, err: () => err
};
return { startedAt: ts };
@@ -328,13 +350,14 @@ export async function stopAndSave() {
catch { fs.copyFileSync(c.wavPath, path.join(REC_DIR, wavFile)); try { fs.unlinkSync(c.wavPath); } catch {} }
return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur,
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers };
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers,
markers: c.markers };
}
// Transcribe a saved wav (in REC_DIR) with whisper (thermal-guarded) + diarize.
// Returns { transcript, words }. Idempotent — safe to re-run for recovery of a
// recording whose transcription was interrupted (crash) or failed.
export async function transcribeWav(wavFile, numSpeakers) {
export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
const wavPath = path.isAbsolute(wavFile) ? wavFile : path.join(REC_DIR, wavFile);
if (!fs.existsSync(wavPath)) throw new Error('wav-Datei fehlt: ' + wavFile);
const outBase = `/tmp/${path.basename(wavPath, '.wav')}`;
@@ -389,7 +412,7 @@ export async function transcribeWav(wavFile, numSpeakers) {
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
await setA76Max(A76_CAP);
try {
const segs = await diar.diarize(wavPath, numSpeakers);
const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [] });
diar.assignSpeakers(words, segs);
} catch (e) { console.error('diarize failed:', e.message); }
try { fs.unlinkSync(`${outBase}.json`); } catch {}