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:
+108
-3
@@ -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 & analysiert</div></div>
|
||||
<div class="muted">wird nach Stop transkribiert & 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
|
||||
|
||||
Reference in New Issue
Block a user