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:
+29
-6
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user