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
+108 -3
View File
@@ -8,7 +8,7 @@ const api = {
};
const app = document.querySelector('#app');
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null };
const state = { tab: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '' };
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
@@ -34,6 +34,10 @@ function render() {
<div class="rec-ctl">
<button id="recbtn" class="recbtn ${state.recording ? 'rec' : ''}" title="${state.recording ? 'Stop & Transkribieren' : 'Aufnahme starten'}">${state.recording ? '■' : '●'}</button>
<span class="rectime mono" data-rectime>${state.recording ? fmt(Date.now() - state.recStart) : '00:00'}</span>
${state.recording ? `
<input id="mkname" class="input mkin" placeholder="Wer spricht? Name + Enter" value="${escapeHtml(state.mkDraft)}"
title="Namen der Person eintippen, die JETZT spricht — der Zeitstempel ordnet ihn später dem Sprecher zu">
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
</div>
<nav>
${['recordings', 'voices', 'glossary', 'bluetooth'].map(t =>
@@ -46,6 +50,7 @@ function render() {
app.querySelector('#themetoggle').onclick = () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
app.querySelector('#recbtn').onclick = toggleRecord;
bindMarkerInput();
if (state.recording) startTimerUI();
({ bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab]();
}
@@ -59,6 +64,7 @@ async function toggleRecord() {
try {
await api.post('/api/record/start', {});
state.recording = true; state.recStart = Date.now();
state.markers = []; state.mkDraft = '';
render();
if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
} catch (err) { toast(err.message, true); }
@@ -75,6 +81,52 @@ async function toggleRecord() {
} catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); }
}
}
// ---------- live speaker markers ----------
// While recording: type the name of whoever is talking, Enter. The server stamps
// the offset; after diarization the name lands on that acoustic speaker. Only
// reliable way to tell 20+ conference participants apart.
function bindMarkerInput() {
const inp = document.querySelector('#mkname');
if (!inp) return;
inp.oninput = () => { state.mkDraft = inp.value; };
inp.onkeydown = async (e) => {
if (e.key !== 'Enter') return;
const name = inp.value.trim();
if (!name) return;
inp.value = ''; state.mkDraft = '';
try {
const r = await api.post('/api/record/marker', { name });
state.markers = r.markers || [];
updateMarkerUI();
toast(`🏷 ${fmt(r.t_ms)} ${name}`);
} catch (err) { toast('Marker fehlgeschlagen: ' + err.message, true); }
};
}
// Refresh the marker count + chip list in place (no re-render → focus stays in
// the input, so names can be typed back-to-back).
function updateMarkerUI() {
const c = document.querySelector('#mkcount');
if (c) { c.textContent = '🏷 ' + state.markers.length; c.classList.toggle('ok', !!state.markers.length); }
const chips = document.querySelector('#mkchips');
if (chips) chips.innerHTML = markerChipsHtml();
bindMarkerChips();
}
function markerChipsHtml() {
if (!state.markers.length) return '<span class="muted">noch keine Namen markiert</span>';
return state.markers.map((m, i) =>
`<span class="mkchip"><span class="mono">${fmt(m.t_ms)}</span> ${escapeHtml(m.name)}<button class="mkdel" data-mk="${i}" title="Marker entfernen">✕</button></span>`).join('');
}
function bindMarkerChips() {
document.querySelectorAll('.mkdel').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
try {
const r = await api.del('/api/record/marker/' + b.dataset.mk);
state.markers = r.markers || [];
updateMarkerUI();
} catch (err) { toast(err.message, true); }
});
}
// Tick every .rectime element (header + list placeholder) from one interval.
function startTimerUI() {
clearInterval(state.timer);
@@ -340,7 +392,8 @@ async function refreshRecList() {
<div class="item rec-live">
<span class="rec-dot"></span>
<div class="grow"><b>Aufnahme läuft…</b>
<div class="muted">wird nach Stop transkribiert &amp; analysiert</div></div>
<div class="muted">wird nach Stop transkribiert &amp; analysiert</div>
<div class="mkchips" id="mkchips">${markerChipsHtml()}</div></div>
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
</div>` : '';
listEl.innerHTML = liveHtml + groups.map(day => {
@@ -374,6 +427,7 @@ async function refreshRecList() {
if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; }
state.lastListSig = null; refreshRecList();
});
bindMarkerChips();
listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => {
const day = h.dataset.day;
if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day);
@@ -433,10 +487,30 @@ async function openRecording(id) {
</div>
</div>
</div>` : '';
// Live-typed name markers: which cluster each one was resolved to, plus the
// option to add one while listening back (same resolution path) and to re-run
// the assignment after a re-diarization.
const CONF_LBL = { high: '<span class="badge ok">sicher</span>', mid: '<span class="badge warn">wahrscheinlich</span>', low: '<span class="badge err">unsicher</span>' };
const markersHtml = `
<h3>Namensmarker <span class="muted" style="font-weight:400">(während der Aufnahme getippt)</span></h3>
${(r.markers || []).length ? `<ul class="markers">${r.markers.map(m => {
const i = spkOrder.indexOf(m.speaker);
const to = m.speaker ? spkLabel(m.speaker, i >= 0 ? i : 0) : '– kein Sprecher gefunden';
return `<li><button class="mkseek mono" data-t="${m.t_ms}" title="Dorthin springen">${fmt(m.t_ms)}</button>
<b>${escapeHtml(m.name)}</b> → <span class="${m.speaker ? (scls[m.speaker] || '') : 'muted'}">${escapeHtml(to)}</span>
${CONF_LBL[m.conf] || ''}
<button class="btn danger icon small" data-mkdel="${m.id}" title="Marker löschen">🗑</button></li>`;
}).join('')}</ul>` : '<div class="muted">Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.</div>'}
<div class="row mkadd-row">
<input class="input" id="mkat" placeholder="Name an aktueller Abspielposition">
<button class="btn small" id="mkatbtn">🏷 Setzen</button>
${(r.markers || []).length ? '<button class="btn small" id="applymk" title="Marker erneut den Sprechern zuordnen (nach Neu-Trennung)">↻ Zuordnen</button>' : ''}
</div>`;
const mods = {
analyse: `
${r.summary ? `<p class="summary">${escapeHtml(r.summary)}</p>` : '<div class="muted">Noch keine Zusammenfassung.</div>'}
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}`,
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}
${markersHtml}`,
todos: (r.todos && r.todos.length) ? `<ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : '<div class="muted">Keine ToDos erkannt.</div>',
transcript: `
<div id="transcript-view">
@@ -568,6 +642,37 @@ async function openRecording(id) {
setTimeout(pollDetail, 5000);
}
const audio = detail.querySelector('#player');
// ---- marker actions (seek / add at playhead / delete / re-assign) ----
detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => {
audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play();
});
const mkAt = detail.querySelector('#mkat'), mkAtBtn = detail.querySelector('#mkatbtn');
const addAt = async () => {
const name = (mkAt.value || '').trim();
if (!name) return;
try {
await api.post(`/api/recordings/${id}/markers`, { name, t_ms: Math.round(audio.currentTime * 1000) });
mkAt.value = '';
await api.post(`/api/recordings/${id}/apply-markers`, {});
toast(`🏷 ${name} gesetzt & zugeordnet`);
openRecording(id);
} catch (e) { toast(e.message, true); }
};
if (mkAtBtn) mkAtBtn.onclick = addAt;
if (mkAt) mkAt.onkeydown = (e) => { if (e.key === 'Enter') addAt(); };
detail.querySelectorAll('[data-mkdel]').forEach(b => b.onclick = async () => {
try { await api.del(`/api/recordings/${id}/markers/${b.dataset.mkdel}`); openRecording(id); }
catch (e) { toast(e.message, true); }
});
const amk = detail.querySelector('#applymk');
if (amk) amk.onclick = async () => {
amk.disabled = true; amk.textContent = 'ordne zu…';
try {
const r2 = await api.post(`/api/recordings/${id}/apply-markers`, {});
toast(`${r2.named} Sprecher benannt, ${r2.merged} zusammengeführt, ${r2.learned} Stimme(n) gelernt`);
openRecording(id);
} catch (e) { toast(e.message, true); amk.disabled = false; }
};
const spans = [...detail.querySelectorAll('.w')];
spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); });
// Whisper (batched) word timestamps run slightly AHEAD of the audio, so a