');
}
// ---------- 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
Personen
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
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); }
};
}
// ---------- Search ----------
// Two-stage on purpose (see search.js): the hit list is SQL and lands in
// milliseconds, the written answer is a ~30-90 s LLM call that streams in
// afterwards. Never make the user wait on the slow half to see the fast half.
const SEARCH_EXAMPLES = [
'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
'Was wurde über die Preiserhöhung besprochen?',
'Welche Aufgaben hat Markus im Juli übernommen?',
];
function viewSearch() {
const v = document.querySelector('#view');
const hasResults = !!state.searchRes;
v.innerHTML = `
';
state.searchBusy = false;
return;
}
state.searchBusy = false;
renderSearchResults();
// Only bother the NPU when there is something to summarise.
if (state.searchRes.hits.length) askSearchAnswer(q);
}
// The written answer. Fired after the hits are already on screen; the box shows
// a live elapsed counter because on this hardware it genuinely takes a while.
async function askSearchAnswer(q) {
const box = document.querySelector('#answerbox');
if (!box) return;
const t0 = Date.now();
const tick = setInterval(() => {
const el = box.querySelector('.answer-elapsed');
if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
}, 1000);
try {
const a = await api.post('/api/search/answer', { q });
// A late answer to a query the user has already replaced must not overwrite
// the newer result.
if (state.searchQ !== q) return;
state.searchAnswer = a;
renderAnswer();
} catch (e) {
if (state.searchQ !== q) return;
box.innerHTML = `
Antwort
${escapeHtml(e.message)}
`;
} finally { clearInterval(tick); }
}
// Highlight the query's content words inside a snippet, the way a search engine
// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
function highlight(text, terms) {
let html = escapeHtml(text);
for (const t of (terms || [])) {
if (t.length < 3) continue;
const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1');
}
return html;
}
function renderSearchResults() {
const out = document.querySelector('#searchout');
if (!out || !state.searchRes) return;
const { hits, terms, range, semantic, total } = state.searchRes;
if (!hits.length) {
out.innerHTML = `
Keine Treffer für „${escapeHtml(state.searchQ)}".
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
Andere Formulierung oder weniger Wörter versuchen.
` : ''}`;
box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t));
}
// Jump from a hit into the recording, positioned at the spoken minute. The
// detail pane lives in the recordings tab, so switch there first and wait for
// the player element to exist before seeking.
async function openFromSearch(recId, tMs) {
state.tab = 'recordings';
state.selectedId = recId;
state.trashOpen = false;
render();
for (let i = 0; i < 40; i++) {
const p = document.querySelector('#player');
if (p) {
const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} };
if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true });
return;
}
await new Promise(r => setTimeout(r, 100));
}
}
// ---------- Modal ----------
// Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt
// als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen;
// gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler).
function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) {
const back = document.createElement('div');
back.className = 'modal-back';
back.innerHTML = `
${title}
${bodyHtml}
${okText ? `` : ''}
`;
document.body.appendChild(back);
const root = back.querySelector('.modal');
const close = () => { document.removeEventListener('keydown', onKey); back.remove(); };
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
back.onclick = (e) => { if (e.target === back) close(); };
back.querySelector('[data-cancel]').onclick = close;
const ok = back.querySelector('[data-ok]');
if (ok && onOk) ok.onclick = async () => {
ok.disabled = true;
try { if (await onOk(root, close) !== false) close(); }
catch (e) { toast(e.message, true); }
finally { ok.disabled = false; }
};
if (onReady) onReady(root, close);
return { root, close };
}
// ---------- Projektansicht ----------
// Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante
// Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin
// ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in
// mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht.
function viewProjects() {
const v = document.querySelector('#view');
if (state.projectId == null) return renderProjectList(v);
return renderProjectDetail(v);
}
async function renderProjectList(v) {
v.innerHTML = `
Projekte
Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
';
let p;
try { p = await api.get('/api/projects/' + state.projectId); }
catch (e) { v.innerHTML = '
' + e.message + '
'; return; }
if (!p) { state.projectId = null; return viewProjects(); }
// Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die
// Ordner-Ebene, statt eine leere Geisterebene anzuzeigen.
if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0;
const cur = state.projectTabId;
const pins = p.pins.filter(x => x.tab_id === cur);
// Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner".
const rootName = p.root_tab_name || 'Ordner';
const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName;
v.innerHTML = `
` : '';
const m = openModal(`„${escapeHtml(title)}" anheften`, `
${pinsHtml()}
`, {
okText: '📌 Anheften',
onReady: (root) => {
const pp = root.querySelector('#pp'), pt = root.querySelector('#pt');
// Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen.
const loadTabs = async () => {
if (pp.value === 'new') { pt.innerHTML = ''; return; }
const p = await api.get('/api/projects/' + pp.value);
pt.innerHTML = `` +
p.tabs.map(t => ``).join('');
};
pp.onchange = async () => {
if (pp.value === 'new') {
const name = prompt('Name des neuen Projektordners:', '');
if (name && name.trim()) {
const np = await api.post('/api/projects', { name: name.trim() });
pp.insertAdjacentHTML('afterbegin', ``);
pp.value = String(np.id);
} else { pp.value = projects.length ? String(projects[0].id) : 'new'; }
}
loadTabs();
};
bindUnpin(root);
if (projects.length) loadTabs();
},
onOk: async (root) => {
const pid = root.querySelector('#pp').value;
if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; }
await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value });
toast('Angeheftet ✓');
if (done) done();
}
});
function bindUnpin(root) {
root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => {
await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`);
pins = pins.filter(p => p.pin_id != b.dataset.unpin);
root.querySelector('#pcur').innerHTML = pinsHtml();
bindUnpin(root);
toast('Anheftung gelöst');
if (done) done();
});
}
return m;
}
// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der
// Suche, nur ohne Zeitsprung).
function jumpToRecording(id) {
state.tab = 'recordings';
state.selectedId = id;
state.trashOpen = false;
render();
}
// ---------- 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');
// Entering the tab: show only the newest day expanded, the rest collapsed
// (applied in refreshRecList once the day groups are known).
state.resetCollapse = true;
v.innerHTML = `
← Aufnahme wählen
`;
v.querySelector('#trashtoggle').onclick = () => {
state.trashOpen = !state.trashOpen;
state.lastListSig = null;
v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
refreshRecList();
};
// Draggable splitter: list width between LIST_MIN (the old fixed width) and
// twice that; persisted for all sessions.
const LIST_MIN = 340, LIST_MAX = 680;
const listPane = v.querySelector('.rec-list-pane');
const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; };
applyW(+localStorage.getItem('aime-list-width') || LIST_MIN);
v.querySelector('.rec-splitter').onmousedown = (e) => {
e.preventDefault();
const startX = e.clientX, startW = listPane.getBoundingClientRect().width;
document.body.classList.add('resizing');
const move = (ev) => applyW(startW + (ev.clientX - startX));
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.body.classList.remove('resizing');
localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width));
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
};
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
if (state.trashOpen) return refreshTrashList(listEl);
let recs, pinCounts = {};
try { recs = await api.get('/api/recordings'); }
catch (e) { listEl.innerHTML = '
' + e.message + '
'; return; }
// Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze
// Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert.
try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; }
// 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 && !state.recording) { 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}|${pinCounts[r.id] || 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);
}
// Fresh tab entry: only the newest day (first group, recs are newest-first)
// stays open; manual toggles afterwards are respected until the next entry.
if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; }
// Live placeholder while a recording runs: its own card at the very top.
const liveHtml = state.recording ? `
`;
}).join('');
// Click a row (not one of its buttons) → open in the right pane.
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return;
openRecording(+it.dataset.open);
});
listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.pin);
pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); });
});
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.ren);
await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
});
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} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) 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
'; }
toast('In den Papierkorb verschoben');
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);
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 + '
'; }
}
// Rename a recording's title. An empty answer clears the title and hands
// naming back to the LLM on the next analysis (see db.renameRecording).
async function renameRecording(id, current) {
const t = prompt('Titel der Aufnahme:', current || '');
if (t === null) return false; // cancelled
try {
const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
state.lastListSig = null;
refreshRecList();
// Keep the open detail pane in sync without rebuilding it (audio keeps playing).
const h = document.querySelector('#detail h2');
if (h && state.selectedId == id) {
const badge = h.querySelector('.badge');
h.textContent = r.title || 'Aufnahme #' + id;
if (badge) h.appendChild(badge);
}
toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
return true;
} catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
}
// Trash pane: same list slot, different contents. Deliberately flat (no day
// grouping) — the interesting order here is "when did I delete it", and the
// remaining days matter more than the recording date.
async function refreshTrashList(listEl) {
let data;
try { data = await api.get('/api/trash'); }
catch (e) { listEl.innerHTML = '
`).join('');
listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
await api.post(`/api/trash/${b.dataset.restore}/restore`, {});
toast('Wiederhergestellt');
refreshTrashList(listEl);
});
listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => {
const rec = items.find(r => r.id == b.dataset.purge);
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge;
if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return;
await api.del('/api/trash/' + b.dataset.purge);
toast('Endgültig gelöscht');
refreshTrashList(listEl);
});
}
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', transcript: 'Transkript' };
const vizHtml = spkOrder.length ? `
${spkOrder.map((sp, i) => `
${escapeHtml(spkLabel(sp, i))}
${(segsBySpk[sp] || []).map(s =>
``).join('')}
`).join('')}
` : '';
// 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: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' };
const markersHtml = `
Namensmarker (während der Aufnahme getippt)
${(r.markers || []).length ? `
${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 `
`;
// Drag & drop across BOTH columns: grab any module by its header, drop it in
// either column at any position; the layout is saved for all recordings.
const cols = [...detail.querySelectorAll('.mod-col')];
// ":empty" never matches because of template whitespace -> class-driven.
const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module')));
markEmpty();
let dragged = null;
detail.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 save = {};
for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod);
localStorage.setItem('aime-detail-layout', JSON.stringify(save));
markEmpty();
});
});
cols.forEach(col => col.addEventListener('dragover', (e) => {
if (!dragged) return;
e.preventDefault();
const others = [...col.querySelectorAll('.module:not(.dragging)')];
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
markEmpty();
}));
const renBtn = detail.querySelector('#renametitle');
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
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');
// ---- 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
// 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;
// ---- View switch: transcript <-> conference-table scene ----
const switchBtn = detail.querySelector('#viewswitch');
const tView = detail.querySelector('#table-view');
const trView = detail.querySelector('#transcript-view');
const sideTitle = detail.querySelector('#sidetitle');
const bubble = detail.querySelector('#tbubble');
const tIdle = detail.querySelector('#tidle');
const persons = [...detail.querySelectorAll('.tperson')];
let bubbleTurnIdx = -1;
const applyViewMode = () => {
const table = localStorage.getItem('aime-side-view') === 'table';
if (tView) tView.classList.toggle('hidden', !table);
if (trView) trView.classList.toggle('hidden', table);
if (sideTitle) sideTitle.textContent = table ? 'Am Tisch' : 'Transkript';
if (switchBtn) switchBtn.innerHTML = table ? ICON_SHEET : ICON_TABLE;
};
if (switchBtn) {
switchBtn.onclick = () => {
const now = localStorage.getItem('aime-side-view') === 'table' ? 'text' : 'table';
localStorage.setItem('aime-side-view', now);
applyViewMode();
};
applyViewMode();
}
// All eyes on the speaker: shift every person's pupils toward the active seat.
const lookAt = (activeSp) => {
const target = persons.find(p => p.dataset.sp === activeSp);
for (const p of persons) {
const eyes = p.querySelector('.teyes');
if (!eyes) continue;
if (!target || p === target) { eyes.style.transform = 'translate(0, 1px)'; continue; }
const dx = (+target.dataset.x) - (+p.dataset.x);
const dy = (+target.dataset.y) - (+p.dataset.y);
const len = Math.hypot(dx, dy) || 1;
eyes.style.transform = `translate(${(dx / len * 3.2).toFixed(1)}px, ${(dy / len * 2.4).toFixed(1)}px)`;
}
};
const updateTable = (t) => {
if (!tView || tView.classList.contains('hidden')) return;
let idx = -1;
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
if (idx < 0) return;
const tu = turns[idx];
if (idx !== bubbleTurnIdx) {
bubbleTurnIdx = idx;
const i = spkOrder.indexOf(tu.sp);
const seats = tableSeats(Math.min(persons.length, spkOrder.length));
const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0];
if (bubble && seat) {
// The bubble sits FIXED on the table; only its tail (the "Öffnung")
// wanders to point at whoever is speaking.
bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`;
bubble.style.left = '';
bubble.style.top = '';
// Tail x-position along the top/bottom edge = the speaker's seat mapped
// into bubble-local coordinates (bubble is centred, BUBBLE_W wide).
const ax = Math.max(8, Math.min(92, (seat.x - (50 - BUBBLE_W / 2)) / BUBBLE_W * 100));
bubble.style.setProperty('--ax', ax.toFixed(1) + '%');
bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0);
// The full turn as clickable word spans — highlighted + scrolled below.
bubble.querySelector('.tb-text').innerHTML =
tu.words.map(w => `${escapeHtml(w.w)}`).join(' ');
}
if (tIdle) tIdle.classList.add('hidden');
lookAt(tu.sp);
}
persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused));
// Word highlight + follow-scroll inside the bubble (like the transcript).
if (bubble && !bubble.classList.contains('hidden')) {
const scroller = bubble.querySelector('.tb-scroll');
const bws = bubble.querySelectorAll('.bw');
let act = null;
for (const s of bws) { if (+s.dataset.s <= t) act = s; else break; }
bws.forEach(s => s.classList.toggle('active', s === act));
if (act && scroller) {
const target = act.offsetTop - scroller.clientHeight / 2 + 10;
if (Math.abs(scroller.scrollTop - target) > 24) scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}
}
};
if (bubble) bubble.addEventListener('click', (e) => {
const s = e.target.closest('.bw');
if (s) { audio.currentTime = (+s.dataset.s) / 1000 + 0.001; audio.play().catch(() => {}); }
});
// 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 inside a LANE seeks; clicks on the label column open the editor.
const seek = (e) => {
if (!e.target.closest('.lane')) return;
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);
}
// Speaker editor popover: click a lane label -> rename + optionally learn the
// voice (replaces the old separate "Sprecher" module).
const pop = detail.querySelector('#spkpop');
if (pop && lanesEl) {
let popSp = null;
const popName = pop.querySelector('#spkpop-name');
lanesEl.querySelectorAll('.lane-label').forEach(l => l.onclick = (e) => {
e.stopPropagation();
const row = l.closest('.lane-row');
popSp = row.dataset.sp;
pop.style.top = (row.offsetTop + 30) + 'px';
pop.classList.remove('hidden');
const i = spkOrder.indexOf(popSp);
popName.value = names[popSp] || '';
popName.placeholder = spkLabel(popSp, i >= 0 ? i : 0);
popName.focus();
});
pop.querySelector('#spkpop-cancel').onclick = () => pop.classList.add('hidden');
pop.querySelector('#spkpop-save').onclick = async () => {
const name = popName.value.trim();
const enroll = pop.querySelector('#spkpop-learn').checked;
const btn = pop.querySelector('#spkpop-save');
btn.disabled = true; btn.textContent = '…';
try {
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll });
const base = res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert');
toast(res.relabeled ? base + ' · Name in Zusammenfassung übernommen' : base);
openRecording(id);
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
};
}
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'));
persons.forEach(p => p.classList.remove('talking'));
};
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 && (!trView || !trView.classList.contains('hidden'))) {
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);
updateTable(t);
};
// (Speaker renaming lives in the lane-label popover above.)
// 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])); }
// The section summary comes back as "Abschnittstitel:\nFließtext…" blocks (see
// llm.js analyzeLong). Flat, that reads as one wall of text, so the title lines
// get set in bold. Done at RENDER time on purpose, not stored in the database:
// it applies to every summary that already exists, without re-running a
// 20-minute analysis on 22 recordings.
function renderSummary(text) {
return escapeHtml(text || '').split('\n').map(line => {
const t = line.trim();
// A heading is a SHORT, STANDALONE line ending in a colon. Both conditions
// matter: prose like "…die Struktur der Projektberichterstattung: kurze
// Einordnung, aktueller Stand…" has the colon mid-line and keeps running,
// and excluding . ! ? stops a full sentence that happens to end in a colon.
return /^[^.!?]{3,90}:$/.test(t) ? `${t}` : line;
}).join('\n');
}
// ---- Konferenztisch-Ansicht ----
// Seat positions (person center, % of scene) per participant count, arranged
// around the rectangular table; each seat knows which bubble-arrow side points
// at it from the table-centre bubble.
const SEAT_LAYOUTS = {
1: [{ x: 50, y: 84, side: 'bottom' }],
2: [{ x: 50, y: 14, side: 'top' }, { x: 50, y: 84, side: 'bottom' }],
3: [{ x: 50, y: 13, side: 'top' }, { x: 26, y: 84, side: 'bottom' }, { x: 74, y: 84, side: 'bottom' }],
4: [{ x: 50, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }],
5: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 60, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 60, side: 'left' }],
6: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 68, y: 86, side: 'bottom' }, { x: 32, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }],
};
// Bubble width in scene-% (kept in sync with .tbubble in style.css) — needed to
// map a seat's x onto the bubble edge so the tail points AT the speaker.
const BUBBLE_W = 54;
function tableSeats(n) { return SEAT_LAYOUTS[Math.min(6, Math.max(1, n))] || SEAT_LAYOUTS[6]; }
// Toggle-button icons: three people around a table / a text sheet.
// Stecknadel als SVG statt 📌: ein Farb-Emoji bleibt immer rot, `currentColor`
// lässt sich dagegen einfärben — angeheftet = grün, sonst blass wie die
// übrigen Zeilenknöpfe.
const ICON_PIN = ``;
const ICON_TABLE = ``;
const ICON_SHEET = ``;
// The scene: rounded table, one person per speaker (their lane colour), a
// speech bubble ON the table whose arrow + border follow whoever talks.
function buildTableScene(spkOrder, scls, names, spkLabel) {
if (!spkOrder.length) return '
`;
}
// 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 for the table scene: speaker, joined text AND the single words
// (with start times) so the bubble can highlight word-by-word like the transcript.
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; last.words.push({ w: w.word, s: w.t_start_ms }); }
else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms, words: [{ w: w.word, s: w.t_start_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();