');
}
// ---------- 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.
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 => `
'; }
}
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 `
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();