Diarisierung: Marker-Zwangsbedingungen absichern, Voiceprints als cannot-link; LLM: Sektions-Pfad ab 9k Zeichen
#79 verlor einen von vier Sprechern und lieferte eine 1082-Zeichen-Zusammenfassung. Diarisierung: ein zweiter, falsch verankerter Marker verschmolz Saschas Cluster in Merles (Kosinus -0,727). must-link prueft jetzt die akustische Aehnlichkeit, Anker werden nach Trefferqualitaet gewichtet, und enrolled Stimmen wirken als cannot-link -- damit ist der Box-Besitzer immer ein eigener Sprecher. LLM: der Whole-Pass komprimierte das erste Thema weg. Sektions-Pfad jetzt ab 9000 Zeichen, kleinere Chunks, woertlicher Kontext aus dem Vorgaenger-Chunk und ein gezielter Pruef-Aufruf gegen ueberdehnte Einzelaussagen. #79: 4 Sprecher, Zusammenfassung ~5300 Zeichen, 7 Themenbloecke. Regression: #70 -> 4, #68 -> 2, #74 -> 20 Sprecher unveraendert.
This commit is contained in:
+58
-5
@@ -44,7 +44,16 @@ function buildBasis(rec, nameMap = {}) {
|
||||
if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} };
|
||||
const turns = diar.toTurns(words);
|
||||
const labels = {}; let n = 0;
|
||||
const label = sp => labels[sp] || (labels[sp] = nameMap[sp] || `Sprecher ${++n}`);
|
||||
// Labels MUST be unique. On #79 a mis-anchored marker named Sascha's cluster
|
||||
// "Markus" while the voiceprint named Markus' own cluster "Markus" too — the
|
||||
// LLM then read two different people under one name and blended them into one
|
||||
// person, and relabelAnalysis refuses to patch an ambiguous label afterwards.
|
||||
const used = new Set();
|
||||
const uniq = (name) => {
|
||||
if (!used.has(name)) { used.add(name); return name; }
|
||||
for (let k = 2; ; k++) if (!used.has(`${name} ${k}`)) { used.add(`${name} ${k}`); return `${name} ${k}`; }
|
||||
};
|
||||
const label = sp => labels[sp] || (labels[sp] = uniq(nameMap[sp] || `Sprecher ${++n}`));
|
||||
const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n');
|
||||
return { text, labeled: true, labels };
|
||||
}
|
||||
@@ -182,12 +191,21 @@ async function applyMarkers(id) {
|
||||
const resolved = diar.resolveMarkers(rec.words, rec.markers);
|
||||
for (const m of resolved) await db.setMarkerSpeaker(m.id, m.speaker, m.conf).catch(() => {});
|
||||
|
||||
// name -> clusters (only confident hits drive the merge)
|
||||
// name -> clusters. NAMING may follow a 'mid' hit, but the cluster MERGE below
|
||||
// is destructive and unguarded (no acoustic check at this level), so only a
|
||||
// 'high' hit — marker inside the turn, that turn clearly dominant — is allowed
|
||||
// to fold two clusters together. #79 lost a speaker exactly this way one layer
|
||||
// deeper, in the diarizer's must-link.
|
||||
const byName = {};
|
||||
for (const m of resolved) {
|
||||
if (!m.speaker || m.conf === 'low' || m.conf === null) continue;
|
||||
if (!m.speaker || m.conf !== 'high') continue;
|
||||
(byName[m.name] = byName[m.name] || new Set()).add(m.speaker);
|
||||
}
|
||||
const nameOnly = {};
|
||||
for (const m of resolved) {
|
||||
if (!m.speaker || m.conf === 'low' || m.conf === null) continue;
|
||||
(nameOnly[m.name] = nameOnly[m.name] || new Set()).add(m.speaker);
|
||||
}
|
||||
const talk = {};
|
||||
for (const w of rec.words) if (w.speaker) talk[w.speaker] = (talk[w.speaker] || 0) + (w.t_end_ms - w.t_start_ms);
|
||||
|
||||
@@ -199,6 +217,13 @@ async function applyMarkers(id) {
|
||||
nameOf[primary] = name;
|
||||
for (const c of cls.slice(1)) fold[c] = primary;
|
||||
}
|
||||
// Naming without merging: a 'mid' hit labels the cluster it landed on, as long
|
||||
// as that cluster is not already claimed by somebody else.
|
||||
for (const [name, set] of Object.entries(nameOnly)) {
|
||||
if (byName[name]) continue;
|
||||
const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0));
|
||||
if (cls[0] && !nameOf[cls[0]]) nameOf[cls[0]] = name;
|
||||
}
|
||||
let merged = 0;
|
||||
if (Object.keys(fold).length) {
|
||||
for (const w of rec.words) if (fold[w.speaker]) w.speaker = fold[w.speaker];
|
||||
@@ -236,7 +261,23 @@ async function analyzeRecording(id) {
|
||||
// Seed the per-recording speaker names from acoustic identification (fills
|
||||
// blanks only; manual edits stay). Manual names win for the LLM labelling.
|
||||
await db.mergeSpeakerNames(id, nameMap).catch(() => {});
|
||||
// Stored names normally win over acoustic identification (they are typed by
|
||||
// hand). Exception: a name that came from a NON-'high' marker is only a
|
||||
// guess about WHICH cluster was speaking — resolveMarkers looks back up to
|
||||
// 25 s, so in a fast four-way discussion it lands on the wrong voice (#79
|
||||
// put "Markus" on Sascha's cluster). If an enrolled voiceprint disagrees
|
||||
// with such a weak marker name, the voiceprint wins.
|
||||
const markers = await db.listMarkers(id).catch(() => []);
|
||||
const weakName = new Map();
|
||||
for (const m of markers) if (m.speaker && m.conf !== 'high') weakName.set(m.speaker, m.name);
|
||||
const names = { ...nameMap, ...(rec.speaker_names || {}) };
|
||||
for (const [sp, vname] of Object.entries(nameMap)) {
|
||||
if (names[sp] !== vname && weakName.get(sp) === names[sp]) {
|
||||
console.log(`identify #${id}: Stimmabdruck "${vname}" schlägt unsicheren Marker "${names[sp]}" auf ${sp}`);
|
||||
names[sp] = vname;
|
||||
await db.setSpeakerName(id, sp, vname).catch(() => {});
|
||||
}
|
||||
}
|
||||
const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled);
|
||||
// Deterministic soundalike replacement: the 3B model is unreliable at obeying
|
||||
// a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries
|
||||
@@ -269,6 +310,11 @@ async function analyzeRecording(id) {
|
||||
a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap));
|
||||
a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap));
|
||||
await db.updateAnalysis(id, { ...a, status: 'done' });
|
||||
// A speaker can be named WHILE the analysis runs (it takes ~15 min, and the
|
||||
// marker/voiceprint passes themselves rename clusters). The finished text
|
||||
// still carries the label from analysis start ("Sprecher 1"), so patch it
|
||||
// once at the end — same mechanism as renaming a speaker by hand.
|
||||
await relabelAnalysis(id).catch(() => {});
|
||||
console.log(`analysis done for #${id}: ${a.title}`);
|
||||
} catch (e) {
|
||||
console.error(`analysis failed for #${id}:`, e.message);
|
||||
@@ -344,7 +390,10 @@ async function transcribeAndAnalyze(id, wavFile, numSpeakers) {
|
||||
// Live markers constrain the diarization itself (same name = one speaker,
|
||||
// different names = never merged), so they are loaded before transcribing.
|
||||
const markers = await db.listMarkers(id).catch(() => []);
|
||||
const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers });
|
||||
// Enrolled voices do the same for people who were never typed — the box
|
||||
// owner above all (see diar.clusterWindows).
|
||||
const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []);
|
||||
const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers, voiceprints });
|
||||
await db.setTranscript(id, transcript, words);
|
||||
console.log(`transcription done for #${id}: ${words.length} segments`);
|
||||
// Names typed live → cluster names (+ user-supervised cluster merge) BEFORE
|
||||
@@ -439,7 +488,11 @@ async function rediarize(id, numSpeakers) {
|
||||
// is the other entry point and must do it too, or a long re-diar overheats).
|
||||
await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {});
|
||||
try {
|
||||
const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [] });
|
||||
// Enrolled voices go IN, not just out: they keep two known people (above all
|
||||
// the box owner, who is enrolled and talks in every recording) from ending up
|
||||
// in one cluster. See the cannot-link block in diar.clusterWindows.
|
||||
const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []);
|
||||
const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [], voiceprints });
|
||||
diar.assignSpeakers(words, segs);
|
||||
await db.replaceWords(id, words);
|
||||
// Re-diarization RENUMBERS the clusters, so every previously stored name
|
||||
|
||||
Reference in New Issue
Block a user