import './style.css'; const api = { async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }, async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); }, async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); } }; const app = document.querySelector('#app'); const state = { tab: 'record', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null }; 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); } // Theme: data-theme on , chosen in index.html before first paint (no // flash); the toggle persists to localStorage. function applyTheme(t) { document.documentElement.dataset.theme = t; localStorage.setItem('aime-theme', t); const b = document.querySelector('#themetoggle'); if (b) b.textContent = t === 'dark' ? '☀️' : '🌙'; } function render() { app.classList.toggle('wide', state.tab === 'recordings'); const theme = document.documentElement.dataset.theme || 'dark'; app.innerHTML = `

Aime Anruf-Assistent

`; app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); }); app.querySelector('#themetoggle').onclick = () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); ({ record: viewRecord, bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab](); } // ---------- Bluetooth ---------- async function viewBluetooth() { const v = document.querySelector('#view'); v.innerHTML = `
Lade…
`; const listEl = v.querySelector('#btlist'); const msg = v.querySelector('#btmsg'); async function load(devices) { const d = devices || await api.get('/api/bt/devices'); if (!d.length) { listEl.innerHTML = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
'; return; } listEl.innerHTML = d.map(dev => `
${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''}
${dev.mac}
${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} ${dev.connected ? `` : dev.paired ? `` : ``} ${dev.audio ? `` : ''}
`).join(''); listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b)); } async function act(a, mac, btn) { btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…'; try { const r = await api.post('/api/bt/' + a, { mac }); if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok); else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`); if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {}); } catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); } await load(); } v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; }; v.querySelector('#refresh').onclick = () => load(); load().catch(e => listEl.innerHTML = '
' + e.message + '
'); } // ---------- Record ---------- async function viewRecord() { const v = document.querySelector('#view'); v.innerHTML = `
Wenn du die Anzahl der Sprecher kennst, hier eintragen — verbessert die Trennung. Sonst leer lassen (Auto). Titel, Zusammenfassung, ToDos & Sprecher-Erkennung laufen automatisch per KI.
00:00
`; const sel = v.querySelector('#src'); async function loadSrc() { try { const s = await api.get('/api/audio/sources'); sel.innerHTML = '' + s.map(x => ``).join(''); } catch { sel.innerHTML = ''; } } await loadSrc(); v.querySelector('#reloadsrc').onclick = loadSrc; const timerEl = v.querySelector('#timer'); if (state.recording) startTimerUI(timerEl); v.querySelector('#recbtn').onclick = async (e) => { if (!state.recording) { try { await api.post('/api/record/start', { source: sel.value, codec: v.querySelector('#codec').value, numSpeakers: +v.querySelector('#nspk').value || undefined }); state.recording = true; state.recStart = Date.now(); render(); } catch (err) { toast(err.message, true); } } else { e.target.disabled = true; e.target.textContent = 'Transkribiere… (kann dauern)'; clearInterval(state.timer); try { const r = await api.post('/api/record/stop', {}); state.recording = false; toast('Aufnahme gespeichert – wird transkribiert…'); state.tab = 'recordings'; render(); openRecording(r.id); } catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); } } }; } function startTimerUI(el) { clearInterval(state.timer); state.timer = setInterval(() => { el.textContent = fmt(Date.now() - state.recStart); }, 500); } // ---------- Voices (enrollment) ---------- async function viewVoices() { const v = document.querySelector('#view'); v.innerHTML = `

Stimme anlernen

Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
00:00

Angelernte Stimmen

Lade…
`; const listEl = v.querySelector('#vplist'); async function load() { try { const vps = await api.get('/api/voiceprints'); listEl.innerHTML = vps.length ? vps.map(p => `
👤 ${escapeHtml(p.name)}
${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim
`).join('') : '
Noch keine Stimmen. Oben anlernen.
'; listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); }); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } await load(); const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer'); if (state.enrolling) startTimerUI(timerEl); btn.onclick = async () => { if (!state.enrolling) { const name = v.querySelector('#vname').value.trim(); if (!name) { toast('Bitte Namen eingeben', true); return; } state.enrollName = name; try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); } catch (e) { toast(e.message, true); } } else { btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer); try { const r = await api.post('/api/enroll/stop', { name: state.enrollName }); state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render(); } catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); } } }; } // ---------- Glossary ---------- async function viewGlossary() { const v = document.querySelector('#view'); v.innerHTML = `

Begriff hinzufügen

Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.

Glossar

Lade…
`; const listEl = v.querySelector('#glist'); let editId = null; // id of the entry currently loaded into the form const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel'); function setEdit(g) { editId = g ? g.id : null; formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen'; saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen'; cancelBtn.style.display = g ? '' : 'none'; v.querySelector('#gterm').value = g ? g.term : ''; v.querySelector('#gsound').value = g ? (g.soundalikes || '') : ''; if (g) v.querySelector('#gterm').focus(); } async function load() { try { const gs = await api.get('/api/glossary'); listEl.innerHTML = gs.length ? gs.map(g => `
📖 ${escapeHtml(g.term)} ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
`).join('') : '
Noch keine Begriffe. Oben hinzufügen.
'; listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); if (editId === +b.dataset.del) setEdit(null); load(); }); listEl.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => setEdit(gs.find(g => g.id === +b.dataset.edit))); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } await load(); cancelBtn.onclick = () => setEdit(null); saveBtn.onclick = async () => { const term = v.querySelector('#gterm').value.trim(); if (!term) { toast('Bitte Begriff eingeben', true); return; } const soundalikes = v.querySelector('#gsound').value.trim(); try { if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes }); else await api.post('/api/glossary', { term, soundalikes }); toast(`„${term}" gespeichert ✓`); setEdit(null); load(); } catch (e) { toast(e.message, true); } }; } // ---------- Recordings ---------- // Analysis-duration counter. Shows the total wall time of a processing run // (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at), // frozen to analysis_ms once done. Returns the inner only; callers add the ⏱. function anaTimeSpan(r) { if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`; if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing')) return `${fmt(Date.now() - r.analysis_started_at)}`; return ''; } function ensureAnaTicker() { if (state.anaTicker) return; state.anaTicker = setInterval(() => { document.querySelectorAll('.ana-time.live').forEach(el => { const t0 = +el.dataset.anaStart; if (t0) el.textContent = fmt(Date.now() - t0); }); }, 1000); } // Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the // selected recording's detail (with its own audio player). Crucially, the // while-analysing auto-refresh only rebuilds the LEFT list — it never touches // the RIGHT detail pane, so you can keep listening to an old recording while a // new one is being transcribed/analysed (the old bug: the whole view — detail + // audio element — was re-rendered every 5s, stopping playback). function viewRecordings() { const v = document.querySelector('#view'); v.innerHTML = `
← Aufnahme wählen
`; refreshRecList(); if (state.selectedId != null) openRecording(state.selectedId); } // (Re)build ONLY the left list pane. Called on entry and by the auto-refresh // timer; safe to run while the right pane holds a playing recording. async function refreshRecList() { const listEl = document.querySelector('#reclist'); if (!listEl) return; // left the tab let recs; try { recs = await api.get('/api/recordings'); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; } // Reschedule the poll ONLY while something is still processing. const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing'); const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); }; try { if (!recs.length) { listEl.innerHTML = '
Noch keine Aufnahmen.
'; state.lastListSig = 'empty'; scheduleNext(); return; } // Signature of everything the list RENDERS except the live ⏱ seconds (those // tick via ensureAnaTicker without a rebuild). If nothing meaningful changed, // DON'T touch the DOM at all — no flicker, no scroll jump, selection kept. // This is the real fix for "I get pulled away from my recording every 5s": // the 5s poll now no-ops unless a status/title/todo actually changed. const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}`).join('~'); if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; } state.lastListSig = sig; // Preserve the left pane's scroll position across the rebuild. const scroller = listEl.closest('.rec-list-pane') || listEl; const savedTop = scroller.scrollTop; // Group by calendar day (recs come newest-first). Each day is a collapsible // section; the day header shows the recording count. const groups = []; const byDay = {}; for (const r of recs) { const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }); if (!byDay[day]) { byDay[day] = []; groups.push(day); } byDay[day].push(r); } listEl.innerHTML = groups.map(day => { const collapsed = state.collapsedDates.has(day); const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''}
`; }).join(''); return `
▾ ${escapeHtml(day)} ${byDay[day].length}
${items}
`; }).join(''); // Click a row (not the trash button) → open in the right pane. listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => { if (e.target.closest('[data-del]')) return; openRecording(+it.dataset.open); }); listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => { e.stopPropagation(); const rec = recs.find(r => r.id == b.dataset.del); const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del; if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return; await api.del('/api/recordings/' + b.dataset.del); if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '
← Aufnahme wählen
'; } state.lastListSig = null; refreshRecList(); }); 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); h.closest('.day-group').classList.toggle('collapsed'); }); scroller.scrollTop = savedTop; // keep the list where the user had it ensureAnaTicker(); scheduleNext(); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } async function openRecording(id) { state.selectedId = id; const detail = document.querySelector('#detail'); if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); } // Highlight the selected row without rebuilding the list (keeps it stable). document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id)); detail.innerHTML = '
Lade…
'; const r = await api.get('/api/recordings/' + id); const pending = r.analysis_status === 'pending'; const errored = r.analysis_status === 'error'; const transcribing = r.status === 'transcribing'; const transcribeFailed = r.status === 'error'; const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3 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. // NOT modules: the transcript (own sticky right column) and the speaker-lane // visualizer (fixed full-width strip under the player, spanning both columns). const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', speakers: 'Sprecher' }; const vizHtml = spkOrder.length ? `
${spkOrder.map((sp, i) => `
${escapeHtml(spkLabel(sp, i))}
${(segsBySpk[sp] || []).map(s => ``).join('')}
`).join('')}
▶ Abspielen — hier erscheint, was gerade gesprochen wird.
` : ''; const mods = { analyse: ` ${r.summary ? `

${escapeHtml(r.summary)}

` : '
Noch keine Zusammenfassung.
'} ${(r.participants && r.participants.length) ? `

Teilnehmer

` : ''}`, todos: (r.todos && r.todos.length) ? `` : '
Keine ToDos erkannt.
', speakers: spkOrder.length ? `
Namen vergeben → App lernt die Stimme.
${spkOrder.map((sp, i) => `
`).join('')}
` : '', }; const defOrder = ['analyse', 'todos', 'speakers']; 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 = `

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

${vizHtml}
${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''}
${order.filter(m => mods[m]).map(m => `
⠿

${MOD_TITLES[m]}

${mods[m]}
`).join('')}

Transkript

${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''}
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
${renderTurns(r.words, names, scls, spkOrder)}
`; // 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 // consolidated, so no more "14 speakers" for a 2-person call), a number pins // the count. Cancel aborts. const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', ''); if (ans === null) return; const numSpeakers = parseInt(ans, 10); const body = numSpeakers > 0 ? { numSpeakers } : {}; reBtn.disabled = true; reBtn.textContent = 'trennt neu…'; try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); } catch (e) { toast(e.message, true); } }; const rtBtn = detail.querySelector('#retranscribe'); if (rtBtn) rtBtn.onclick = async () => { rtBtn.disabled = true; rtBtn.textContent = 'startet…'; try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); } catch (e) { toast(e.message, true); rtBtn.disabled = false; } }; ensureAnaTicker(); // Poll while transcription OR the NPU analysis is still running. The old // version re-ran openRecording(id) blindly every 5s — a stale closure: after // switching to ANOTHER recording the timer still fired and yanked the user // (and the playing audio) back to the analysing one. Now: stop when the // selection changed, poll ONLY the status while busy (no DOM rebuild), and // rebuild once when the results are in — restoring the player position. if (pending || transcribing) { const pollDetail = async () => { if (state.selectedId !== id || state.tab !== 'recordings') return; // user moved on if (!document.querySelector('#detail')) return; // view gone let cur; try { cur = await api.get('/api/recordings/' + id); } catch { return; } if (state.selectedId !== id || state.tab !== 'recordings') return; // re-check after await const busy = cur.analysis_status === 'pending' || cur.status === 'transcribing'; if (busy) { setTimeout(pollDetail, 5000); return; } // Results arrived → one rebuild; keep the audio where the user had it. const a = document.querySelector('#detail #player'); const keep = a && !a.paused ? { t: a.currentTime, play: true } : (a ? { t: a.currentTime, play: false } : null); await openRecording(id); if (keep && keep.t > 0) { const na = document.querySelector('#detail #player'); if (na) { na.currentTime = keep.t; if (keep.play) na.play().catch(() => {}); } } }; setTimeout(pollDetail, 5000); } const audio = detail.querySelector('#player'); 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 // pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was // spoken. Compensate with a fixed lead offset and highlight the LAST word whose // 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 = `
${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))} ${escapeHtml(tu.text)}
`; } } }; 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)); // Follow the active word INSIDE the transcript panel (it scrolls itself now — // scrollIntoView would also yank the page). if (active) { const panel = detail.querySelector('#transcript-panel'); if (panel) { const pr = panel.getBoundingClientRect(), ar = active.getBoundingClientRect(); if (ar.top < pr.top + 40 || ar.bottom > pr.bottom - 40) panel.scrollTo({ top: panel.scrollTop + (ar.top - pr.top) - panel.clientHeight / 2, behavior: 'smooth' }); } } updateViz(t); }; // Rename a speaker + (optionally) learn their voice into the DB. detail.querySelectorAll('.spk-save').forEach(btn => { btn.onclick = async () => { const row = btn.closest('.spk-row'); const name = row.querySelector('.spk-name').value.trim(); const enroll = row.querySelector('.spk-learn').checked; btn.disabled = true; btn.textContent = '…'; try { const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: btn.dataset.sp, name, enroll }); toast(res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert')); openRecording(id); } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; } }; }); // Spacebar toggles play/pause of the open recording (unless typing in a field). if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler); state.spaceHandler = (e) => { if (e.code !== 'Space' && e.key !== ' ') return; const t = e.target, tag = (t.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return; const a = document.querySelector('#player'); if (!a) return; e.preventDefault(); if (a.paused) a.play(); else a.pause(); }; document.addEventListener('keydown', state.spaceHandler); // Two-pane layout: scroll the detail pane to its top. On a narrow (stacked) // screen also bring the detail into view since it sits below the list. const pane = detail.closest('.rec-detail-pane'); if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' }); if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' }); } function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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']; for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } } return m; } // Render the raw transcript as speaker turns with a NAME header per turn. Each // word stays its own .w span (click-to-seek + active highlight still work). function renderTurns(words, names, scls, order) { if (!words || !words.length) return 'Kein Transkript.'; const idx = {}; order.forEach((s, i) => idx[s] = i); const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : ''); let html = '', cur = '\0', buf = []; const flush = () => { if (!buf.length) return; const nm = label(cur === '\0' ? null : cur); html += `
${nm ? `${escapeHtml(nm)} ` : ''}${buf.join(' ')}
`; buf = []; }; for (const w of words) { const sp = w.speaker || null; if (sp !== cur) { flush(); cur = sp; } buf.push(`${escapeHtml(w.word)}`); } flush(); return html; } // Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by // the LLM from context — not acoustic). function renderDialog(text) { const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean); const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3']; return lines.map(l => { const m = l.match(/^([^:]{1,30}):\s*(.*)$/); if (!m) return `
${escapeHtml(l)}
`; const spk = m[1].trim(); if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; } return `
${escapeHtml(spk)} ${escapeHtml(m[2])}
`; }).join(''); } function analysisBadge(st) { if (st === 'pending') return '🧠 Analyse…'; if (st === 'error') return 'KI-Fehler'; if (st === 'done') return 'KI ✓'; return ''; } render();