GUI: Sprecher-Verlauf (farbige Spuren + Playhead + Live-Text) und Drag&Drop-Module

Detail-Ansicht modular: Zusammenfassung/Verlauf/ToDos/Sprecher/Transkript
per Grip umsortierbar (localStorage). Verlauf: Spur je Sprecher, Klick=Seek,
aktive Spur pulsiert, Live-Box zeigt aktuellen Redebeitrag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
schakal00783
2026-07-17 00:46:27 +02:00
co-authored by Claude Fable 5
parent 63c7d30b5e
commit 4500e03ae4
3 changed files with 224 additions and 19 deletions
+158 -19
View File
@@ -383,36 +383,100 @@ async function openRecording(id) {
const spkOrder = Object.keys(scls); // first-appearance order
const names = r.speaker_names || {}; // rawSpeaker -> name
const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1));
// Speaker segments + turns for the lane visualizer and the live-text box.
const segsBySpk = speakerSegments(r.words || []);
const turns = wordTurns(r.words || []);
const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1;
// Draggable module blocks; the saved order wins, unknown/new modules append.
const MOD_TITLES = { analyse: 'Zusammenfassung', viz: 'Sprecher-Verlauf', todos: 'ToDos', speakers: 'Sprecher', transcript: 'Transkript' };
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>` : ''}`,
viz: spkOrder.length ? `
<div class="viz-wrap">
<div class="lanes" id="lanes">
${spkOrder.map((sp, i) => `
<div class="lane-row ${scls[sp]}" data-sp="${sp}">
<span class="lane-label"><span class="spk-dot"></span>${escapeHtml(spkLabel(sp, i))}</span>
<div class="lane">${(segsBySpk[sp] || []).map(s =>
`<span class="seg" style="left:${(s.start / durMs * 100).toFixed(3)}%;width:${Math.max(.18, (s.end - s.start) / durMs * 100).toFixed(3)}%"></span>`).join('')}
</div>
</div>`).join('')}
<div class="playhead" id="playhead"></div>
</div>
<div class="livebox" id="livebox"><div class="muted">▶ Abspielen — hier erscheint, was gerade gesprochen wird.</div></div>
</div>
<div class="hint">Klick in die Spuren springt an die Stelle.</div>` : '<div class="muted">Keine Sprecherdaten.</div>',
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>',
speakers: spkOrder.length ? `
<div class="hint">Namen vergeben → App lernt die Stimme.</div>
<div class="spk-edit">${spkOrder.map((sp, i) => `
<div class="spk-row ${scls[sp]}">
<span class="spk-dot"></span>
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
</div>`).join('')}
</div>` : '',
transcript: `
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>`,
};
const defOrder = ['analyse', 'viz', 'todos', 'speakers', 'transcript'];
let order = [];
try { order = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
order = order.filter(m => mods[m] !== undefined).concat(defOrder.filter(m => !order.includes(m)));
detail.innerHTML = `
<div class="card">
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
<div class="analysis">
<div class="status-line">
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
${transcribing ? '<div class="muted">📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).</div>' : ''}
${transcribeFailed ? `<div class="err-inline">Transkription fehlgeschlagen. <button class="btn" id="retranscribe">Neu transkribieren</button></div>` : ''}
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU, ~30-90s)</div>' : ''}
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU)</div>' : ''}
${(errored && !transcribeFailed) ? `<div class="err-inline">KI-Analyse fehlgeschlagen. <button class="btn" id="reanalyze">Erneut versuchen</button></div>` : ''}
${r.summary ? `<h3>Zusammenfassung</h3><p class="summary">${escapeHtml(r.summary)}</p>` : ''}
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}
${(r.todos && r.todos.length) ? `<h3>ToDos</h3><ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : ''}
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
</div>
${spkOrder.length ? `
<h3>Sprecher <span class="muted" style="font-weight:400">(Namen vergeben → App lernt die Stimme)</span></h3>
<div class="spk-edit">${spkOrder.map((sp, i) => `
<div class="spk-row ${scls[sp]}">
<span class="spk-dot"></span>
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
</div>`).join('')}
</div>` : ''}
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<h3>Original-Transkript</h3>
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
<div id="modules">
${order.filter(m => mods[m]).map(m => `
<section class="module" data-mod="${m}">
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3>${MOD_TITLES[m]}</h3></div>
<div class="mod-body">${mods[m]}</div>
</section>`).join('')}
</div>
</div>`;
// Drag & drop: grab a module by its header, drop it above/below the others;
// the order is saved and applies to every recording.
const modWrap = detail.querySelector('#modules');
let dragged = null;
modWrap.querySelectorAll('.mod-head').forEach(h => {
h.addEventListener('dragstart', (e) => {
dragged = h.closest('.module');
dragged.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data
});
h.addEventListener('dragend', () => {
if (!dragged) return;
dragged.classList.remove('dragging');
dragged = null;
const now = [...modWrap.querySelectorAll('.module')].map(m => m.dataset.mod);
localStorage.setItem('aime-detail-order', JSON.stringify(now));
});
});
modWrap.addEventListener('dragover', (e) => {
if (!dragged) return;
e.preventDefault();
const others = [...modWrap.querySelectorAll('.module:not(.dragging)')];
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
if (next) modWrap.insertBefore(dragged, next); else modWrap.appendChild(dragged);
});
const reBtn = detail.querySelector('#reanalyze');
if (reBtn) reBtn.onclick = async () => {
// Re-runs speaker separation every time: empty = automatic detection (now
@@ -468,12 +532,63 @@ async function openRecording(id) {
// start has passed (stays lit through inter-word gaps → no flicker).
// Tune SYNC_MS if the marker still leads (raise) or lags (lower).
const SYNC_MS = 250;
// Lane visualizer wiring: playhead + speaking lane + live-text box.
const lanesEl = detail.querySelector('#lanes');
const playheadEl = detail.querySelector('#playhead');
const liveboxEl = detail.querySelector('#livebox');
let liveTurnIdx = -1;
if (lanesEl) {
// Click (or drag) inside the lanes seeks; ignore the label column.
const seek = (e) => {
const lane = lanesEl.querySelector('.lane');
if (!lane) return;
const rc = lane.getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width));
audio.currentTime = frac * durMs / 1000;
audio.play().catch(() => {});
};
lanesEl.addEventListener('click', seek);
}
const updateViz = (t) => {
if (!lanesEl) return;
if (playheadEl) {
const lane = lanesEl.querySelector('.lane');
if (lane) {
const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth;
playheadEl.style.left = left + 'px';
playheadEl.style.display = 'block';
}
}
// Which speakers talk right now → light their lane rows.
lanesEl.querySelectorAll('.lane-row').forEach(row => {
const sp = row.dataset.sp;
const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end);
row.classList.toggle('speaking', on && !audio.paused);
});
// Live text: last turn whose start has passed (stays through gaps).
if (liveboxEl) {
let idx = -1;
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
if (idx !== liveTurnIdx && idx >= 0) {
liveTurnIdx = idx;
const tu = turns[idx];
const i = spkOrder.indexOf(tu.sp);
liveboxEl.innerHTML = `
<div class="live-turn ${scls[tu.sp] || ''}">
<span class="spk">${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))}</span>
<span class="live-text">${escapeHtml(tu.text)}</span>
</div>`;
}
}
};
audio.onpause = () => { if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); };
audio.ontimeupdate = () => {
const t = audio.currentTime * 1000 - SYNC_MS;
let active = null;
for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; }
spans.forEach(s => s.classList.toggle('active', s === active));
if (active) { const r2 = active.getBoundingClientRect(); if (r2.top < 90 || r2.bottom > innerHeight - 40) active.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
updateViz(t);
};
// Rename a speaker + (optionally) learn their voice into the DB.
detail.querySelectorAll('.spk-save').forEach(btn => {
@@ -511,6 +626,30 @@ async function openRecording(id) {
}
function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// Per-speaker talk segments for the lane visualizer: consecutive words of the
// same speaker, gaps ≤ 1.2 s bridged (word-level gaps would render as dust).
function speakerSegments(words) {
const by = {};
for (const w of words) {
if (!w.speaker) continue;
const list = by[w.speaker] || (by[w.speaker] = []);
const last = list[list.length - 1];
if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms);
else list.push({ start: w.t_start_ms, end: w.t_end_ms });
}
return by;
}
// Turn list (speaker + joined text) for the live-text box.
function wordTurns(words) {
const turns = [];
for (const w of words) {
const last = turns[turns.length - 1];
if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; }
else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms });
}
return turns;
}
// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3.
function speakerClasses(words) {
const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];