From 2228c575224fb0ddead43549771247c38d1ae828 Mon Sep 17 00:00:00 2001 From: sascha Date: Fri, 7 Aug 2026 16:18:33 +0200 Subject: [PATCH] feat(persons): eine Person, mehrere Namen und mehrere Stimmproben Die Identitaet war der NAME. Derselbe Mensch, einmal "Sascha" und einmal "Sascha Wolf" benannt, existierte zweimal und konnte zwei Cluster benennen -- Aufnahme #101 (34 s, zwei Menschen) zeigte Michael/Sascha/Sprecher 3/ Sascha Wolf. Der Vornamens-Trick in diar.js hat das ueberdeckt, setzte aber auch "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241). Neu: persons + person_names (Name = PK, gehoert genau einer Person), voiceprints.person_id. Mehrere Proben pro Person (max. 8) statt eines laufenden Mittels; bewertet wird die beste Probe. Score UND Abstand rechnen zwischen Personen -- vorher rissen zwei Abdruecke desselben Menschen (0,928) die Abstandspruefung, womit die Zwangsbedingung faktisch aus war. diar.identityKey bildet Marker-Namen und Abdruecke auf p ab. GUI "Stimmen" zeigt Personen mit Namens-Chips: Namen hinzufuegen/entfernen, Personen zusammenfuehren, Proben verwerfen. Altbestand geleert (Dump in backup/), weil lab/vpcos.mjs neben echten Dubletten (Jens ~ Jens Kuppler = 1,000) auch kontaminierte Abdruecke zeigte: Johannes lag mit 0,912 an Sascha Wolf, aber 0,115 an Johannes Jeckel. Verifiziert an #101: Sascha Wolf als zweiter Name derselben Person -> Re-Diarisierung benennt nur noch Michael + Sascha statt drei Namen fuer zwei Menschen. GUI mit Playwright geprueft, keine JS-Fehler. --- app/db.js | 170 +++- app/diar.js | 57 +- app/recorder.js | 2 +- app/server.js | 1799 +++++++++++++++++++++------------------- app/spk.js | 31 +- app/web/src/main.js | 62 +- app/web/src/style.css | 1137 ++++++++++++------------- docs/04-diarization.md | 86 +- docs/08-changelog.md | 37 + 9 files changed, 1883 insertions(+), 1498 deletions(-) diff --git a/app/db.js b/app/db.js index 7b1d888..431befb 100644 --- a/app/db.js +++ b/app/db.js @@ -33,6 +33,30 @@ export async function ensureSchema() { // uses the rows of the model currently loaded. NULL = pre-2026-07-27 TitaNet. await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS model VARCHAR(64) NULL`); await pool.query(`UPDATE voiceprints SET model='nemo_titanet_small' WHERE model IS NULL`); + // Ein Mensch, mehrere Namen (seit 2026-08-07). Vorher war der NAME die Identität: + // "Sascha" und "Sascha Wolf" waren zwei Einträge derselben Stimme (Kosinus 0,928), + // konnten zwei verschiedene Cluster benennen und ließen die Diarisierung 4 + // Sprecher statt 2 zeigen. diar.js hat das mit einem Vornamens-Trick überdeckt — + // der aber "Markus Ufer" und "Markus Witsch" (Kosinus 0,241, zwei Menschen) + // fälschlich gleichsetzt. Identität ist deshalb jetzt explizit die PERSON. + await pool.query(`CREATE TABLE IF NOT EXISTS persons ( + id INT AUTO_INCREMENT PRIMARY KEY, + display_name VARCHAR(128) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`); + // Ein Name gehört zu genau EINER Person (PK auf name; die Standard-Kollation + // utf8mb4_general_ci macht das absichtlich case-insensitiv, "sascha" = "Sascha"). + await pool.query(`CREATE TABLE IF NOT EXISTS person_names ( + name VARCHAR(128) NOT NULL PRIMARY KEY, + person_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_person (person_id))`); + // Abdrücke gehören der Person, nicht dem Namen — und es dürfen MEHRERE pro + // Person sein (verschiedene Sessions/Headsets/Räume). Das ersetzt das alte + // laufende Mittel: ein Zentroid über sehr unterschiedliche Aufnahmesituationen + // verwischt gerade die Merkmale, auf die es ankommt (Spalte `n` bleibt für die + // Altdaten stehen). + await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS person_id INT NULL`); + await pool.query(`ALTER TABLE voiceprints ADD INDEX IF NOT EXISTS idx_person (person_id)`); // Domain glossary: known proper nouns / terms the ASR mis-hears (e.g. "Aime" // heard as "Amy"). Fed into the LLM correction step so soundalikes get mapped // to the correct spelling. `soundalikes` = optional comma-list of mis-hearings. @@ -248,40 +272,132 @@ export async function updateGlossary(id, term, soundalikes) { return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null; } -export async function addVoiceprint(name, vec, model) { - const [res] = await pool.execute( - 'INSERT INTO voiceprints (name, dim, vec, model) VALUES (?,?,?,?)', - [name, vec.length, JSON.stringify(vec), model || null]); - return res.insertId; +// --- Personen (Identität) und ihre Stimmabdrücke ------------------------------ +// Höchstzahl Abdrücke je Person und Modell. Mehrere Proben sind der Sinn der +// Sache (verschiedene Sessions), aber unbegrenzt wachsen darf es nicht: jede +// Probe kostet bei jeder Identifikation einen Kosinus. Älteste fallen raus. +const MAX_PRINTS_PER_PERSON = +(process.env.AIME_MAX_PRINTS || 8); + +// Name → Person. Unbekannter Name legt Person + Namenseintrag an, damit jeder +// Weg (Marker, Sprecher-Popup, Enrollment) dieselbe Identität trifft. +export async function resolvePerson(name) { + const n = String(name || '').trim(); + if (!n) return null; + const [rows] = await pool.execute( + `SELECT p.id, p.display_name FROM person_names pn JOIN persons p ON p.id = pn.person_id + WHERE pn.name = ? LIMIT 1`, [n]); + if (rows.length) return { id: rows[0].id, display_name: rows[0].display_name }; + const [res] = await pool.execute('INSERT INTO persons (display_name) VALUES (?)', [n]); + await pool.execute('INSERT INTO person_names (name, person_id) VALUES (?,?)', [n, res.insertId]); + return { id: res.insertId, display_name: n }; } -// Add a sample to a named voiceprint, keeping ONE row per name as a running -// centroid (mean of all samples). This is how the app "learns who is who": every -// time a speaker is named in a recording, their voice is folded into the centroid, -// so future recordings match them more reliably. New name -> fresh row. -export async function upsertVoiceprint(name, vec, model) { - const [rows] = await pool.query( - 'SELECT id, vec, n FROM voiceprints WHERE name=? AND (model=? OR (model IS NULL AND ? IS NULL)) ORDER BY id LIMIT 1', - [name, model || null, model || null]); - if (!rows.length) { - const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n, model) VALUES (?,?,?,1,?)', - [name, vec.length, JSON.stringify(vec), model || null]); - return res.insertId; +// Weiteren Namen an eine Person hängen ("Sascha" und "Sascha Wolf" = ein Mensch). +// Gehört der Name schon einer ANDEREN Person, werden die beiden zusammengeführt — +// das ist die Absicht des Nutzers, wenn er denselben Namen zweimal vergibt. +export async function addPersonName(personId, name) { + const n = String(name || '').trim(); + if (!n) throw new Error('Name fehlt'); + const [rows] = await pool.execute('SELECT person_id FROM person_names WHERE name=?', [n]); + if (rows.length) { + if (rows[0].person_id === +personId) return { merged: false }; + await mergePersons(personId, rows[0].person_id); + return { merged: true }; } - const old = rows[0]; - const on = old.n || 1; - const ov = JSON.parse(old.vec); - const mean = vec.map((x, i) => ((ov[i] || 0) * on + x) / (on + 1)); - await pool.execute('UPDATE voiceprints SET vec=?, n=?, dim=? WHERE id=?', - [JSON.stringify(mean), on + 1, mean.length, old.id]); - return old.id; + await pool.execute('INSERT INTO person_names (name, person_id) VALUES (?,?)', [n, personId]); + return { merged: false }; } + +// Der letzte Name einer Person darf nicht weg — sonst bleibt eine namenlose +// Identität mit Abdrücken zurück, die niemand mehr zuordnen kann. +export async function removePersonName(name) { + const n = String(name || '').trim(); + const [rows] = await pool.execute('SELECT person_id FROM person_names WHERE name=?', [n]); + if (!rows.length) return false; + const [[{ c }]] = await pool.execute('SELECT COUNT(*) AS c FROM person_names WHERE person_id=?', [rows[0].person_id]); + if (c <= 1) throw new Error('letzter Name einer Person — stattdessen Person löschen'); + await pool.execute('DELETE FROM person_names WHERE name=?', [n]); + // War es der Anzeigename, rückt der älteste verbliebene Name nach. + const [rest] = await pool.execute( + 'SELECT name FROM person_names WHERE person_id=? ORDER BY created_at LIMIT 1', [rows[0].person_id]); + if (rest.length) await pool.execute('UPDATE persons SET display_name=? WHERE id=? AND display_name=?', + [rest[0].name, rows[0].person_id, n]); + return true; +} + +export async function renamePerson(id, displayName) { + const n = String(displayName || '').trim(); + if (!n) throw new Error('Name fehlt'); + await addPersonName(id, n).catch(() => {}); + await pool.execute('UPDATE persons SET display_name=? WHERE id=?', [n, id]); +} + +// Namen und Abdrücke von `fromId` nach `intoId` umhängen, Quelle löschen. +export async function mergePersons(intoId, fromId) { + if (+intoId === +fromId) return; + await pool.execute('UPDATE person_names SET person_id=? WHERE person_id=?', [intoId, fromId]); + await pool.execute('UPDATE voiceprints SET person_id=? WHERE person_id=?', [intoId, fromId]); + await pool.execute('DELETE FROM persons WHERE id=?', [fromId]); + await prunePrints(intoId); +} + +export async function deletePerson(id) { + await pool.execute('DELETE FROM voiceprints WHERE person_id=?', [id]); + await pool.execute('DELETE FROM person_names WHERE person_id=?', [id]); + await pool.execute('DELETE FROM persons WHERE id=?', [id]); +} + +// Personen mit allen Namen und der Zahl ihrer Abdrücke (GUI: Stimmen-Tab). +export async function listPersons() { + const [ps] = await pool.query('SELECT id, display_name, created_at FROM persons ORDER BY display_name'); + const [ns] = await pool.query('SELECT person_id, name FROM person_names ORDER BY created_at'); + const [vs] = await pool.query( + 'SELECT person_id, COUNT(*) AS prints, MAX(created_at) AS last_print FROM voiceprints WHERE person_id IS NOT NULL GROUP BY person_id'); + const byId = new Map(ps.map(p => [p.id, { ...p, names: [], prints: 0, last_print: null }])); + for (const n of ns) byId.get(n.person_id)?.names.push(n.name); + for (const v of vs) { const p = byId.get(v.person_id); if (p) { p.prints = v.prints; p.last_print = v.last_print; } } + return [...byId.values()]; +} + +// Lowercase-Name → person_id. diar.js braucht das, um Live-Marker und Abdrücke +// auf DIESELBE Identität abzubilden (ein Marker "Sascha" und ein Abdruck von +// "Sascha Wolf" dürfen sich nicht als zwei Personen widersprechen). +export async function personNameMap() { + const [rows] = await pool.query('SELECT name, person_id FROM person_names'); + const map = {}; + for (const r of rows) map[r.name.toLowerCase()] = r.person_id; + return map; +} + +async function prunePrints(personId) { + const [rows] = await pool.execute( + 'SELECT id FROM voiceprints WHERE person_id=? ORDER BY created_at DESC, id DESC', [personId]); + const drop = rows.slice(MAX_PRINTS_PER_PERSON).map(r => r.id); + if (drop.length) await pool.query(`DELETE FROM voiceprints WHERE id IN (${drop.map(() => '?').join(',')})`, drop); +} + +// Eine neue Stimmprobe für die Person hinter `name` speichern (kein Mitteln mehr, +// siehe MAX_PRINTS_PER_PERSON). Legt die Person bei Bedarf an. +export async function learnVoice(name, vec, model) { + const person = await resolvePerson(name); + if (!person) throw new Error('Name fehlt'); + const [res] = await pool.execute( + 'INSERT INTO voiceprints (name, person_id, dim, vec, n, model) VALUES (?,?,?,?,1,?)', + [person.display_name, person.id, vec.length, JSON.stringify(vec), model || null]); + await prunePrints(person.id); + return { id: res.insertId, personId: person.id, name: person.display_name }; +} + // model given → only the prints of that embedding model (what identification -// must use); omitted → all rows (GUI listing). +// must use); omitted → all rows (GUI listing). `name` ist ab 2026-08-07 nur noch +// Anzeige — die Identität ist person_id (Fallback für Altzeilen ohne Person). export async function listVoiceprints(model) { + const sql = `SELECT v.id, v.person_id, v.dim, v.vec, v.model, v.created_at, + COALESCE(p.display_name, v.name) AS name + FROM voiceprints v LEFT JOIN persons p ON p.id = v.person_id`; const [rows] = model - ? await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints WHERE model=? ORDER BY name', [model]) - : await pool.query('SELECT id,name,dim,vec,model,created_at FROM voiceprints ORDER BY name'); + ? await pool.query(`${sql} WHERE v.model=? ORDER BY name, v.created_at`, [model]) + : await pool.query(`${sql} ORDER BY name, v.created_at`); return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) })); } export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); } diff --git a/app/diar.js b/app/diar.js index 6be671a..cd4670e 100644 --- a/app/diar.js +++ b/app/diar.js @@ -180,7 +180,7 @@ export async function diarize(wavPath, numSpeakers, opts = {}) { if (MODE === 'v2') { const turns = await sherpaTurns(wavPath, n, true); if (turns.length) { - try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || []); } + try { return await reclusterTurns(wavPath, turns, n, opts.markers || [], opts.voiceprints || [], opts.persons || {}); } catch (e) { console.log('diar: v2 re-clustering failed, falling back to legacy:', e.message); } } } @@ -345,9 +345,9 @@ function ahcAverage(X, threshold, kStop = 0, initGroups = null, names = null) { } // v2 entry: embed the windows, then cluster them. -async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = []) { +async function reclusterTurns(wavPath, turns, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) { const ok = await embedWindows(wavPath, turns); - return clusterWindows(turns, ok, numSpeakers, markers, voiceprints); + return clusterWindows(turns, ok, numSpeakers, markers, voiceprints, persons); } // Map each live marker to the turn the named person was speaking in: the turn @@ -362,7 +362,7 @@ const ANCHOR_MIN_SEC = 1.0; // too short to identify a voice from // likely the name belongs to somebody else who spoke in between). The vote // weight below is derived from it — a covering anchor must outweigh a 20-second // guess, otherwise one mistyped marker decides a whole cluster (#79). -function markerAnchors(turns, markers) { +function markerAnchors(turns, markers, persons) { const byName = new Map(); const usable = turns.map((t, i) => ({ i, ...t })).filter(t => t.end - t.start >= ANCHOR_MIN_SEC); for (const mk of markers || []) { @@ -378,15 +378,22 @@ function markerAnchors(turns, markers) { if (d < bd) { bd = d; pick = c.i; } } if (pick < 0) continue; - const key = normName(mk.name); + const key = identityKey(mk.name, persons); (byName.get(key) || byName.set(key, []).get(key)).push({ ti: pick, d: bd }); } return byName; } -// Constraint key for a person. First name only, lowercased: the same human is -// enrolled/typed as both "Sascha" and "Sascha Wolf" (two voiceprint rows exist), -// and treating those as two people would forbid merging their clusters. -const normName = (s) => String(s || '').trim().toLowerCase().split(/\s+/)[0]; +// Zwangsbedingungs-Schlüssel = die IDENTITÄT hinter einem Namen, nicht der Name. +// Seit 2026-08-07 ist das die Person aus `person_names`: "Sascha" und "Sascha Wolf" +// sind derselbe Mensch, ihre Cluster dürfen also verschmelzen. Ohne Personen- +// Eintrag zählt der VOLLE Name — der alte Trick, nur den Vornamen zu nehmen, setzte +// "Markus Ufer" und "Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen). +const identityKey = (s, persons) => { + const n = String(s || '').trim().toLowerCase(); + if (!n) return ''; + const pid = persons && persons[n]; + return pid ? `p${pid}` : n; +}; // Anchor vote weight: 1.0 for a marker inside the turn, decaying to 0.2 at the // far end of the look-back window. const anchorWeight = (d) => d <= 0 ? 1 : Math.max(0.2, 1 - 0.8 * (d / ANCHOR_BACK_SEC)); @@ -445,7 +452,16 @@ export async function embedWindows(wavPath, turns) { // The heart of v2: window embeddings → clusters → per-turn speaker labels. // Split from the audio/embedding part so the clustering can be replayed offline // on cached embeddings (lab/lab-eval.mjs) — the tuning ran against THIS code. -export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = []) { +export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voiceprints = [], persons = {}) { + // Tags sind Identitäts-Schlüssel (siehe identityKey); fürs Log brauchen wir den + // Klarnamen dahinter. + const keyLabel = new Map(); + for (const mk of markers || []) { const k = identityKey(mk.name, persons); if (k && !keyLabel.has(k)) keyLabel.set(k, mk.name); } + for (const vp of voiceprints || []) { + const k = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons); + if (k && !keyLabel.has(k)) keyLabel.set(k, vp.name); + } + const disp = k => keyLabel.get(k) || k; // Channel compensation: L2 → subtract the recording mean → L2 again. let X = ok.map(w => l2(w.vec)); const mu = meanVec(X); @@ -505,7 +521,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr // Live markers → must-link (same name in one group) + cannot-link (different // names never merge). Applied to the folded stage-A clusters: the cluster a // marker's turn landed in carries that name. - const anchors = markerAnchors(turns, markers); + const anchors = markerAnchors(turns, markers, persons); let tags = null; if (anchors.size) { const { lab } = labelTurns(groups); @@ -529,7 +545,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr // Two different names anchored in ONE cluster means stage A already glued // two people together — the cannot-link can no longer separate them, so at // least say so instead of silently keeping the louder vote. - if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Namen → "${name}" gewinnt (Sprecher evtl. verschmolzen)`); + if (rival) console.log(`diar v2: WARNUNG Cluster ${g} trägt Marker mehrerer Personen → "${disp(name)}" gewinnt (Sprecher evtl. verschmolzen)`); } // must-link: groups carrying the SAME name become one — but only as far as // they sound alike (see MARK_LINK_REL). The strongest-vote group is the seed; @@ -550,7 +566,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr const c = dot(seedCent, centroidOf(groups[g])); if (c >= linkMin) { take.push(g); continue; } rejected++; - console.log(`diar v2: Marker "${name}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`); + console.log(`diar v2: Marker "${disp(name)}" auf Cluster ${g} verworfen (cos ${c.toFixed(3)} < ${linkMin.toFixed(3)}) — vermutlich falsch getippter Zeitpunkt`); } merged.push(take.flatMap(g => groups[g])); mtags.push(name); @@ -574,10 +590,19 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr groups.forEach((g, gi) => { if (tags[gi]) return; const c = rawCent(g); - let best = null, s1 = -Infinity, s2 = -Infinity; + // Pro PERSON ihr bester Abdruck, und der Abstand gilt zur nächstbesten + // PERSON. Vorher lief beides über einzelne Abdrücke: zwei Abdrücke desselben + // Menschen ("Sascha" 0,928 zu "Sascha Wolf") rissen die Abstandsprüfung, die + // Zwangsbedingung war damit faktisch aus. + const perId = new Map(); for (const vp of voiceprints) { + const key = vp.person_id != null ? `p${vp.person_id}` : identityKey(vp.name, persons); const s = spk.cosine(c, vp.vec); - if (s > s1) { s2 = s1; s1 = s; best = normName(vp.name); } + if (!perId.has(key) || s > perId.get(key)) perId.set(key, s); + } + let best = null, s1 = -Infinity, s2 = -Infinity; + for (const [key, s] of perId) { + if (s > s1) { s2 = s1; s1 = s; best = key; } else if (s > s2) s2 = s; } if (best && s1 >= VP_MIN && (s1 - s2) >= VP_MARGIN && !taken.has(best)) cand.push({ gi, name: best, s: s1 }); @@ -587,7 +612,7 @@ export function clusterWindows(turns, ok, numSpeakers = 0, markers = [], voicepr for (const c of cand) { if (taken.has(c.name) || tags[c.gi]) continue; tags[c.gi] = c.name; taken.add(c.name); vpTags++; - console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${c.name}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`); + console.log(`diar v2: Cluster ${c.gi} = bekannte Stimme "${disp(c.name)}" (cos ${c.s.toFixed(3)}) → Trennungs-Zwangsbedingung`); } if (!vpTags) console.log(`diar v2: keine Stimme sicher genug erkannt (${voiceprints.length} Abdrücke, Schwelle ${VP_MIN})`); } diff --git a/app/recorder.js b/app/recorder.js index 446c593..de87a09 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -445,7 +445,7 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) { await gate.waitIdle('diarize'); await setA76Max(A76_CAP); try { - const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [] }); + const segs = await diar.diarize(wavPath, numSpeakers, { markers: opts.markers || [], voiceprints: opts.voiceprints || [], persons: opts.persons || {} }); diar.assignSpeakers(words, segs); } catch (e) { console.error('diarize failed:', e.message); } try { fs.unlinkSync(`${outBase}.json`); } catch {} diff --git a/app/server.js b/app/server.js index 7879995..673f7b1 100644 --- a/app/server.js +++ b/app/server.js @@ -1,873 +1,926 @@ -import express from 'express'; -import path from 'node:path'; -import fs from 'node:fs'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { fileURLToPath } from 'node:url'; -import * as bt from './bt.js'; -import * as rec from './recorder.js'; -import * as db from './db.js'; -import * as llm from './llm.js'; -import * as diar from './diar.js'; -import * as spk from './spk.js'; -import * as search from './search.js'; -import * as emb from './emb.js'; -import * as gate from './gate.js'; -const pexec = promisify(execFile); - -// Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen -// wird. gate.js fragt sie hier ab, statt recorder.js zu importieren (das -// waere ein Zyklus, weil recorder.js llm.js importiert). -gate.setProbe(() => rec.isRecording()); - -// Wer sich wie freigeben lässt, wenn ein großes Modell RAM braucht (gate.freeRam). -// Hier verdrahtet, damit gate.js keines der Module importieren muss (Zyklen) — -// dasselbe Muster wie setProbe oben. Jeder Helfer startet faul neu. -gate.registerReleaser('rkllm', async () => { - if (!llm.status().running) return false; - await llm.stopServer(); - return true; -}); -gate.registerReleaser('cpu-transcriber', () => rec.stopTranscribeDaemon()); -gate.registerReleaser('text-embedder', () => emb.stop()); -gate.registerReleaser('spk-embedder', () => spk.stop()); - -// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH -// soundalikes get a deterministic swap; term-only entries are left to the LLM hint. -function glossaryMap(glossary) { - const m = {}; - for (const g of glossary || []) { - if (!g.term || !g.soundalikes) continue; - for (const s of String(g.soundalikes).split(',')) { - const k = s.trim().toLowerCase(); - if (k) m[k] = String(g.term).trim(); - } - } - return m; -} -// Whole-word replace (Unicode-aware, case-insensitive match): swaps any token -// equal to a known soundalike for its correct term, leaving punctuation intact. -function applyGlossaryText(text, m) { - if (!text || !m || !Object.keys(m).length) return text; - return String(text).replace(/[\p{L}\p{N}]+/gu, (tok) => m[tok.toLowerCase()] || tok); -} - -// Build the LLM input from stored words. If acoustic diarization tagged speakers, -// produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers -// instead of guessing; otherwise the plain transcript. -// Also returns `labels` = the exact label every speaker carries in the text the -// LLM sees ({speaker_07: "Sprecher 5"}); it is stored with the analysis so a -// rename afterwards can be patched into the finished summary (relabelAnalysis). -function buildBasis(rec, nameMap = {}) { - const words = rec.words || []; - const hasSpk = words.some(w => w.speaker); - if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} }; - const turns = diar.toTurns(words); - const labels = {}; let n = 0; - // Labels MUST be unique. On #79 a mis-anchored marker named Sascha's cluster - // "Markus" while the voiceprint named Markus' own cluster "Markus" too — the - // LLM then read two different people under one name and blended them into one - // person, and relabelAnalysis refuses to patch an ambiguous label afterwards. - const used = new Set(); - const uniq = (name) => { - if (!used.has(name)) { used.add(name); return name; } - for (let k = 2; ; k++) if (!used.has(`${name} ${k}`)) { used.add(`${name} ${k}`); return `${name} ${k}`; } - }; - const label = sp => labels[sp] || (labels[sp] = uniq(nameMap[sp] || `Sprecher ${++n}`)); - const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n'); - return { text, labeled: true, labels }; -} - -// Swap speaker labels for real names in the ALREADY FINISHED analysis text. -// Renaming a speaker after the analysis used to leave "Sprecher 5" all over the -// summary/todos; re-running the LLM costs ~20 min on this board, while the label -// each speaker had is known exactly (recordings.speaker_labels). Whole-word -// replacement in summary, todos, transcript_clean and participants; the stored -// label is then updated to the new name so a second rename works too. -async function relabelAnalysis(id) { - const rec = await db.getRecording(id); - if (!rec) return { changed: 0 }; - const names = rec.speaker_names || {}; - const labels = await db.getSpeakerLabels(id); - if (!Object.keys(names).length || !Object.keys(labels).length) return { changed: 0 }; - // A label that TWO speakers carried (the voice-ID once matched "Fabio" onto two - // clusters) is ambiguous in the text — leave those alone instead of renaming - // both occurrences to one person. - const labelCount = {}; - for (const l of Object.values(labels)) labelCount[l] = (labelCount[l] || 0) + 1; - const pairs = Object.entries(names) - .filter(([sp, name]) => name && labels[sp] && labels[sp] !== name && labelCount[labels[sp]] === 1) - .map(([sp, name]) => ({ sp, from: labels[sp], to: name })) - // longest label first: "Sprecher 1" must not eat "Sprecher 12" - .sort((a, b) => b.from.length - a.from.length); - const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const swap = (text) => { - if (!text) return text; - let out = String(text); - for (const p of pairs) out = out.replace(new RegExp(`(? swap(String(t))) : rec.todos; - // Participants are REBUILT (not string-swapped) from the diarized speakers in - // order of first appearance — a swap would leave the entries of speakers that - // were never mentioned in the text untouched and can duplicate names. - const spkOrder = []; - for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); - const participants = spkOrder.length - ? spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`) - : (Array.isArray(rec.participants) ? rec.participants.map(p => swap(String(p))) : rec.participants); - const partChanged = JSON.stringify(participants || null) !== JSON.stringify(rec.participants || null); - if (!pairs.length && !partChanged) return { changed: 0 }; - await db.pool.execute( - 'UPDATE recordings SET summary=?, todos=?, transcript_clean=?, participants=? WHERE id=?', - [swap(rec.summary), todos ? JSON.stringify(todos) : null, swap(rec.transcript_clean), - participants ? JSON.stringify(participants) : null, id]); - const next = { ...labels }; - for (const p of pairs) next[p.sp] = p.to; - await db.setSpeakerLabels(id, next); - console.log(`relabel #${id}: ${pairs.length ? pairs.map(p => `${p.from}→${p.to}`).join(', ') : 'nur Teilnehmerliste'}`); - return { changed: pairs.length || (partChanged ? 1 : 0) }; -} - -const REC_DIR2 = path.join(path.dirname(fileURLToPath(import.meta.url)), 'recordings'); -// Match each diarization cluster against enrolled voiceprints → { speaker: name }. -// Cuts the speaker's longest contiguous run from the kept wav, embeds it, cosine- -// matches. Returns only confident matches (others stay "Sprecher N"). -async function identifySpeakers(recData) { - const words = recData.words || []; - const speakers = [...new Set(words.filter(w => w.speaker).map(w => w.speaker))]; - if (!speakers.length || !recData.wav_file) return {}; - const wavPath = path.join(REC_DIR2, recData.wav_file); - if (!fs.existsSync(wavPath)) return {}; - const vps = await db.listVoiceprints(spk.modelId()); - if (!vps.length) return {}; - const best = {}; let cur = null; - for (const w of words) { - if (!cur || cur.speaker !== w.speaker) cur = { speaker: w.speaker, start: w.t_start_ms, end: w.t_end_ms }; - else cur.end = w.t_end_ms; - const len = cur.end - cur.start; - if (!best[cur.speaker] || len > best[cur.speaker].len) best[cur.speaker] = { start: cur.start, end: cur.end, len }; - } - // Cut at most ID_CUT_SEC: onnxruntime ABORTS the embedding helper on very long - // inputs (reproduced: 180 s -> "Non-zero status code returned while running - // Where"; 20/60 s fine). On #74 the longest run of a moderator was minutes - // long, which killed the helper on the first speaker and — through the then - // poisoned promise chain in spk.js — every speaker after it ("spk beendet"), - // i.e. voice auto-naming was dead for the whole recording. - const ID_CUT_SEC = 25; - const hits = []; - for (const sp of speakers) { - const r = best[sp]; if (!r) continue; - const seg = `/tmp/idseg_${recData.id}_${sp}.wav`; - const from = r.start / 1000; - const to = Math.min(r.end / 1000, from + ID_CUT_SEC); - try { - await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(from), '-to', String(to), '-ar', '16000', '-ac', '1', seg]); - const emb = await spk.embed(seg); - const m = spk.bestMatch(emb, vps); - if (m) hits.push({ speaker: sp, name: m.name, score: m.score }); - } catch (e) { console.error('identify', sp, e.message); } - finally { try { fs.unlinkSync(seg); } catch {} } - } - // One enrolled person can only BE one cluster: keep the best-scoring match per - // name (#74 handed "Fabio" to two clusters at once). - const bestByName = {}; - for (const h of hits) if (!bestByName[h.name] || h.score > bestByName[h.name].score) bestByName[h.name] = h; - const map = {}; - for (const h of Object.values(bestByName)) map[h.speaker] = h.name; - return map; -} - -// Fold a speaker's longest clean run into the named voiceprint centroid, so the -// next recording can auto-identify them. Shared by the manual rename popup and -// the live-marker resolution. Returns true if a sample was learned. -async function enrollSpeakerVoice(recData, speaker, name) { - if (!name || !recData.wav_file) return false; - const seg = longestRun(recData.words, speaker); - if (!seg || (seg.end - seg.start) < 1500) return false; - const wavPath = path.join(REC_DIR2, recData.wav_file); - if (!fs.existsSync(wavPath)) return false; - const cut = `/tmp/enr_${recData.id}_${speaker}.wav`; - try { - await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(seg.start / 1000), - '-to', String(Math.min(seg.end, seg.start + 20000) / 1000), '-ar', '16000', '-ac', '1', cut]); - const emb = await spk.embed(cut); - await db.upsertVoiceprint(name, emb, spk.modelId()); - return true; - } catch (e) { console.error('enroll failed:', e.message); return false; } - finally { try { fs.unlinkSync(cut); } catch {} } -} - -// Apply the names the user typed DURING the recording to the diarized clusters. -// Runs after every (re-)diarization, before the LLM analysis, so the summary -// already talks about real people. Two effects: -// 1. each resolved cluster gets its typed name (authoritative, manual-level), -// 2. if ONE name confidently lands on SEVERAL clusters, those clusters are the -// same person split by the diarizer → they are merged (user-supervised fix -// for the residual over-segmentation). -async function applyMarkers(id) { - const rec = await db.getRecording(id); - if (!rec || !(rec.markers || []).length || !(rec.words || []).some(w => w.speaker)) return null; - const resolved = diar.resolveMarkers(rec.words, rec.markers); - for (const m of resolved) await db.setMarkerSpeaker(m.id, m.speaker, m.conf).catch(() => {}); - - // name -> clusters. NAMING may follow a 'mid' hit, but the cluster MERGE below - // is destructive and unguarded (no acoustic check at this level), so only a - // 'high' hit — marker inside the turn, that turn clearly dominant — is allowed - // to fold two clusters together. #79 lost a speaker exactly this way one layer - // deeper, in the diarizer's must-link. - const byName = {}; - for (const m of resolved) { - if (!m.speaker || m.conf !== 'high') continue; - (byName[m.name] = byName[m.name] || new Set()).add(m.speaker); - } - const nameOnly = {}; - for (const m of resolved) { - if (!m.speaker || m.conf === 'low' || m.conf === null) continue; - (nameOnly[m.name] = nameOnly[m.name] || new Set()).add(m.speaker); - } - const talk = {}; - for (const w of rec.words) if (w.speaker) talk[w.speaker] = (talk[w.speaker] || 0) + (w.t_end_ms - w.t_start_ms); - - const fold = {}; // extra cluster -> primary cluster - const nameOf = {}; // cluster -> typed name - for (const [name, set] of Object.entries(byName)) { - const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0)); - const primary = cls[0]; - nameOf[primary] = name; - for (const c of cls.slice(1)) fold[c] = primary; - } - // Naming without merging: a 'mid' hit labels the cluster it landed on, as long - // as that cluster is not already claimed by somebody else. - for (const [name, set] of Object.entries(nameOnly)) { - if (byName[name]) continue; - const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0)); - if (cls[0] && !nameOf[cls[0]]) nameOf[cls[0]] = name; - } - let merged = 0; - if (Object.keys(fold).length) { - for (const w of rec.words) if (fold[w.speaker]) w.speaker = fold[w.speaker]; - await db.replaceWords(id, rec.words); - merged = Object.keys(fold).length; - console.log(`markers: merged ${merged} cluster(s) into marker-named speakers on #${id}`); - } - for (const [sp, name] of Object.entries(nameOf)) await db.setSpeakerName(id, sp, name).catch(() => {}); - // Learn the voices so the NEXT recording identifies them automatically. - const rec2 = await db.getRecording(id); - let learned = 0; - for (const [sp, name] of Object.entries(nameOf)) if (await enrollSpeakerVoice(rec2, sp, name)) learned++; - const named = Object.keys(nameOf).length; - await relabelAnalysis(id).catch(() => {}); - console.log(`markers on #${id}: ${resolved.length} marker(s) → ${named} named speaker(s), ${merged} merged, ${learned} voice(s) learned`); - return { resolved, named, merged, learned }; -} - -// Run LLM analysis for a recording and persist title/summary/todos/participants/ -// clean dialogue. Background (NPU ~30-90s) so record/stop stays fast. -// A76 clock cap for the whole run: rkllm burns ~1.7 CPU cores alongside the NPU -// and a long Gemma generation hit 95-97°C uncapped on the fanless board (#70, -// 16-min run) — same shutdown risk transcribeWav/rediarize already guard against. -async function analyzeRecording(id) { - // Free the resident rkllm (~5.9GB) BEFORE identifySpeakers: the TitaNet - // spk-embed helper OOM-dies next to it ("spk beendet" per cluster, #71 got - // 48 raw speakers). llm.analyze() below respawns it (~96s load, noise - // against a 15-min analysis). - await llm.stopServer(); - await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); - try { - const rec = await db.getRecording(id); - if (!rec) return; - const nameMap = await identifySpeakers(rec).catch(() => ({})); - // Seed the per-recording speaker names from acoustic identification (fills - // blanks only; manual edits stay). Manual names win for the LLM labelling. - await db.mergeSpeakerNames(id, nameMap).catch(() => {}); - // Stored names normally win over acoustic identification (they are typed by - // hand). Exception: a name that came from a NON-'high' marker is only a - // guess about WHICH cluster was speaking — resolveMarkers looks back up to - // 25 s, so in a fast four-way discussion it lands on the wrong voice (#79 - // put "Markus" on Sascha's cluster). If an enrolled voiceprint disagrees - // with such a weak marker name, the voiceprint wins. - const markers = await db.listMarkers(id).catch(() => []); - const weakName = new Map(); - for (const m of markers) if (m.speaker && m.conf !== 'high') weakName.set(m.speaker, m.name); - const names = { ...nameMap, ...(rec.speaker_names || {}) }; - for (const [sp, vname] of Object.entries(nameMap)) { - if (names[sp] !== vname && weakName.get(sp) === names[sp]) { - console.log(`identify #${id}: Stimmabdruck "${vname}" schlägt unsicheren Marker "${names[sp]}" auf ${sp}`); - names[sp] = vname; - await db.setSpeakerName(id, sp, vname).catch(() => {}); - } - } - const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled); - // Deterministic soundalike replacement: the 3B model is unreliable at obeying - // a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries - // WITH soundalikes are applied as a hard whole-word swap on the words FIRST — - // then the LLM sees the correct term and the turn view shows it too. Entries - // without soundalikes still rely on the (contextual) LLM glossary hint. - const gmap = glossaryMap(glossary); - if (Object.keys(gmap).length && Array.isArray(rec.words) && rec.words.length) { - let changed = false; - for (const w of rec.words) { const nt = applyGlossaryText(w.word, gmap); if (nt !== w.word) { w.word = nt; changed = true; } } - if (changed) { await db.replaceWords(id, rec.words); console.log(`glossary: applied soundalike swaps to #${id} words`); } - } - const { text, labeled, labels } = buildBasis(rec, names); - // Remember which label each speaker had in the LLM input, so renaming a - // speaker later can patch the finished text (relabelAnalysis). - await db.setSpeakerLabels(id, labels).catch(() => {}); - const a = await llm.analyze(text, { labeled, glossary }); - // Participants = the ACOUSTICALLY diarized speakers (named where known), - // not the LLM's list — the model returned every name MENTIONED in the - // conversation as a "Teilnehmer" (11 names on a 3-person call, #69). - // LLM participants stay only as fallback when there is no diarization. - const spkOrder = []; - for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); - // Same labels as in the dialogue the LLM read — otherwise the participant - // list numbers the speakers differently than the summary talks about them. - if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`); - // Belt-and-suspenders: also swap in the LLM outputs (cheap, catches any the - // model reintroduced). - for (const k of ['title', 'summary', 'transcriptClean']) if (a[k]) a[k] = applyGlossaryText(a[k], gmap); - a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap)); - a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap)); - await db.updateAnalysis(id, { ...a, status: 'done' }); - // rkllm ist ab hier fertig und wird freigegeben, BEVOR der Suchindex unten - // das e5-Modell (1,15 GB) startet: am 2026-08-07 traf genau diese Reihenfolge - // den OOM-Killer, der den residenten rkllm erschlug (13:27:09, dmesg). Vorher - // blieb rkllm nach einer Analyse für immer resident — das war der Zustand - // "nur noch 100 MB frei", in dem jeder weitere Helferstart russisches - // Roulette war. Preis: die nächste Analyse lädt das Modell neu (~96 s). - await llm.stopServer(); - // A speaker can be named WHILE the analysis runs (it takes ~15 min, and the - // marker/voiceprint passes themselves rename clusters). The finished text - // still carries the label from analysis start ("Sprecher 1"), so patch it - // once at the end — same mechanism as renaming a speaker by hand. - await relabelAnalysis(id).catch(() => {}); - // Rebuild the search index last: it needs the final speaker names, which - // relabelAnalysis may only just have settled. - await search.indexRecording(id).catch(e => console.error('search index:', e.message)); - console.log(`analysis done for #${id}: ${a.title}`); - } catch (e) { - console.error(`analysis failed for #${id}:`, e.message); - await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); - } finally { - await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {}); - } -} - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const app = express(); -app.use(express.json()); - -const REC_DIR = path.join(__dirname, 'recordings'); -app.use('/media', express.static(REC_DIR)); -app.use(express.static(path.join(__dirname, 'web/dist'))); - -const wrap = fn => (req, res) => fn(req, res).catch(e => res.status(500).json({ error: String(e && e.message || e) })); - -app.get('/api/health', (req, res) => res.json({ ok: true, ...rec.status() })); - -// --- Bluetooth --- -app.post('/api/bt/scan', wrap(async (req, res) => { - const secs = Math.min(+(req.body && req.body.seconds) || 10, 30); - res.json(await bt.scan(secs)); -})); -app.get('/api/bt/devices', wrap(async (req, res) => res.json(await bt.knownDevices()))); -app.post('/api/bt/pair', wrap(async (req, res) => res.json(await bt.pair(req.body.mac)))); -app.post('/api/bt/connect', wrap(async (req, res) => res.json(await bt.connect(req.body.mac)))); -app.post('/api/bt/repair', wrap(async (req, res) => res.json(await bt.repair(req.body.mac)))); -app.post('/api/bt/disconnect', wrap(async (req, res) => res.json(await bt.disconnect(req.body.mac)))); - -// --- Audio --- -app.get('/api/audio/sources', wrap(async (req, res) => res.json(await rec.listSources()))); -app.post('/api/audio/headset-mic', wrap(async (req, res) => res.json(await rec.enableHeadsetMic((req.body && req.body.codec) || 'auto')))); - -// --- Global NPU pipeline serializer --------------------------------------- -// Transcription (whisper-turbo ENCODER on the NPU) and LLM analysis (rkllm on -// the NPU) cannot run simultaneously: while the whisper encoder holds the NPU, -// rkllm_init fails with "failed to allocate handle, ret:-1, errno:14, Bad -// address", which surfaces as "rkllm READY timeout" and a failed/empty -// analysis. This bit when two recordings finished within seconds of each other -// (both fired transcribe+analyze fire-and-forget with no coordination). Every -// heavy per-recording job (transcribe / analyze / re-diarize) now runs through -// this single FIFO queue, so exactly one touches the NPU at a time; a second -// recording simply waits its turn instead of colliding. -let npuChain = Promise.resolve(); -let npuDepth = 0; -function withNpuLock(label, fn) { - npuDepth++; - const waiting = npuDepth - 1; - const run = npuChain.then(async () => { - if (waiting > 0) console.log(`npu-queue: ${label} start (${waiting} ahead done)`); - // Nie neben einer laufenden Aufnahme: rkllm haelt ~6,7 GB von 7,74 GB, - // eine zweite schwere Last traf am 2026-08-06 den globalen OOM-Killer - // und nahm den Dienst mit. Siehe gate.js. - await gate.waitIdle(label); - try { return await fn(); } - finally { npuDepth--; } - }); - npuChain = run.catch(() => {}); // keep the chain alive across a job's failure - return run; -} - -// --- Recording --- -app.post('/api/record/start', wrap(async (req, res) => res.json(await rec.startRecording(req.body || {})))); -// Live speaker markers: type the name of whoever is talking WHILE recording. -app.post('/api/record/marker', wrap(async (req, res) => res.json(rec.addMarker((req.body && req.body.name) || '')))); -app.get('/api/record/markers', (req, res) => res.json(rec.listMarkers())); -app.delete('/api/record/marker/:index', wrap(async (req, res) => res.json({ markers: rec.removeMarker(req.params.index) }))); -// Transcribe (thermal-guarded whisper + diarize) then analyze, in the background. -// The wav + DB row already exist, so a crash here loses nothing — the recording -// stays with status 'transcribing' and can be recovered via /retranscribe. -async function transcribeAndAnalyze(id, wavFile, numSpeakers) { - await db.markAnalysisStart(id).catch(() => {}); - try { - // Live markers constrain the diarization itself (same name = one speaker, - // different names = never merged), so they are loaded before transcribing. - const markers = await db.listMarkers(id).catch(() => []); - // Enrolled voices do the same for people who were never typed — the box - // owner above all (see diar.clusterWindows). - const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []); - const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers, voiceprints }); - await db.setTranscript(id, transcript, words); - console.log(`transcription done for #${id}: ${words.length} segments`); - // Names typed live → cluster names (+ user-supervised cluster merge) BEFORE - // the LLM sees the dialogue. - await applyMarkers(id).catch(e => console.error('applyMarkers:', e.message)); - } catch (e) { - console.error(`transcription failed for #${id}:`, e.message); - await db.setStatus(id, 'error').catch(() => {}); - // also mark analysis 'error' so the UI stops polling and shows a retry - await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); - return; // no transcript -> skip LLM analysis - } - await analyzeRecording(id); -} - -app.post('/api/record/stop', wrap(async (req, res) => { - // Persist wav + mp3 + DB row FIRST (crash-safe), respond immediately, then - // transcribe/analyze in the background. The frontend already polls while - // analysis_status === 'pending', so the transcript + analysis fill in live. - const r = await rec.stopAndSave(); - const id = await db.addRecording({ ...r, transcript: '', status: 'transcribing' }); - await db.addMarkers(id, r.markers).catch(e => console.error('addMarkers:', e.message)); - res.json({ id, title: r.title, mp3_file: r.mp3_file, duration_ms: r.duration_ms, - transcript: '', status: 'transcribing', analysis_status: 'pending', - markers: (r.markers || []).length }); - withNpuLock(`transcribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, r.numSpeakers)); -})); - -// --- Recordings --- -app.get('/api/recordings', wrap(async (req, res) => res.json(await db.listRecordings()))); -app.get('/api/recordings/:id', wrap(async (req, res) => { - const r = await db.getRecording(+req.params.id); - r ? res.json(r) : res.status(404).json({ error: 'not found' }); -})); -// Rename. An empty title clears it AND unlocks, so the next analysis may -// propose one again (see db.renameRecording / updateAnalysis). -app.patch('/api/recordings/:id/title', wrap(async (req, res) => { - const id = +req.params.id; - const row = await db.renameRecording(id, String((req.body && req.body.title) || '')); - if (!row) return res.status(404).json({ error: 'not found' }); - res.json({ ok: true, id, title: row.title, title_locked: !!row.title_locked }); -})); - -// --- Trash ------------------------------------------------------------------- -// Delete = move to the bin. Nothing on disk is touched until the retention -// window expires, so a restore brings back audio, transcript and analysis. -app.delete('/api/recordings/:id', wrap(async (req, res) => { - await db.deleteRecording(+req.params.id); - res.json({ ok: true, trashed: true, days: db.TRASH_DAYS }); -})); -// --- Projektansicht ----------------------------------------------------------- -// Ordner > Unterreiter > angepinnte Aufnahmen. Rein organisatorisch: ein Pin -// referenziert eine Aufnahme, er verschiebt oder kopiert nichts. Dieselbe -// Aufnahme darf in mehreren Projekten und Reitern hängen. -app.get('/api/projects', wrap(async (req, res) => res.json(await db.listProjects()))); -app.post('/api/projects', wrap(async (req, res) => { - const name = String((req.body && req.body.name) || '').trim(); - if (!name) return res.status(400).json({ error: 'name fehlt' }); - res.json(await db.createProject(name)); -})); -app.get('/api/projects/:id', wrap(async (req, res) => { - const p = await db.getProject(+req.params.id); - p ? res.json(p) : res.status(404).json({ error: 'not found' }); -})); -app.patch('/api/projects/:id', wrap(async (req, res) => { - const name = String((req.body && req.body.name) || '').trim(); - if (!name) return res.status(400).json({ error: 'name fehlt' }); - await db.renameProject(+req.params.id, name); - res.json({ ok: true, id: +req.params.id, name }); -})); -app.delete('/api/projects/:id', wrap(async (req, res) => { - await db.deleteProject(+req.params.id); - res.json({ ok: true }); -})); - -app.post('/api/projects/:id/tabs', wrap(async (req, res) => { - const name = String((req.body && req.body.name) || '').trim(); - if (!name) return res.status(400).json({ error: 'name fehlt' }); - res.json(await db.createProjectTab(+req.params.id, name)); -})); -// tid 0 = die Ordner-Ebene. Die ist keine Zeile in project_tabs (es gibt sie -// immer, sie lässt sich nicht löschen), ihre Beschriftung hängt am Projekt. -// Leerer Name setzt sie auf die Vorgabe „Ordner" zurück. -app.patch('/api/projects/:id/tabs/:tid', wrap(async (req, res) => { - const name = String((req.body && req.body.name) || '').trim(); - if (+req.params.tid === 0) { - const n = await db.renameProjectRoot(+req.params.id, name); - return res.json({ ok: true, id: 0, name: n }); - } - if (!name) return res.status(400).json({ error: 'name fehlt' }); - await db.renameProjectTab(+req.params.tid, name); - res.json({ ok: true, id: +req.params.tid, name }); -})); -app.delete('/api/projects/:id/tabs/:tid', wrap(async (req, res) => { - await db.deleteProjectTab(+req.params.tid); - res.json({ ok: true }); -})); - -// tab_id fehlt / 0 = direkt auf der Ordner-Ebene anpinnen. -app.post('/api/projects/:id/pins', wrap(async (req, res) => { - const recId = +((req.body && req.body.recording_id) || 0); - if (!recId) return res.status(400).json({ error: 'recording_id fehlt' }); - await db.pinRecording(+req.params.id, +((req.body && req.body.tab_id) || 0), recId); - res.json({ ok: true }); -})); -app.delete('/api/projects/:id/pins/:pinId', wrap(async (req, res) => { - await db.unpinRecording(+req.params.pinId); - res.json({ ok: true }); -})); -// Für den 📌-Button in der Aufnahmenliste: Zuordnungen einer Aufnahme bzw. -// die Pin-Zahl aller Aufnahmen auf einmal. -app.get('/api/recordings/:id/pins', wrap(async (req, res) => res.json(await db.listPinsForRecording(+req.params.id)))); -app.get('/api/pins/counts', wrap(async (req, res) => res.json(await db.pinCounts()))); - -app.get('/api/trash', wrap(async (req, res) => res.json({ days: db.TRASH_DAYS, items: await db.listTrash() }))); -app.post('/api/trash/:id/restore', wrap(async (req, res) => { - await db.restoreRecording(+req.params.id); - res.json({ ok: true }); -})); -// Irreversible: drops the row, its words/markers/passages and the audio files. -app.delete('/api/trash/:id', wrap(async (req, res) => { - res.json({ ok: true, purged: await purgeRecording(+req.params.id) }); -})); - -// Shared by the manual purge route and the retention sweep. -async function purgeRecording(id) { - const files = await db.purgeRecording(id); - let removed = 0; - for (const f of files) { - try { fs.unlinkSync(path.join(REC_DIR, f)); removed++; } - catch (e) { if (e.code !== 'ENOENT') console.error(`purge #${id}: ${f} — ${e.message}`); } - } - return { id, files: removed }; -} - -// Retention sweep: once at boot (the box is often off for days, so a timer -// alone would keep missing the window) and then daily. -async function sweepTrash() { - const ids = await db.expiredTrashIds().catch(() => []); - for (const id of ids) { - try { await purgeRecording(id); console.log(`trash: #${id} nach ${db.TRASH_DAYS} Tagen endgültig gelöscht`); } - catch (e) { console.error(`trash sweep #${id}:`, e.message); } - } -} - -// Verwaiste Audiodateien: alles in REC_DIR, auf das keine Zeile in `recordings` -// mehr zeigt (auch keine im Papierkorb). Sowas hinterließ das Löschen VOR dem -// Papierkorb-Feature — es entfernte nur die Datenbankzeile, die Dateien blieben -// für immer liegen (gefunden: 108 Dateien, 247 MB). Läuft zusammen mit dem -// Papierkorb-Sweep beim Start und danach täglich. -// Die Altersgrenze ist das Sicherheitsnetz: eine Aufnahme wird erst nach REC_DIR -// verschoben und dann in die Datenbank geschrieben, dazwischen liegt ein winziges -// Fenster, in dem eine gültige Datei noch verwaist aussieht. -const ORPHAN_MIN_AGE_MS = 2 * 3600 * 1000; - -async function sweepOrphans() { - let inUse; - try { inUse = await db.allAudioFiles(); } - catch (e) { return console.error('orphan sweep:', e.message); } - const keep = new Set(inUse); - let removed = 0, bytes = 0; - for (const f of fs.readdirSync(REC_DIR)) { - if (!/^rec_.*\.(mp3|wav)$/.test(f) || keep.has(f)) continue; - const p = path.join(REC_DIR, f); - try { - const st = fs.statSync(p); - if (Date.now() - st.mtimeMs < ORPHAN_MIN_AGE_MS) continue; - fs.unlinkSync(p); - removed++; bytes += st.size; - } catch (e) { if (e.code !== 'ENOENT') console.error(`orphan ${f}:`, e.message); } - } - if (removed) console.log(`orphans: ${removed} verwaiste Dateien gelöscht (${(bytes / 1048576).toFixed(0)} MB)`); -} - -// --- Search ------------------------------------------------------------------ -// Hits are pure SQL and come back immediately; the written answer is a separate -// call because the LLM needs ~30-90 s on this hardware (see search.js). -app.get('/api/search', wrap(async (req, res) => { - const q = String(req.query.q || '').trim(); - if (q.length < 2) return res.json({ hits: [], terms: [], range: null, semantic: false, total: 0 }); - res.json(await search.search(q, { limit: Math.min(+req.query.limit || 20, 50) })); -})); -// Runs through the NPU queue: rkllm and the whisper encoder cannot share the NPU. -app.post('/api/search/answer', wrap(async (req, res) => { - const q = String((req.body && req.body.q) || '').trim(); - if (!q) return res.status(400).json({ error: 'q fehlt' }); - const { hits } = await search.search(q, { limit: 20 }); - // stopServer() afterwards is NOT optional: rkllm stays resident with ~6GB - // once loaded, and the next speaker-embedding pass then OOM-dies next to it - // (analyzeRecording/rediarize free it for exactly that reason). A search - // answer left the model squatting 82% of RAM for hours. - try { - res.json(await withNpuLock('search-answer', () => search.answer(q, hits))); - } finally { - await llm.stopServer(); - } -})); -app.get('/api/search/status', wrap(async (req, res) => { - const [[{ n }]] = await db.pool.query('SELECT COUNT(*) AS n FROM passages'); - const [[{ v }]] = await db.pool.query('SELECT COUNT(*) AS v FROM passages WHERE vec IS NOT NULL'); - res.json({ passages: n, embedded: v, ...emb.status(), indexing: !!indexJob }); -})); -// Background reindex; `force` rebuilds everything, otherwise only recordings -// that have no passages yet (the normal case after this update). -let indexJob = null; -app.post('/api/search/reindex', wrap(async (req, res) => { - if (indexJob) return res.json({ ok: true, already: true }); - const force = !!(req.body && req.body.force); - indexJob = search.reindexAll({ force, onProgress: (d, t) => console.log(`search: reindex ${d}/${t}`) }) - .then(r => { console.log(`search: reindex fertig — ${r.indexed}/${r.total}`); return r; }) - .catch(e => { console.error('search: reindex:', e.message); }) - .finally(() => { indexJob = null; }); - res.json({ ok: true, started: true }); -})); - -// --- Voiceprints / enrollment --- -app.get('/api/voiceprints', wrap(async (req, res) => { - const vps = await db.listVoiceprints(); - const cur = spk.modelId(); - res.json(vps.map(v => ({ id: v.id, name: v.name, dim: v.dim, model: v.model, - stale: v.model !== cur, created_at: v.created_at }))); -})); -app.delete('/api/voiceprints/:id', wrap(async (req, res) => { await db.deleteVoiceprint(+req.params.id); res.json({ ok: true }); })); -app.post('/api/enroll/start', wrap(async (req, res) => res.json(await rec.startRecording({ source: req.body && req.body.source })))); -app.post('/api/enroll/stop', wrap(async (req, res) => { - const name = (req.body && req.body.name || '').trim(); - if (!name) return res.status(400).json({ error: 'Name fehlt' }); - const wav = await rec.stopRaw(); - try { - const emb = await spk.embed(wav); - const id = await db.addVoiceprint(name, emb, spk.modelId()); - res.json({ id, name, dim: emb.length }); - } finally { try { fs.unlinkSync(wav); } catch {} } -})); - -// --- Glossary (known proper nouns / terms; fed into LLM correction) --- -app.get('/api/glossary', wrap(async (req, res) => res.json(await db.listGlossary()))); -app.post('/api/glossary', wrap(async (req, res) => { - const term = (req.body && req.body.term || '').trim(); - const soundalikes = (req.body && req.body.soundalikes || '').trim(); - if (!term) return res.status(400).json({ error: 'Begriff fehlt' }); - res.json(await db.addGlossary(term, soundalikes)); -})); -app.delete('/api/glossary/:id', wrap(async (req, res) => { await db.deleteGlossary(+req.params.id); res.json({ ok: true }); })); -app.put('/api/glossary/:id', wrap(async (req, res) => { - const term = (req.body && req.body.term || '').trim(); - const soundalikes = (req.body && req.body.soundalikes || '').trim(); - if (!term) return res.status(400).json({ error: 'Begriff fehlt' }); - const g = await db.updateGlossary(+req.params.id, term, soundalikes); - if (!g) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); - res.json(g); -})); - -// --- LLM --- -app.get('/api/llm/status', (req, res) => res.json(llm.status())); -app.get('/api/spk/status', (req, res) => res.json(spk.status())); -// Re-diarize a recording's words with an (optional) fixed speaker count, then -// persist the new speaker labels. Text/timestamps are untouched. Needs the wav. -async function rediarize(id, numSpeakers) { - const r = await db.getRecording(id); - if (!r || !r.wav_file) throw new Error('wav für Re-Diarisierung fehlt'); - const wav = path.join(REC_DIR, r.wav_file); - if (!fs.existsSync(wav)) throw new Error('wav-Datei nicht gefunden: ' + r.wav_file); - const words = r.words || []; - // Free the resident rkllm first — diar's consolidation embeds every cluster - // via TitaNet, which OOM-dies next to a resident 5.9GB LLM (see - // analyzeRecording; on #71 that silently yielded 48 raw speakers). - // Abwarten: ein nicht abgewartetes stopServer() laesst proc/ready gesetzt, - // bis das exit-Event eintrifft — der naechste ask() schreibt dann in die - // tote Pipe (EPIPE, riss am 2026-08-06 den Dienst mit). - await llm.stopServer(); - // Diarization runs on the A76 cores; the board is fanless, so cap the clock - // for the whole run (transcribeWav does this for the record path — rediarize - // is the other entry point and must do it too, or a long re-diar overheats). - await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); - try { - // Enrolled voices go IN, not just out: they keep two known people (above all - // the box owner, who is enrolled and talks in every recording) from ending up - // in one cluster. See the cannot-link block in diar.clusterWindows. - const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []); - const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [], voiceprints }); - diar.assignSpeakers(words, segs); - await db.replaceWords(id, words); - // Re-diarization RENUMBERS the clusters, so every previously stored name - // ("speaker_02" = Fabio) would now point at a different voice. Drop them; the - // live markers and the voiceprint identification re-fill them right after. - await db.pool.query('UPDATE recordings SET speaker_names=NULL WHERE id=?', [id]).catch(() => {}); - console.log(`re-diarized #${id} (numSpeakers=${numSpeakers || 'auto'}) → ${new Set(segs.map(s => s.speaker)).size} speakers`); - } finally { - await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {}); - } -} - -// Re-run analysis (e.g. the model wasn't downloaded yet at record time). Optional -// body { numSpeakers } re-diarizes first (fixes over-segmentation on a known count). -app.post('/api/recordings/:id/reanalyze', wrap(async (req, res) => { - const id = +req.params.id; - const r = await db.getRecording(id); - if (!r) return res.status(404).json({ error: 'not found' }); - const numSpeakers = +(req.body && req.body.numSpeakers) || 0; - await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); - await db.markAnalysisStart(id).catch(() => {}); - res.json({ ok: true, status: 'pending', reDiarize: true }); - // Always re-diarize: numSpeakers>0 pins the count, 0 = auto (now consolidated, - // so auto no longer over-segments). This is the "fix my speakers" path. - withNpuLock(`reanalyze #${id}`, async () => { - try { await rediarize(id, numSpeakers); } - catch (e) { console.error(`re-diarize failed for #${id}:`, e.message); } - await applyMarkers(id).catch(e => console.error('applyMarkers:', e.message)); - await analyzeRecording(id); - }); -})); - -// --- markers of a stored recording ----------------------------------------- -app.get('/api/recordings/:id/markers', wrap(async (req, res) => res.json(await db.listMarkers(+req.params.id)))); -// Add a marker after the fact (e.g. while listening back) — same resolution path. -app.post('/api/recordings/:id/markers', wrap(async (req, res) => { - const name = String((req.body && req.body.name) || '').trim(); - const tMs = +(req.body && req.body.t_ms); - if (!name || !Number.isFinite(tMs)) return res.status(400).json({ error: 'name/t_ms fehlt' }); - await db.addMarker(+req.params.id, tMs, name); - res.json({ ok: true, markers: await db.listMarkers(+req.params.id) }); -})); -app.delete('/api/recordings/:id/markers/:mid', wrap(async (req, res) => { - await db.deleteMarker(+req.params.mid); - res.json({ ok: true, markers: await db.listMarkers(+req.params.id) }); -})); -// Re-resolve the markers against the current diarization (no LLM, no sherpa). -app.post('/api/recordings/:id/apply-markers', wrap(async (req, res) => { - const out = await applyMarkers(+req.params.id); - const r = await db.getRecording(+req.params.id); - res.json({ ok: true, ...(out || { named: 0, merged: 0, learned: 0 }), - speaker_names: (r && r.speaker_names) || {}, markers: (r && r.markers) || [] }); -})); - -// LLM-only re-analysis: re-run title/summary/todos on the EXISTING words + -// speaker labels, WITHOUT re-diarizing (fast — skips the ~40min sherpa pass when -// the speakers are already correct and only the LLM/prompts changed). -app.post('/api/recordings/:id/reanalyze-llm', wrap(async (req, res) => { - const id = +req.params.id; - const r = await db.getRecording(id); - if (!r) return res.status(404).json({ error: 'not found' }); - await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); - await db.markAnalysisStart(id).catch(() => {}); - res.json({ ok: true, status: 'pending', reDiarize: false }); - withNpuLock(`reanalyze-llm #${id}`, async () => { await analyzeRecording(id); }); -})); - -// Re-run transcription from the saved wav (recovery: a crash/thermal-shutdown -// during whisper leaves the row at status 'transcribing' with an empty -// transcript, or 'error'; the wav is on disk). Optional { numSpeakers }. -app.post('/api/recordings/:id/retranscribe', wrap(async (req, res) => { - const id = +req.params.id; - const r = await db.getRecording(id); - if (!r) return res.status(404).json({ error: 'not found' }); - if (!r.wav_file) return res.status(400).json({ error: 'kein wav für Re-Transkription' }); - const numSpeakers = +(req.body && req.body.numSpeakers) || 0; - await db.setStatus(id, 'transcribing'); - await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); - res.json({ ok: true, status: 'transcribing' }); - withNpuLock(`retranscribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, numSpeakers)); -})); - -// Longest contiguous run of one diarization cluster (best clean sample of a voice). -function longestRun(words, speaker) { - let best = null, cur = null; - for (const w of words || []) { - if (w.speaker !== speaker) { cur = null; continue; } - if (!cur) cur = { start: w.t_start_ms, end: w.t_end_ms }; - else cur.end = w.t_end_ms; - if (!best || (cur.end - cur.start) > (best.end - best.start)) best = { start: cur.start, end: cur.end }; - } - return best; -} - -// Name a diarized speaker in a recording and (default) LEARN their voice: fold -// the speaker's longest clean segment into the named voiceprint centroid, so -// future recordings auto-identify them. Empty name clears the label. -app.post('/api/recordings/:id/speaker', wrap(async (req, res) => { - const id = +req.params.id; - const speaker = String((req.body && req.body.speaker) || '').trim(); - const name = String((req.body && req.body.name) || '').trim(); - const enroll = !(req.body && req.body.enroll === false); - if (!speaker) return res.status(400).json({ error: 'speaker fehlt' }); - const r = await db.getRecording(id); - if (!r) return res.status(404).json({ error: 'not found' }); - await db.setSpeakerName(id, speaker, name); - // Patch the name into the already generated summary/todos/participants. - const relabel = await relabelAnalysis(id).catch(e => { console.error('relabel:', e.message); return { changed: 0 }; }); - - let enrolled = false, learnMsg = null; - if (enroll && name && r.wav_file) { - const seg = longestRun(r.words, speaker); - const wavPath = path.join(REC_DIR, r.wav_file); - if (!seg || (seg.end - seg.start) < 1500) { - learnMsg = 'Segment zu kurz zum Lernen (<1,5s)'; - } else if (!fs.existsSync(wavPath)) { - learnMsg = 'wav fehlt – nicht gelernt'; - } else { - const cut = `/tmp/enr_${id}_${speaker}.wav`; - try { - await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(seg.start / 1000), - '-to', String(seg.end / 1000), '-ar', '16000', '-ac', '1', cut]); - const emb = await spk.embed(cut); - await db.upsertVoiceprint(name, emb, spk.modelId()); - enrolled = true; - } catch (e) { console.error('enroll-from-recording failed:', e.message); learnMsg = 'Lernen fehlgeschlagen'; } - finally { try { fs.unlinkSync(cut); } catch {} } - } - } - const r2 = await db.getRecording(id); - // The passage rows carry the speaker NAME denormalised (so search can filter - // by person without a join) — a rename has to be written through. - search.indexRecording(id).catch(e => console.error('search index:', e.message)); - res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg, - relabeled: relabel.changed }); -})); - -app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname, 'web/dist/index.html'))); - -const PORT = process.env.PORT || 3000; -db.ensureSchema() - .catch(e => console.error('ensureSchema:', e.message)) - .then(() => db.failStaleJobs()) - .then(r => { if (r && (r.analyses || r.transcriptions)) console.log(`boot: ${r.analyses} abgebrochene Analysen, ${r.transcriptions} abgebrochene Transkriptionen als Fehler markiert`); }) - .catch(e => console.error('failStaleJobs:', e.message)) - .finally(() => app.listen(PORT, '0.0.0.0', () => { - console.log(`callrec listening on :${PORT}`); - // Retention sweep at boot + daily. unref() so it never holds the process open. - const sweep = () => { - sweepTrash() - .catch(e => console.error('trash sweep:', e.message)) - .then(() => sweepOrphans()); // erst leeren, dann die Reste wegräumen - }; - sweep(); - setInterval(sweep, 24 * 3600 * 1000).unref(); - // Backfill the search index for everything recorded before this update. - // Deliberately not under the NPU lock: it is CPU/SQL only and must not - // delay a transcription that is already queued. - search.reindexAll({ onProgress: (d, t) => { if (d === 1 || d === t) console.log(`search: backfill ${d}/${t}`); } }) - .then(r => { if (r.total) console.log(`search: backfill fertig — ${r.indexed}/${r.total} Aufnahmen`); }) - .catch(e => console.error('search backfill:', e.message)); - })); +import express from 'express'; +import path from 'node:path'; +import fs from 'node:fs'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import * as bt from './bt.js'; +import * as rec from './recorder.js'; +import * as db from './db.js'; +import * as llm from './llm.js'; +import * as diar from './diar.js'; +import * as spk from './spk.js'; +import * as search from './search.js'; +import * as emb from './emb.js'; +import * as gate from './gate.js'; +const pexec = promisify(execFile); + +// Der Recorder ist die einzige Wahrheit darueber, ob gerade aufgenommen +// wird. gate.js fragt sie hier ab, statt recorder.js zu importieren (das +// waere ein Zyklus, weil recorder.js llm.js importiert). +gate.setProbe(() => rec.isRecording()); + +// Wer sich wie freigeben lässt, wenn ein großes Modell RAM braucht (gate.freeRam). +// Hier verdrahtet, damit gate.js keines der Module importieren muss (Zyklen) — +// dasselbe Muster wie setProbe oben. Jeder Helfer startet faul neu. +gate.registerReleaser('rkllm', async () => { + if (!llm.status().running) return false; + await llm.stopServer(); + return true; +}); +gate.registerReleaser('cpu-transcriber', () => rec.stopTranscribeDaemon()); +gate.registerReleaser('text-embedder', () => emb.stop()); +gate.registerReleaser('spk-embedder', () => spk.stop()); + +// Glossary → { soundalike(lowercased): correctTerm }. Only entries WITH +// soundalikes get a deterministic swap; term-only entries are left to the LLM hint. +function glossaryMap(glossary) { + const m = {}; + for (const g of glossary || []) { + if (!g.term || !g.soundalikes) continue; + for (const s of String(g.soundalikes).split(',')) { + const k = s.trim().toLowerCase(); + if (k) m[k] = String(g.term).trim(); + } + } + return m; +} +// Whole-word replace (Unicode-aware, case-insensitive match): swaps any token +// equal to a known soundalike for its correct term, leaving punctuation intact. +function applyGlossaryText(text, m) { + if (!text || !m || !Object.keys(m).length) return text; + return String(text).replace(/[\p{L}\p{N}]+/gu, (tok) => m[tok.toLowerCase()] || tok); +} + +// Build the LLM input from stored words. If acoustic diarization tagged speakers, +// produce a labelled dialogue ("Sprecher 1: …") so the LLM keeps real speakers +// instead of guessing; otherwise the plain transcript. +// Also returns `labels` = the exact label every speaker carries in the text the +// LLM sees ({speaker_07: "Sprecher 5"}); it is stored with the analysis so a +// rename afterwards can be patched into the finished summary (relabelAnalysis). +function buildBasis(rec, nameMap = {}) { + const words = rec.words || []; + const hasSpk = words.some(w => w.speaker); + if (!hasSpk) return { text: rec.transcript || '', labeled: false, labels: {} }; + const turns = diar.toTurns(words); + const labels = {}; let n = 0; + // Labels MUST be unique. On #79 a mis-anchored marker named Sascha's cluster + // "Markus" while the voiceprint named Markus' own cluster "Markus" too — the + // LLM then read two different people under one name and blended them into one + // person, and relabelAnalysis refuses to patch an ambiguous label afterwards. + const used = new Set(); + const uniq = (name) => { + if (!used.has(name)) { used.add(name); return name; } + for (let k = 2; ; k++) if (!used.has(`${name} ${k}`)) { used.add(`${name} ${k}`); return `${name} ${k}`; } + }; + const label = sp => labels[sp] || (labels[sp] = uniq(nameMap[sp] || `Sprecher ${++n}`)); + const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n'); + return { text, labeled: true, labels }; +} + +// Swap speaker labels for real names in the ALREADY FINISHED analysis text. +// Renaming a speaker after the analysis used to leave "Sprecher 5" all over the +// summary/todos; re-running the LLM costs ~20 min on this board, while the label +// each speaker had is known exactly (recordings.speaker_labels). Whole-word +// replacement in summary, todos, transcript_clean and participants; the stored +// label is then updated to the new name so a second rename works too. +async function relabelAnalysis(id) { + const rec = await db.getRecording(id); + if (!rec) return { changed: 0 }; + const names = rec.speaker_names || {}; + const labels = await db.getSpeakerLabels(id); + if (!Object.keys(names).length || !Object.keys(labels).length) return { changed: 0 }; + // A label that TWO speakers carried (the voice-ID once matched "Fabio" onto two + // clusters) is ambiguous in the text — leave those alone instead of renaming + // both occurrences to one person. + const labelCount = {}; + for (const l of Object.values(labels)) labelCount[l] = (labelCount[l] || 0) + 1; + const pairs = Object.entries(names) + .filter(([sp, name]) => name && labels[sp] && labels[sp] !== name && labelCount[labels[sp]] === 1) + .map(([sp, name]) => ({ sp, from: labels[sp], to: name })) + // longest label first: "Sprecher 1" must not eat "Sprecher 12" + .sort((a, b) => b.from.length - a.from.length); + const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const swap = (text) => { + if (!text) return text; + let out = String(text); + for (const p of pairs) out = out.replace(new RegExp(`(? swap(String(t))) : rec.todos; + // Participants are REBUILT (not string-swapped) from the diarized speakers in + // order of first appearance — a swap would leave the entries of speakers that + // were never mentioned in the text untouched and can duplicate names. + const spkOrder = []; + for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); + const participants = spkOrder.length + ? spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`) + : (Array.isArray(rec.participants) ? rec.participants.map(p => swap(String(p))) : rec.participants); + const partChanged = JSON.stringify(participants || null) !== JSON.stringify(rec.participants || null); + if (!pairs.length && !partChanged) return { changed: 0 }; + await db.pool.execute( + 'UPDATE recordings SET summary=?, todos=?, transcript_clean=?, participants=? WHERE id=?', + [swap(rec.summary), todos ? JSON.stringify(todos) : null, swap(rec.transcript_clean), + participants ? JSON.stringify(participants) : null, id]); + const next = { ...labels }; + for (const p of pairs) next[p.sp] = p.to; + await db.setSpeakerLabels(id, next); + console.log(`relabel #${id}: ${pairs.length ? pairs.map(p => `${p.from}→${p.to}`).join(', ') : 'nur Teilnehmerliste'}`); + return { changed: pairs.length || (partChanged ? 1 : 0) }; +} + +const REC_DIR2 = path.join(path.dirname(fileURLToPath(import.meta.url)), 'recordings'); +// Match each diarization cluster against enrolled voiceprints → { speaker: name }. +// Cuts the speaker's longest contiguous run from the kept wav, embeds it, cosine- +// matches. Returns only confident matches (others stay "Sprecher N"). +async function identifySpeakers(recData) { + const words = recData.words || []; + const speakers = [...new Set(words.filter(w => w.speaker).map(w => w.speaker))]; + if (!speakers.length || !recData.wav_file) return {}; + const wavPath = path.join(REC_DIR2, recData.wav_file); + if (!fs.existsSync(wavPath)) return {}; + const vps = await db.listVoiceprints(spk.modelId()); + if (!vps.length) return {}; + const best = {}; let cur = null; + for (const w of words) { + if (!cur || cur.speaker !== w.speaker) cur = { speaker: w.speaker, start: w.t_start_ms, end: w.t_end_ms }; + else cur.end = w.t_end_ms; + const len = cur.end - cur.start; + if (!best[cur.speaker] || len > best[cur.speaker].len) best[cur.speaker] = { start: cur.start, end: cur.end, len }; + } + // Cut at most ID_CUT_SEC: onnxruntime ABORTS the embedding helper on very long + // inputs (reproduced: 180 s -> "Non-zero status code returned while running + // Where"; 20/60 s fine). On #74 the longest run of a moderator was minutes + // long, which killed the helper on the first speaker and — through the then + // poisoned promise chain in spk.js — every speaker after it ("spk beendet"), + // i.e. voice auto-naming was dead for the whole recording. + const ID_CUT_SEC = 25; + const hits = []; + for (const sp of speakers) { + const r = best[sp]; if (!r) continue; + const seg = `/tmp/idseg_${recData.id}_${sp}.wav`; + const from = r.start / 1000; + const to = Math.min(r.end / 1000, from + ID_CUT_SEC); + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(from), '-to', String(to), '-ar', '16000', '-ac', '1', seg]); + const emb = await spk.embed(seg); + const m = spk.bestMatch(emb, vps); + if (m) hits.push({ speaker: sp, name: m.name, score: m.score, key: m.key }); + } catch (e) { console.error('identify', sp, e.message); } + finally { try { fs.unlinkSync(seg); } catch {} } + } + // Ein Mensch kann nur EIN Cluster sein: bester Treffer je PERSON (#74 gab + // "Fabio" gleichzeitig an zwei Cluster). Schlüssel ist die Person, nicht der + // Name — sonst konnten "Sascha" und "Sascha Wolf" zwei Cluster benennen und die + // Aufnahme zeigte 4 Sprecher, wo 2 sprachen (2026-08-07). + const bestByPerson = {}; + for (const h of hits) { + const key = h.key || `n:${String(h.name).toLowerCase()}`; + if (!bestByPerson[key] || h.score > bestByPerson[key].score) bestByPerson[key] = h; + } + const map = {}; + for (const h of Object.values(bestByPerson)) map[h.speaker] = h.name; + return map; +} + +// Fold a speaker's longest clean run into the named voiceprint centroid, so the +// next recording can auto-identify them. Shared by the manual rename popup and +// the live-marker resolution. Returns true if a sample was learned. +async function enrollSpeakerVoice(recData, speaker, name) { + if (!name || !recData.wav_file) return false; + const seg = longestRun(recData.words, speaker); + if (!seg || (seg.end - seg.start) < 1500) return false; + const wavPath = path.join(REC_DIR2, recData.wav_file); + if (!fs.existsSync(wavPath)) return false; + const cut = `/tmp/enr_${recData.id}_${speaker}.wav`; + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(seg.start / 1000), + '-to', String(Math.min(seg.end, seg.start + 20000) / 1000), '-ar', '16000', '-ac', '1', cut]); + const emb = await spk.embed(cut); + await db.learnVoice(name, emb, spk.modelId()); + return true; + } catch (e) { console.error('enroll failed:', e.message); return false; } + finally { try { fs.unlinkSync(cut); } catch {} } +} + +// Apply the names the user typed DURING the recording to the diarized clusters. +// Runs after every (re-)diarization, before the LLM analysis, so the summary +// already talks about real people. Two effects: +// 1. each resolved cluster gets its typed name (authoritative, manual-level), +// 2. if ONE name confidently lands on SEVERAL clusters, those clusters are the +// same person split by the diarizer → they are merged (user-supervised fix +// for the residual over-segmentation). +async function applyMarkers(id) { + const rec = await db.getRecording(id); + if (!rec || !(rec.markers || []).length || !(rec.words || []).some(w => w.speaker)) return null; + const resolved = diar.resolveMarkers(rec.words, rec.markers); + for (const m of resolved) await db.setMarkerSpeaker(m.id, m.speaker, m.conf).catch(() => {}); + + // name -> clusters. NAMING may follow a 'mid' hit, but the cluster MERGE below + // is destructive and unguarded (no acoustic check at this level), so only a + // 'high' hit — marker inside the turn, that turn clearly dominant — is allowed + // to fold two clusters together. #79 lost a speaker exactly this way one layer + // deeper, in the diarizer's must-link. + const byName = {}; + for (const m of resolved) { + if (!m.speaker || m.conf !== 'high') continue; + (byName[m.name] = byName[m.name] || new Set()).add(m.speaker); + } + const nameOnly = {}; + for (const m of resolved) { + if (!m.speaker || m.conf === 'low' || m.conf === null) continue; + (nameOnly[m.name] = nameOnly[m.name] || new Set()).add(m.speaker); + } + const talk = {}; + for (const w of rec.words) if (w.speaker) talk[w.speaker] = (talk[w.speaker] || 0) + (w.t_end_ms - w.t_start_ms); + + const fold = {}; // extra cluster -> primary cluster + const nameOf = {}; // cluster -> typed name + for (const [name, set] of Object.entries(byName)) { + const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0)); + const primary = cls[0]; + nameOf[primary] = name; + for (const c of cls.slice(1)) fold[c] = primary; + } + // Naming without merging: a 'mid' hit labels the cluster it landed on, as long + // as that cluster is not already claimed by somebody else. + for (const [name, set] of Object.entries(nameOnly)) { + if (byName[name]) continue; + const cls = [...set].sort((a, b) => (talk[b] || 0) - (talk[a] || 0)); + if (cls[0] && !nameOf[cls[0]]) nameOf[cls[0]] = name; + } + let merged = 0; + if (Object.keys(fold).length) { + for (const w of rec.words) if (fold[w.speaker]) w.speaker = fold[w.speaker]; + await db.replaceWords(id, rec.words); + merged = Object.keys(fold).length; + console.log(`markers: merged ${merged} cluster(s) into marker-named speakers on #${id}`); + } + for (const [sp, name] of Object.entries(nameOf)) await db.setSpeakerName(id, sp, name).catch(() => {}); + // Learn the voices so the NEXT recording identifies them automatically. + const rec2 = await db.getRecording(id); + let learned = 0; + for (const [sp, name] of Object.entries(nameOf)) if (await enrollSpeakerVoice(rec2, sp, name)) learned++; + const named = Object.keys(nameOf).length; + await relabelAnalysis(id).catch(() => {}); + console.log(`markers on #${id}: ${resolved.length} marker(s) → ${named} named speaker(s), ${merged} merged, ${learned} voice(s) learned`); + return { resolved, named, merged, learned }; +} + +// Run LLM analysis for a recording and persist title/summary/todos/participants/ +// clean dialogue. Background (NPU ~30-90s) so record/stop stays fast. +// A76 clock cap for the whole run: rkllm burns ~1.7 CPU cores alongside the NPU +// and a long Gemma generation hit 95-97°C uncapped on the fanless board (#70, +// 16-min run) — same shutdown risk transcribeWav/rediarize already guard against. +async function analyzeRecording(id) { + // Free the resident rkllm (~5.9GB) BEFORE identifySpeakers: the TitaNet + // spk-embed helper OOM-dies next to it ("spk beendet" per cluster, #71 got + // 48 raw speakers). llm.analyze() below respawns it (~96s load, noise + // against a 15-min analysis). + await llm.stopServer(); + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); + try { + const rec = await db.getRecording(id); + if (!rec) return; + const nameMap = await identifySpeakers(rec).catch(() => ({})); + // Seed the per-recording speaker names from acoustic identification (fills + // blanks only; manual edits stay). Manual names win for the LLM labelling. + await db.mergeSpeakerNames(id, nameMap).catch(() => {}); + // Stored names normally win over acoustic identification (they are typed by + // hand). Exception: a name that came from a NON-'high' marker is only a + // guess about WHICH cluster was speaking — resolveMarkers looks back up to + // 25 s, so in a fast four-way discussion it lands on the wrong voice (#79 + // put "Markus" on Sascha's cluster). If an enrolled voiceprint disagrees + // with such a weak marker name, the voiceprint wins. + const markers = await db.listMarkers(id).catch(() => []); + const weakName = new Map(); + for (const m of markers) if (m.speaker && m.conf !== 'high') weakName.set(m.speaker, m.name); + const names = { ...nameMap, ...(rec.speaker_names || {}) }; + for (const [sp, vname] of Object.entries(nameMap)) { + if (names[sp] !== vname && weakName.get(sp) === names[sp]) { + console.log(`identify #${id}: Stimmabdruck "${vname}" schlägt unsicheren Marker "${names[sp]}" auf ${sp}`); + names[sp] = vname; + await db.setSpeakerName(id, sp, vname).catch(() => {}); + } + } + const glossary = (await db.listGlossary().catch(() => [])).filter(g => g.enabled); + // Deterministic soundalike replacement: the 3B model is unreliable at obeying + // a "map Amy->Aime" instruction (it treats "Amy" as a valid name), so entries + // WITH soundalikes are applied as a hard whole-word swap on the words FIRST — + // then the LLM sees the correct term and the turn view shows it too. Entries + // without soundalikes still rely on the (contextual) LLM glossary hint. + const gmap = glossaryMap(glossary); + if (Object.keys(gmap).length && Array.isArray(rec.words) && rec.words.length) { + let changed = false; + for (const w of rec.words) { const nt = applyGlossaryText(w.word, gmap); if (nt !== w.word) { w.word = nt; changed = true; } } + if (changed) { await db.replaceWords(id, rec.words); console.log(`glossary: applied soundalike swaps to #${id} words`); } + } + const { text, labeled, labels } = buildBasis(rec, names); + // Remember which label each speaker had in the LLM input, so renaming a + // speaker later can patch the finished text (relabelAnalysis). + await db.setSpeakerLabels(id, labels).catch(() => {}); + const a = await llm.analyze(text, { labeled, glossary }); + // Participants = the ACOUSTICALLY diarized speakers (named where known), + // not the LLM's list — the model returned every name MENTIONED in the + // conversation as a "Teilnehmer" (11 names on a 3-person call, #69). + // LLM participants stay only as fallback when there is no diarization. + const spkOrder = []; + for (const w of (rec.words || [])) if (w.speaker && !spkOrder.includes(w.speaker)) spkOrder.push(w.speaker); + // Same labels as in the dialogue the LLM read — otherwise the participant + // list numbers the speakers differently than the summary talks about them. + if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[sp] || labels[sp] || `Sprecher ${i + 1}`); + // Belt-and-suspenders: also swap in the LLM outputs (cheap, catches any the + // model reintroduced). + for (const k of ['title', 'summary', 'transcriptClean']) if (a[k]) a[k] = applyGlossaryText(a[k], gmap); + a.participants = (a.participants || []).map(p => applyGlossaryText(p, gmap)); + a.todos = (a.todos || []).map(t => applyGlossaryText(t, gmap)); + await db.updateAnalysis(id, { ...a, status: 'done' }); + // rkllm ist ab hier fertig und wird freigegeben, BEVOR der Suchindex unten + // das e5-Modell (1,15 GB) startet: am 2026-08-07 traf genau diese Reihenfolge + // den OOM-Killer, der den residenten rkllm erschlug (13:27:09, dmesg). Vorher + // blieb rkllm nach einer Analyse für immer resident — das war der Zustand + // "nur noch 100 MB frei", in dem jeder weitere Helferstart russisches + // Roulette war. Preis: die nächste Analyse lädt das Modell neu (~96 s). + await llm.stopServer(); + // A speaker can be named WHILE the analysis runs (it takes ~15 min, and the + // marker/voiceprint passes themselves rename clusters). The finished text + // still carries the label from analysis start ("Sprecher 1"), so patch it + // once at the end — same mechanism as renaming a speaker by hand. + await relabelAnalysis(id).catch(() => {}); + // Rebuild the search index last: it needs the final speaker names, which + // relabelAnalysis may only just have settled. + await search.indexRecording(id).catch(e => console.error('search index:', e.message)); + console.log(`analysis done for #${id}: ${a.title}`); + } catch (e) { + console.error(`analysis failed for #${id}:`, e.message); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); + } finally { + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {}); + } +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +app.use(express.json()); + +const REC_DIR = path.join(__dirname, 'recordings'); +app.use('/media', express.static(REC_DIR)); +app.use(express.static(path.join(__dirname, 'web/dist'))); + +const wrap = fn => (req, res) => fn(req, res).catch(e => res.status(500).json({ error: String(e && e.message || e) })); + +app.get('/api/health', (req, res) => res.json({ ok: true, ...rec.status() })); + +// --- Bluetooth --- +app.post('/api/bt/scan', wrap(async (req, res) => { + const secs = Math.min(+(req.body && req.body.seconds) || 10, 30); + res.json(await bt.scan(secs)); +})); +app.get('/api/bt/devices', wrap(async (req, res) => res.json(await bt.knownDevices()))); +app.post('/api/bt/pair', wrap(async (req, res) => res.json(await bt.pair(req.body.mac)))); +app.post('/api/bt/connect', wrap(async (req, res) => res.json(await bt.connect(req.body.mac)))); +app.post('/api/bt/repair', wrap(async (req, res) => res.json(await bt.repair(req.body.mac)))); +app.post('/api/bt/disconnect', wrap(async (req, res) => res.json(await bt.disconnect(req.body.mac)))); + +// --- Audio --- +app.get('/api/audio/sources', wrap(async (req, res) => res.json(await rec.listSources()))); +app.post('/api/audio/headset-mic', wrap(async (req, res) => res.json(await rec.enableHeadsetMic((req.body && req.body.codec) || 'auto')))); + +// --- Global NPU pipeline serializer --------------------------------------- +// Transcription (whisper-turbo ENCODER on the NPU) and LLM analysis (rkllm on +// the NPU) cannot run simultaneously: while the whisper encoder holds the NPU, +// rkllm_init fails with "failed to allocate handle, ret:-1, errno:14, Bad +// address", which surfaces as "rkllm READY timeout" and a failed/empty +// analysis. This bit when two recordings finished within seconds of each other +// (both fired transcribe+analyze fire-and-forget with no coordination). Every +// heavy per-recording job (transcribe / analyze / re-diarize) now runs through +// this single FIFO queue, so exactly one touches the NPU at a time; a second +// recording simply waits its turn instead of colliding. +let npuChain = Promise.resolve(); +let npuDepth = 0; +function withNpuLock(label, fn) { + npuDepth++; + const waiting = npuDepth - 1; + const run = npuChain.then(async () => { + if (waiting > 0) console.log(`npu-queue: ${label} start (${waiting} ahead done)`); + // Nie neben einer laufenden Aufnahme: rkllm haelt ~6,7 GB von 7,74 GB, + // eine zweite schwere Last traf am 2026-08-06 den globalen OOM-Killer + // und nahm den Dienst mit. Siehe gate.js. + await gate.waitIdle(label); + try { return await fn(); } + finally { npuDepth--; } + }); + npuChain = run.catch(() => {}); // keep the chain alive across a job's failure + return run; +} + +// --- Recording --- +app.post('/api/record/start', wrap(async (req, res) => res.json(await rec.startRecording(req.body || {})))); +// Live speaker markers: type the name of whoever is talking WHILE recording. +app.post('/api/record/marker', wrap(async (req, res) => res.json(rec.addMarker((req.body && req.body.name) || '')))); +app.get('/api/record/markers', (req, res) => res.json(rec.listMarkers())); +app.delete('/api/record/marker/:index', wrap(async (req, res) => res.json({ markers: rec.removeMarker(req.params.index) }))); +// Transcribe (thermal-guarded whisper + diarize) then analyze, in the background. +// The wav + DB row already exist, so a crash here loses nothing — the recording +// stays with status 'transcribing' and can be recovered via /retranscribe. +async function transcribeAndAnalyze(id, wavFile, numSpeakers) { + await db.markAnalysisStart(id).catch(() => {}); + try { + // Live markers constrain the diarization itself (same name = one speaker, + // different names = never merged), so they are loaded before transcribing. + const markers = await db.listMarkers(id).catch(() => []); + // Enrolled voices do the same for people who were never typed — the box + // owner above all (see diar.clusterWindows). + const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []); + // Name → Person: erst damit treffen ein getippter Marker "Sascha" und ein + // Abdruck von "Sascha Wolf" dieselbe Identität (siehe diar.identityKey). + const persons = await db.personNameMap().catch(() => ({})); + const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers, voiceprints, persons }); + await db.setTranscript(id, transcript, words); + console.log(`transcription done for #${id}: ${words.length} segments`); + // Names typed live → cluster names (+ user-supervised cluster merge) BEFORE + // the LLM sees the dialogue. + await applyMarkers(id).catch(e => console.error('applyMarkers:', e.message)); + } catch (e) { + console.error(`transcription failed for #${id}:`, e.message); + await db.setStatus(id, 'error').catch(() => {}); + // also mark analysis 'error' so the UI stops polling and shows a retry + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); + return; // no transcript -> skip LLM analysis + } + await analyzeRecording(id); +} + +app.post('/api/record/stop', wrap(async (req, res) => { + // Persist wav + mp3 + DB row FIRST (crash-safe), respond immediately, then + // transcribe/analyze in the background. The frontend already polls while + // analysis_status === 'pending', so the transcript + analysis fill in live. + const r = await rec.stopAndSave(); + const id = await db.addRecording({ ...r, transcript: '', status: 'transcribing' }); + await db.addMarkers(id, r.markers).catch(e => console.error('addMarkers:', e.message)); + res.json({ id, title: r.title, mp3_file: r.mp3_file, duration_ms: r.duration_ms, + transcript: '', status: 'transcribing', analysis_status: 'pending', + markers: (r.markers || []).length }); + withNpuLock(`transcribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, r.numSpeakers)); +})); + +// --- Recordings --- +app.get('/api/recordings', wrap(async (req, res) => res.json(await db.listRecordings()))); +app.get('/api/recordings/:id', wrap(async (req, res) => { + const r = await db.getRecording(+req.params.id); + r ? res.json(r) : res.status(404).json({ error: 'not found' }); +})); +// Rename. An empty title clears it AND unlocks, so the next analysis may +// propose one again (see db.renameRecording / updateAnalysis). +app.patch('/api/recordings/:id/title', wrap(async (req, res) => { + const id = +req.params.id; + const row = await db.renameRecording(id, String((req.body && req.body.title) || '')); + if (!row) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true, id, title: row.title, title_locked: !!row.title_locked }); +})); + +// --- Trash ------------------------------------------------------------------- +// Delete = move to the bin. Nothing on disk is touched until the retention +// window expires, so a restore brings back audio, transcript and analysis. +app.delete('/api/recordings/:id', wrap(async (req, res) => { + await db.deleteRecording(+req.params.id); + res.json({ ok: true, trashed: true, days: db.TRASH_DAYS }); +})); +// --- Projektansicht ----------------------------------------------------------- +// Ordner > Unterreiter > angepinnte Aufnahmen. Rein organisatorisch: ein Pin +// referenziert eine Aufnahme, er verschiebt oder kopiert nichts. Dieselbe +// Aufnahme darf in mehreren Projekten und Reitern hängen. +app.get('/api/projects', wrap(async (req, res) => res.json(await db.listProjects()))); +app.post('/api/projects', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + if (!name) return res.status(400).json({ error: 'name fehlt' }); + res.json(await db.createProject(name)); +})); +app.get('/api/projects/:id', wrap(async (req, res) => { + const p = await db.getProject(+req.params.id); + p ? res.json(p) : res.status(404).json({ error: 'not found' }); +})); +app.patch('/api/projects/:id', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + if (!name) return res.status(400).json({ error: 'name fehlt' }); + await db.renameProject(+req.params.id, name); + res.json({ ok: true, id: +req.params.id, name }); +})); +app.delete('/api/projects/:id', wrap(async (req, res) => { + await db.deleteProject(+req.params.id); + res.json({ ok: true }); +})); + +app.post('/api/projects/:id/tabs', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + if (!name) return res.status(400).json({ error: 'name fehlt' }); + res.json(await db.createProjectTab(+req.params.id, name)); +})); +// tid 0 = die Ordner-Ebene. Die ist keine Zeile in project_tabs (es gibt sie +// immer, sie lässt sich nicht löschen), ihre Beschriftung hängt am Projekt. +// Leerer Name setzt sie auf die Vorgabe „Ordner" zurück. +app.patch('/api/projects/:id/tabs/:tid', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + if (+req.params.tid === 0) { + const n = await db.renameProjectRoot(+req.params.id, name); + return res.json({ ok: true, id: 0, name: n }); + } + if (!name) return res.status(400).json({ error: 'name fehlt' }); + await db.renameProjectTab(+req.params.tid, name); + res.json({ ok: true, id: +req.params.tid, name }); +})); +app.delete('/api/projects/:id/tabs/:tid', wrap(async (req, res) => { + await db.deleteProjectTab(+req.params.tid); + res.json({ ok: true }); +})); + +// tab_id fehlt / 0 = direkt auf der Ordner-Ebene anpinnen. +app.post('/api/projects/:id/pins', wrap(async (req, res) => { + const recId = +((req.body && req.body.recording_id) || 0); + if (!recId) return res.status(400).json({ error: 'recording_id fehlt' }); + await db.pinRecording(+req.params.id, +((req.body && req.body.tab_id) || 0), recId); + res.json({ ok: true }); +})); +app.delete('/api/projects/:id/pins/:pinId', wrap(async (req, res) => { + await db.unpinRecording(+req.params.pinId); + res.json({ ok: true }); +})); +// Für den 📌-Button in der Aufnahmenliste: Zuordnungen einer Aufnahme bzw. +// die Pin-Zahl aller Aufnahmen auf einmal. +app.get('/api/recordings/:id/pins', wrap(async (req, res) => res.json(await db.listPinsForRecording(+req.params.id)))); +app.get('/api/pins/counts', wrap(async (req, res) => res.json(await db.pinCounts()))); + +app.get('/api/trash', wrap(async (req, res) => res.json({ days: db.TRASH_DAYS, items: await db.listTrash() }))); +app.post('/api/trash/:id/restore', wrap(async (req, res) => { + await db.restoreRecording(+req.params.id); + res.json({ ok: true }); +})); +// Irreversible: drops the row, its words/markers/passages and the audio files. +app.delete('/api/trash/:id', wrap(async (req, res) => { + res.json({ ok: true, purged: await purgeRecording(+req.params.id) }); +})); + +// Shared by the manual purge route and the retention sweep. +async function purgeRecording(id) { + const files = await db.purgeRecording(id); + let removed = 0; + for (const f of files) { + try { fs.unlinkSync(path.join(REC_DIR, f)); removed++; } + catch (e) { if (e.code !== 'ENOENT') console.error(`purge #${id}: ${f} — ${e.message}`); } + } + return { id, files: removed }; +} + +// Retention sweep: once at boot (the box is often off for days, so a timer +// alone would keep missing the window) and then daily. +async function sweepTrash() { + const ids = await db.expiredTrashIds().catch(() => []); + for (const id of ids) { + try { await purgeRecording(id); console.log(`trash: #${id} nach ${db.TRASH_DAYS} Tagen endgültig gelöscht`); } + catch (e) { console.error(`trash sweep #${id}:`, e.message); } + } +} + +// Verwaiste Audiodateien: alles in REC_DIR, auf das keine Zeile in `recordings` +// mehr zeigt (auch keine im Papierkorb). Sowas hinterließ das Löschen VOR dem +// Papierkorb-Feature — es entfernte nur die Datenbankzeile, die Dateien blieben +// für immer liegen (gefunden: 108 Dateien, 247 MB). Läuft zusammen mit dem +// Papierkorb-Sweep beim Start und danach täglich. +// Die Altersgrenze ist das Sicherheitsnetz: eine Aufnahme wird erst nach REC_DIR +// verschoben und dann in die Datenbank geschrieben, dazwischen liegt ein winziges +// Fenster, in dem eine gültige Datei noch verwaist aussieht. +const ORPHAN_MIN_AGE_MS = 2 * 3600 * 1000; + +async function sweepOrphans() { + let inUse; + try { inUse = await db.allAudioFiles(); } + catch (e) { return console.error('orphan sweep:', e.message); } + const keep = new Set(inUse); + let removed = 0, bytes = 0; + for (const f of fs.readdirSync(REC_DIR)) { + if (!/^rec_.*\.(mp3|wav)$/.test(f) || keep.has(f)) continue; + const p = path.join(REC_DIR, f); + try { + const st = fs.statSync(p); + if (Date.now() - st.mtimeMs < ORPHAN_MIN_AGE_MS) continue; + fs.unlinkSync(p); + removed++; bytes += st.size; + } catch (e) { if (e.code !== 'ENOENT') console.error(`orphan ${f}:`, e.message); } + } + if (removed) console.log(`orphans: ${removed} verwaiste Dateien gelöscht (${(bytes / 1048576).toFixed(0)} MB)`); +} + +// --- Search ------------------------------------------------------------------ +// Hits are pure SQL and come back immediately; the written answer is a separate +// call because the LLM needs ~30-90 s on this hardware (see search.js). +app.get('/api/search', wrap(async (req, res) => { + const q = String(req.query.q || '').trim(); + if (q.length < 2) return res.json({ hits: [], terms: [], range: null, semantic: false, total: 0 }); + res.json(await search.search(q, { limit: Math.min(+req.query.limit || 20, 50) })); +})); +// Runs through the NPU queue: rkllm and the whisper encoder cannot share the NPU. +app.post('/api/search/answer', wrap(async (req, res) => { + const q = String((req.body && req.body.q) || '').trim(); + if (!q) return res.status(400).json({ error: 'q fehlt' }); + const { hits } = await search.search(q, { limit: 20 }); + // stopServer() afterwards is NOT optional: rkllm stays resident with ~6GB + // once loaded, and the next speaker-embedding pass then OOM-dies next to it + // (analyzeRecording/rediarize free it for exactly that reason). A search + // answer left the model squatting 82% of RAM for hours. + try { + res.json(await withNpuLock('search-answer', () => search.answer(q, hits))); + } finally { + await llm.stopServer(); + } +})); +app.get('/api/search/status', wrap(async (req, res) => { + const [[{ n }]] = await db.pool.query('SELECT COUNT(*) AS n FROM passages'); + const [[{ v }]] = await db.pool.query('SELECT COUNT(*) AS v FROM passages WHERE vec IS NOT NULL'); + res.json({ passages: n, embedded: v, ...emb.status(), indexing: !!indexJob }); +})); +// Background reindex; `force` rebuilds everything, otherwise only recordings +// that have no passages yet (the normal case after this update). +let indexJob = null; +app.post('/api/search/reindex', wrap(async (req, res) => { + if (indexJob) return res.json({ ok: true, already: true }); + const force = !!(req.body && req.body.force); + indexJob = search.reindexAll({ force, onProgress: (d, t) => console.log(`search: reindex ${d}/${t}`) }) + .then(r => { console.log(`search: reindex fertig — ${r.indexed}/${r.total}`); return r; }) + .catch(e => { console.error('search: reindex:', e.message); }) + .finally(() => { indexJob = null; }); + res.json({ ok: true, started: true }); +})); + +// --- Personen (eine Person, mehrere Namen, mehrere Stimmproben) --------------- +// Identität ist die Person, nicht der Name. Alles, was Sprecher benennt (Marker, +// Sprecher-Popup, Enrollment), löst über `person_names` auf dieselbe Person auf. +app.get('/api/persons', wrap(async (req, res) => { + const persons = await db.listPersons(); + const vps = await db.listVoiceprints(); + const cur = spk.modelId(); + const byPerson = new Map(); + for (const v of vps) { + if (v.person_id == null) continue; + (byPerson.get(v.person_id) || byPerson.set(v.person_id, []).get(v.person_id)).push( + { id: v.id, model: v.model, stale: v.model !== cur, dim: v.dim, created_at: v.created_at }); + } + res.json(persons.map(p => ({ ...p, voiceprints: byPerson.get(p.id) || [] }))); +})); +// Weiteren Namen anhängen. Gehört er schon einer anderen Person, werden beide +// zusammengeführt (dann ist genau das gemeint). +app.post('/api/persons/:id/names', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + if (!name) return res.status(400).json({ error: 'Name fehlt' }); + const r = await db.addPersonName(+req.params.id, name); + res.json({ ok: true, ...r, persons: await db.listPersons() }); +})); +app.delete('/api/persons/names/:name', wrap(async (req, res) => { + await db.removePersonName(req.params.name); + res.json({ ok: true, persons: await db.listPersons() }); +})); +app.patch('/api/persons/:id', wrap(async (req, res) => { + const name = String((req.body && req.body.display_name) || '').trim(); + if (!name) return res.status(400).json({ error: 'Name fehlt' }); + await db.renamePerson(+req.params.id, name); + res.json({ ok: true, persons: await db.listPersons() }); +})); +app.post('/api/persons/:id/merge', wrap(async (req, res) => { + const fromId = +((req.body && req.body.fromId) || 0); + if (!fromId) return res.status(400).json({ error: 'fromId fehlt' }); + await db.mergePersons(+req.params.id, fromId); + res.json({ ok: true, persons: await db.listPersons() }); +})); +app.delete('/api/persons/:id', wrap(async (req, res) => { + await db.deletePerson(+req.params.id); + res.json({ ok: true, persons: await db.listPersons() }); +})); + +// --- Voiceprints / enrollment --- +app.get('/api/voiceprints', wrap(async (req, res) => { + const vps = await db.listVoiceprints(); + const cur = spk.modelId(); + res.json(vps.map(v => ({ id: v.id, name: v.name, person_id: v.person_id, dim: v.dim, model: v.model, + stale: v.model !== cur, created_at: v.created_at }))); +})); +app.delete('/api/voiceprints/:id', wrap(async (req, res) => { await db.deleteVoiceprint(+req.params.id); res.json({ ok: true }); })); +app.post('/api/enroll/start', wrap(async (req, res) => res.json(await rec.startRecording({ source: req.body && req.body.source })))); +app.post('/api/enroll/stop', wrap(async (req, res) => { + const name = (req.body && req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'Name fehlt' }); + const wav = await rec.stopRaw(); + try { + const emb = await spk.embed(wav); + const { id } = await db.learnVoice(name, emb, spk.modelId()); + res.json({ id, name, dim: emb.length }); + } finally { try { fs.unlinkSync(wav); } catch {} } +})); + +// --- Glossary (known proper nouns / terms; fed into LLM correction) --- +app.get('/api/glossary', wrap(async (req, res) => res.json(await db.listGlossary()))); +app.post('/api/glossary', wrap(async (req, res) => { + const term = (req.body && req.body.term || '').trim(); + const soundalikes = (req.body && req.body.soundalikes || '').trim(); + if (!term) return res.status(400).json({ error: 'Begriff fehlt' }); + res.json(await db.addGlossary(term, soundalikes)); +})); +app.delete('/api/glossary/:id', wrap(async (req, res) => { await db.deleteGlossary(+req.params.id); res.json({ ok: true }); })); +app.put('/api/glossary/:id', wrap(async (req, res) => { + const term = (req.body && req.body.term || '').trim(); + const soundalikes = (req.body && req.body.soundalikes || '').trim(); + if (!term) return res.status(400).json({ error: 'Begriff fehlt' }); + const g = await db.updateGlossary(+req.params.id, term, soundalikes); + if (!g) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); + res.json(g); +})); + +// --- LLM --- +app.get('/api/llm/status', (req, res) => res.json(llm.status())); +app.get('/api/spk/status', (req, res) => res.json(spk.status())); +// Re-diarize a recording's words with an (optional) fixed speaker count, then +// persist the new speaker labels. Text/timestamps are untouched. Needs the wav. +async function rediarize(id, numSpeakers) { + const r = await db.getRecording(id); + if (!r || !r.wav_file) throw new Error('wav für Re-Diarisierung fehlt'); + const wav = path.join(REC_DIR, r.wav_file); + if (!fs.existsSync(wav)) throw new Error('wav-Datei nicht gefunden: ' + r.wav_file); + const words = r.words || []; + // Free the resident rkllm first — diar's consolidation embeds every cluster + // via TitaNet, which OOM-dies next to a resident 5.9GB LLM (see + // analyzeRecording; on #71 that silently yielded 48 raw speakers). + // Abwarten: ein nicht abgewartetes stopServer() laesst proc/ready gesetzt, + // bis das exit-Event eintrifft — der naechste ask() schreibt dann in die + // tote Pipe (EPIPE, riss am 2026-08-06 den Dienst mit). + await llm.stopServer(); + // Diarization runs on the A76 cores; the board is fanless, so cap the clock + // for the whole run (transcribeWav does this for the record path — rediarize + // is the other entry point and must do it too, or a long re-diar overheats). + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); + try { + // Enrolled voices go IN, not just out: they keep two known people (above all + // the box owner, who is enrolled and talks in every recording) from ending up + // in one cluster. See the cannot-link block in diar.clusterWindows. + const voiceprints = await db.listVoiceprints(spk.modelId()).catch(() => []); + const persons = await db.personNameMap().catch(() => ({})); + const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [], voiceprints, persons }); + diar.assignSpeakers(words, segs); + await db.replaceWords(id, words); + // Re-diarization RENUMBERS the clusters, so every previously stored name + // ("speaker_02" = Fabio) would now point at a different voice. Drop them; the + // live markers and the voiceprint identification re-fill them right after. + await db.pool.query('UPDATE recordings SET speaker_names=NULL WHERE id=?', [id]).catch(() => {}); + console.log(`re-diarized #${id} (numSpeakers=${numSpeakers || 'auto'}) → ${new Set(segs.map(s => s.speaker)).size} speakers`); + } finally { + await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '2256000']).catch(() => {}); + } +} + +// Re-run analysis (e.g. the model wasn't downloaded yet at record time). Optional +// body { numSpeakers } re-diarizes first (fixes over-segmentation on a known count). +app.post('/api/recordings/:id/reanalyze', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + const numSpeakers = +(req.body && req.body.numSpeakers) || 0; + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + await db.markAnalysisStart(id).catch(() => {}); + res.json({ ok: true, status: 'pending', reDiarize: true }); + // Always re-diarize: numSpeakers>0 pins the count, 0 = auto (now consolidated, + // so auto no longer over-segments). This is the "fix my speakers" path. + withNpuLock(`reanalyze #${id}`, async () => { + try { await rediarize(id, numSpeakers); } + catch (e) { console.error(`re-diarize failed for #${id}:`, e.message); } + await applyMarkers(id).catch(e => console.error('applyMarkers:', e.message)); + await analyzeRecording(id); + }); +})); + +// --- markers of a stored recording ----------------------------------------- +app.get('/api/recordings/:id/markers', wrap(async (req, res) => res.json(await db.listMarkers(+req.params.id)))); +// Add a marker after the fact (e.g. while listening back) — same resolution path. +app.post('/api/recordings/:id/markers', wrap(async (req, res) => { + const name = String((req.body && req.body.name) || '').trim(); + const tMs = +(req.body && req.body.t_ms); + if (!name || !Number.isFinite(tMs)) return res.status(400).json({ error: 'name/t_ms fehlt' }); + await db.addMarker(+req.params.id, tMs, name); + res.json({ ok: true, markers: await db.listMarkers(+req.params.id) }); +})); +app.delete('/api/recordings/:id/markers/:mid', wrap(async (req, res) => { + await db.deleteMarker(+req.params.mid); + res.json({ ok: true, markers: await db.listMarkers(+req.params.id) }); +})); +// Re-resolve the markers against the current diarization (no LLM, no sherpa). +app.post('/api/recordings/:id/apply-markers', wrap(async (req, res) => { + const out = await applyMarkers(+req.params.id); + const r = await db.getRecording(+req.params.id); + res.json({ ok: true, ...(out || { named: 0, merged: 0, learned: 0 }), + speaker_names: (r && r.speaker_names) || {}, markers: (r && r.markers) || [] }); +})); + +// LLM-only re-analysis: re-run title/summary/todos on the EXISTING words + +// speaker labels, WITHOUT re-diarizing (fast — skips the ~40min sherpa pass when +// the speakers are already correct and only the LLM/prompts changed). +app.post('/api/recordings/:id/reanalyze-llm', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + await db.markAnalysisStart(id).catch(() => {}); + res.json({ ok: true, status: 'pending', reDiarize: false }); + withNpuLock(`reanalyze-llm #${id}`, async () => { await analyzeRecording(id); }); +})); + +// Re-run transcription from the saved wav (recovery: a crash/thermal-shutdown +// during whisper leaves the row at status 'transcribing' with an empty +// transcript, or 'error'; the wav is on disk). Optional { numSpeakers }. +app.post('/api/recordings/:id/retranscribe', wrap(async (req, res) => { + const id = +req.params.id; + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + if (!r.wav_file) return res.status(400).json({ error: 'kein wav für Re-Transkription' }); + const numSpeakers = +(req.body && req.body.numSpeakers) || 0; + await db.setStatus(id, 'transcribing'); + await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'pending' }); + res.json({ ok: true, status: 'transcribing' }); + withNpuLock(`retranscribe #${id}`, () => transcribeAndAnalyze(id, r.wav_file, numSpeakers)); +})); + +// Longest contiguous run of one diarization cluster (best clean sample of a voice). +function longestRun(words, speaker) { + let best = null, cur = null; + for (const w of words || []) { + if (w.speaker !== speaker) { cur = null; continue; } + if (!cur) cur = { start: w.t_start_ms, end: w.t_end_ms }; + else cur.end = w.t_end_ms; + if (!best || (cur.end - cur.start) > (best.end - best.start)) best = { start: cur.start, end: cur.end }; + } + return best; +} + +// Name a diarized speaker in a recording and (default) LEARN their voice: fold +// the speaker's longest clean segment into the named voiceprint centroid, so +// future recordings auto-identify them. Empty name clears the label. +app.post('/api/recordings/:id/speaker', wrap(async (req, res) => { + const id = +req.params.id; + const speaker = String((req.body && req.body.speaker) || '').trim(); + const name = String((req.body && req.body.name) || '').trim(); + const enroll = !(req.body && req.body.enroll === false); + if (!speaker) return res.status(400).json({ error: 'speaker fehlt' }); + const r = await db.getRecording(id); + if (!r) return res.status(404).json({ error: 'not found' }); + await db.setSpeakerName(id, speaker, name); + // Patch the name into the already generated summary/todos/participants. + const relabel = await relabelAnalysis(id).catch(e => { console.error('relabel:', e.message); return { changed: 0 }; }); + + let enrolled = false, learnMsg = null; + if (enroll && name && r.wav_file) { + const seg = longestRun(r.words, speaker); + const wavPath = path.join(REC_DIR, r.wav_file); + if (!seg || (seg.end - seg.start) < 1500) { + learnMsg = 'Segment zu kurz zum Lernen (<1,5s)'; + } else if (!fs.existsSync(wavPath)) { + learnMsg = 'wav fehlt – nicht gelernt'; + } else { + const cut = `/tmp/enr_${id}_${speaker}.wav`; + try { + await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(seg.start / 1000), + '-to', String(seg.end / 1000), '-ar', '16000', '-ac', '1', cut]); + const emb = await spk.embed(cut); + await db.learnVoice(name, emb, spk.modelId()); + enrolled = true; + } catch (e) { console.error('enroll-from-recording failed:', e.message); learnMsg = 'Lernen fehlgeschlagen'; } + finally { try { fs.unlinkSync(cut); } catch {} } + } + } + const r2 = await db.getRecording(id); + // The passage rows carry the speaker NAME denormalised (so search can filter + // by person without a join) — a rename has to be written through. + search.indexRecording(id).catch(e => console.error('search index:', e.message)); + res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg, + relabeled: relabel.changed }); +})); + +app.get(/^(?!\/api|\/media).*/, (req, res) => res.sendFile(path.join(__dirname, 'web/dist/index.html'))); + +const PORT = process.env.PORT || 3000; +db.ensureSchema() + .catch(e => console.error('ensureSchema:', e.message)) + .then(() => db.failStaleJobs()) + .then(r => { if (r && (r.analyses || r.transcriptions)) console.log(`boot: ${r.analyses} abgebrochene Analysen, ${r.transcriptions} abgebrochene Transkriptionen als Fehler markiert`); }) + .catch(e => console.error('failStaleJobs:', e.message)) + .finally(() => app.listen(PORT, '0.0.0.0', () => { + console.log(`callrec listening on :${PORT}`); + // Retention sweep at boot + daily. unref() so it never holds the process open. + const sweep = () => { + sweepTrash() + .catch(e => console.error('trash sweep:', e.message)) + .then(() => sweepOrphans()); // erst leeren, dann die Reste wegräumen + }; + sweep(); + setInterval(sweep, 24 * 3600 * 1000).unref(); + // Backfill the search index for everything recorded before this update. + // Deliberately not under the NPU lock: it is CPU/SQL only and must not + // delay a transcription that is already queued. + search.reindexAll({ onProgress: (d, t) => { if (d === 1 || d === t) console.log(`search: backfill ${d}/${t}`); } }) + .then(r => { if (r.total) console.log(`search: backfill fertig — ${r.indexed}/${r.total} Aufnahmen`); }) + .catch(e => console.error('search backfill:', e.message)); + })); diff --git a/app/spk.js b/app/spk.js index 3dbc67e..4a648e7 100644 --- a/app/spk.js +++ b/app/spk.js @@ -71,11 +71,34 @@ export function cosine(a, b) { return d / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9); } -// Best enrolled voiceprint for an embedding, or null if below threshold. +// Beste PERSON für ein Embedding, oder null unter der Schwelle. +// +// Bewertet wird pro Person mit ihrem BESTEN Abdruck (Maximum, kein Mittel): die +// Proben einer Person stammen aus verschiedenen Sessions und Headsets, und ein +// Mittel über sie verwischt genau das, was die Stimme ausmacht. `second` ist der +// beste Wert einer ANDEREN Person — nur damit ist ein Abstands-Kriterium +// überhaupt sinnvoll. Vorher lief der Abstand über einzelne ABDRÜCKE, und zwei +// Abdrücke derselben Person (Kosinus 0,928) haben ihn zuverlässig gerissen: die +// Zwangsbedingung in diar.js war dadurch faktisch abgeschaltet. export function bestMatch(emb, voiceprints, threshold = THRESHOLD) { - let best = null, bs = -1; - for (const v of voiceprints) { const s = cosine(emb, v.vec); if (s > bs) { bs = s; best = v; } } - return (best && bs >= threshold) ? { id: best.id, name: best.name, score: bs } : null; + const perPerson = new Map(); // Identität → bester Kosinus + const meta = new Map(); + for (const v of voiceprints) { + const key = v.person_id != null ? `p${v.person_id}` : `n:${String(v.name || '').toLowerCase()}`; + const s = cosine(emb, v.vec); + if (!perPerson.has(key) || s > perPerson.get(key)) { + perPerson.set(key, s); + meta.set(key, { personId: v.person_id ?? null, name: v.name, printId: v.id }); + } + } + let bestKey = null, s1 = -Infinity, s2 = -Infinity; + for (const [key, s] of perPerson) { + if (s > s1) { s2 = s1; s1 = s; bestKey = key; } + else if (s > s2) s2 = s; + } + if (bestKey === null || s1 < threshold) return null; + const m = meta.get(bestKey); + return { id: m.printId, personId: m.personId, key: bestKey, name: m.name, score: s1, second: s2 }; } export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; } diff --git a/app/web/src/main.js b/app/web/src/main.js index 66f0c68..d4cd4a8 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -213,17 +213,63 @@ async function viewVoices() {
-

Angelernte Stimmen

Lade…
`; +

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.
+
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(); }); + const persons = await api.get('/api/persons'); + const opts = persons.map(p => ``).join(''); + listEl.innerHTML = persons.length ? persons.map(p => ` +
+
+ 👤 ${escapeHtml(p.display_name)} +
${p.names.map(n => `${escapeHtml(n)}${p.names.length > 1 + ? `` : ''}`).join('')} +
+
${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print + ? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale) + ? ' · ⚠ andere Modellversion' : ''}
+
+ ${persons.length > 1 ? `` : ''} + ${p.voiceprints.length ? `` : ''} + +
`).join('') + : '
Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.
'; + + listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => { + const name = prompt('Weiterer Name für diese Person:'); + if (!name || !name.trim()) return; + try { + const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() }); + toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓'); + load(); + } catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => { + try { await api.del('/api/persons/names/' + b.dataset.delname); load(); } + catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => { + const fromId = sel.value; if (!fromId) return; + if (fromId === sel.dataset.merge) { sel.value = ''; return; } + if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; } + try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); } + catch (e) { toast(e.message, true); } + }); + listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => { + if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return; + const p = persons.find(x => x.id === +b.dataset.clear); + for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id); + load(); + }); + listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => { + if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return; + await api.del('/api/persons/' + b.dataset.delperson); load(); + }); } catch (e) { listEl.innerHTML = '
' + e.message + '
'; } } await load(); diff --git a/app/web/src/style.css b/app/web/src/style.css index 063f656..86f1e86 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -1,563 +1,563 @@ -/* Aime — Anruf-Assistent. Zwei Themes über CSS-Variablen; data-theme setzt - index.html vor dem ersten Paint, der Header-Toggle schaltet um. - Akzent = warmes Kupfer (Tonband-Anmutung), Rot bleibt der Aufnahme reserviert. */ - -:root[data-theme="dark"] { - --bg: #14161b; - --surface: #1d222a; - --surface-2: #262c36; - --inset: #10141a; - --line: #303845; - --fg: #e9ecf1; - --muted: #98a2b3; - --accent: #e8804c; - --accent-fg: #17130f; - --accent-soft: rgba(232, 128, 76, .14); - --ok: #55b381; - --ok-soft: rgba(85, 179, 129, .14); - --warn: #e2b93b; - --warn-soft: rgba(226, 185, 59, .14); - --danger: #e5595e; - --danger-soft: rgba(229, 89, 94, .12); - --shadow: none; - --sp0: #5bc0eb; --sp1: #ef8fc0; --sp2: #a8cc4e; --sp3: #e6b84c; - --sp0-w: #8fd6f5; --sp1-w: #f5b5d6; --sp2-w: #c5e07e; --sp3-w: #f0d08a; -} -:root[data-theme="light"] { - --bg: #f4f2ee; - --surface: #ffffff; - --surface-2: #eceae4; - --inset: #f7f6f2; - --line: #ddd8cf; - --fg: #23272e; - --muted: #6d7683; - --accent: #c05f2d; - --accent-fg: #ffffff; - --accent-soft: rgba(192, 95, 45, .1); - --ok: #2f8a5d; - --ok-soft: rgba(47, 138, 93, .12); - --warn: #9a7514; - --warn-soft: rgba(154, 117, 20, .12); - --danger: #c73e43; - --danger-soft: rgba(199, 62, 67, .09); - --shadow: 0 1px 3px rgba(35, 39, 46, .07); - --sp0: #1179a8; --sp1: #c2417f; --sp2: #5f7d16; --sp3: #a4700d; - --sp0-w: #0e6b95; --sp1-w: #ad3a72; --sp2-w: #556f14; --sp3-w: #92640b; -} - -* { box-sizing: border-box; } - -/* Scrollbalken im Logo-Kupfer (Firefox: scrollbar-color; Chromium/WebKit: ::-webkit-*) */ -* { scrollbar-width: thin; scrollbar-color: var(--accent) transparent; } -::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: var(--accent); border-radius: 5px; - border: 2px solid transparent; background-clip: padding-box; -} -::-webkit-scrollbar-thumb:hover { background: var(--accent); filter: brightness(1.15); } -::-webkit-scrollbar-corner { background: transparent; } -html { color-scheme: dark; } -:root[data-theme="light"] { color-scheme: light; } -body { - margin: 0; background: var(--bg); color: var(--fg); - font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif; - transition: background .2s, color .2s; -} -/* Volle Fensterbreite für ALLE Tabs. Früher war nur „Aufnahmen“ breit (.wide) - und der Rest auf 860px zentriert — dadurch sprang die Kopfzeile beim - Tabwechsel in die Mitte. */ -#app { max-width: none; margin: 0 auto; padding: 16px 28px; } - -/* ---- Topbar / Brand ---- */ -.topbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 6px 0 14px; } -.brand { display: flex; align-items: center; gap: 10px; } -.brand-wave { width: 30px; height: 18px; color: var(--accent); flex: none; } -.topbar h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.01em; } -.topbar .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 6px; } -nav { display: flex; gap: 6px; flex-wrap: wrap; } -.topbar nav { margin-left: 0; } - -/* Aufnahme-Steuerung in der Kopfzeile (zwischen Logo und Tabs) */ -.rec-ctl { display: flex; align-items: center; gap: 10px; margin: 0 auto; } -.recbtn { - width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--danger); - background: var(--danger-soft); color: var(--danger); font-size: 15px; line-height: 1; - cursor: pointer; transition: background .15s, transform .1s; -} -.recbtn:hover { background: var(--danger); color: #fff; } -.recbtn.rec { background: var(--danger); color: #fff; animation: recpulse 1.4s ease-in-out infinite; } -@keyframes recpulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 89, 94, .45); } 50% { box-shadow: 0 0 0 9px rgba(229, 89, 94, 0); } } -.recbtn:disabled { opacity: .55; cursor: default; animation: none; } -.rec-ctl .rectime { font-size: 14px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 44px; } - -/* Namensmarker während der Aufnahme (Kopfzeile) */ -.rec-ctl .mkin { width: 210px; padding: 6px 10px; font-size: 13px; } -@media (max-width: 1100px) { .rec-ctl .mkin { width: 130px; } } - -/* Live-Platzhalter in der Aufnahmen-Liste */ -.item.rec-live { border-color: var(--danger); background: var(--danger-soft); } -.rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); flex: none; animation: recpulse 1.4s ease-in-out infinite; } -.item.rec-live .rectime { color: var(--danger); font-weight: 650; } -.mkchips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } -.mkchip { - display: inline-flex; align-items: center; gap: 5px; font-size: 12px; - background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 4px 2px 8px; -} -.mkchip .mono { color: var(--muted); } -.mkdel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; line-height: 1; padding: 2px 4px; } -.mkdel:hover { color: var(--danger); } - -/* Namensmarker-Liste im Detail */ -.markers { list-style: none; margin: 6px 0; padding: 0; } -.markers li { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 13px; } -.mkseek { - background: var(--surface-2); border: 1px solid var(--line); border-radius: 7px; - color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 7px; -} -.mkseek:hover { border-color: var(--accent); } -.mkadd-row { margin-top: 8px; } -.mkadd-row .input { flex: 1; min-width: 120px; } -.tab { - background: transparent; color: var(--muted); border: 1px solid transparent; - padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; - transition: color .12s, background .12s; -} -.tab:hover { color: var(--fg); background: var(--surface-2); } -.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; } -.btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; } - -/* ---- Bausteine ---- */ -.card { - background: var(--surface); border: 1px solid var(--line); border-radius: 14px; - padding: 18px; margin-bottom: 16px; box-shadow: var(--shadow); -} -h2 { margin: 0 0 12px; font-size: 18px; letter-spacing: -.01em; } -.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } -.grow { flex: 1; } -.btn { - background: var(--surface-2); color: var(--fg); border: 1px solid var(--line); - border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px; - transition: border-color .12s, background .12s; -} -.btn:hover { border-color: var(--accent); } -.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; } -.btn.primary:hover { filter: brightness(1.07); } -.btn.danger { background: var(--danger-soft); border-color: transparent; color: var(--danger); } -.btn.danger:hover { border-color: var(--danger); } -.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; } -.btn.small { font-size: .8rem; padding: 3px 8px; } -.btn.icon { padding: 6px 9px; flex: none; } -.btn:disabled { opacity: .55; cursor: default; } -:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; } -.input, select { - width: 100%; background: var(--inset); color: var(--fg); - border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px; -} -.input:focus, select:focus { border-color: var(--accent); outline: none; } -.list { display: flex; flex-direction: column; gap: 8px; } -.item { - display: flex; gap: 10px; align-items: center; - background: var(--inset); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; -} -.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: var(--surface-2); color: var(--muted); } -.badge.ok { background: var(--ok-soft); color: var(--ok); } -.badge.warn { background: var(--warn-soft); color: var(--warn); } -.muted { color: var(--muted); } -.mono { font-family: ui-monospace, monospace; font-size: 12px; } -.err { color: var(--danger); } -.err-inline { color: var(--danger); margin-bottom: 6px; } -.hint { font-size: .8rem; color: var(--muted); margin-bottom: 10px; } - -/* ---- Aufnahme ---- */ -.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; } -.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; } -audio { width: 100%; margin-bottom: 10px; } - -/* ---- Transkript ---- */ -.transcript { line-height: 2.1; font-size: 16px; } -.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; } -.transcript .w:hover { background: var(--surface-2); } -.transcript .w.active, .w.active { background: var(--accent); color: var(--accent-fg); } -.w.sp0 { color: var(--sp0-w); } .w.sp1 { color: var(--sp1-w); } -.w.sp2 { color: var(--sp2-w); } .w.sp3 { color: var(--sp3-w); } - -.toast { - position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); - background: var(--surface); border: 1px solid var(--accent); color: var(--fg); - padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; box-shadow: var(--shadow); -} -.toast.err { border-color: var(--danger); } - -/* ---- Analyse ---- */ -.summary-snip { - font-size: .85rem; color: var(--muted); margin-top: 4px; overflow: hidden; - text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; -} -.analysis { margin: 10px 0; padding: 12px; background: var(--inset); border: 1px solid var(--line); border-radius: 10px; } -.analysis h3 { margin: 8px 0 4px; font-size: .9rem; color: var(--muted); } -.summary { margin: 0 0 6px; line-height: 1.55; white-space: pre-line; } -.todos { margin: 0; padding-left: 20px; } -.todos li { margin: 3px 0; } -.parts { margin: 0; padding-left: 20px; } .parts li { margin: 3px 0; } -.clean { line-height: 1.55; background: var(--inset); padding: 8px 10px; border-radius: 8px; margin: 0 0 4px; } - -/* ---- Dialog-Turns / Sprecher ---- */ -.dialog { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } -.turn { padding: 6px 10px; border-radius: 8px; background: var(--inset); line-height: 1.5; } -.turn .spk { margin-right: 6px; font-weight: 600; } -.turn.sp0 { border-left: 3px solid var(--sp0); } .turn.sp0 .spk { color: var(--sp0); } -.turn.sp1 { border-left: 3px solid var(--sp1); } .turn.sp1 .spk { color: var(--sp1); } -.turn.sp2 { border-left: 3px solid var(--sp2); } .turn.sp2 .spk { color: var(--sp2); } -.turn.sp3 { border-left: 3px solid var(--sp3); } .turn.sp3 .spk { color: var(--sp3); } - -.spk-edit { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; } -.spk-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: var(--inset); border-left: 3px solid var(--line); } -.spk-row.sp0 { border-left-color: var(--sp0); } .spk-row.sp1 { border-left-color: var(--sp1); } -.spk-row.sp2 { border-left-color: var(--sp2); } .spk-row.sp3 { border-left-color: var(--sp3); } -.spk-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex: none; } -.spk-row.sp0 .spk-dot { background: var(--sp0); } .spk-row.sp1 .spk-dot { background: var(--sp1); } -.spk-row.sp2 .spk-dot { background: var(--sp2); } .spk-row.sp3 .spk-dot { background: var(--sp3); } -.spk-name { flex: 1; min-width: 80px; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: var(--fg); } -.spk-learn-l { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; } - -.ana-time { font-variant-numeric: tabular-nums; font-weight: 600; } -.ana-time.live { color: var(--accent); } -.ana-line { margin: 2px 0 8px; } - -/* ---- Aufnahmen: Liste links, Detail rechts ---- */ -.rec-layout { display: flex; gap: 8px; align-items: flex-start; } -.rec-splitter { - flex: 0 0 6px; align-self: stretch; cursor: col-resize; border-radius: 3px; - background: transparent; transition: background .12s; -} -.rec-splitter:hover, body.resizing .rec-splitter { background: var(--accent-soft); } -body.resizing { cursor: col-resize; user-select: none; } -.rec-list-pane { - flex: 0 0 340px; position: sticky; top: 12px; max-height: calc(100vh - 90px); - overflow-y: auto; background: var(--surface); border: 1px solid var(--line); - border-radius: 14px; padding: 10px; box-shadow: var(--shadow); -} -.rec-detail-pane { flex: 1 1 auto; min-width: 0; } -.rec-detail-pane .card { margin-bottom: 0; } -.detail-empty { padding: 40px 16px; text-align: center; border: 1px dashed var(--line); border-radius: 14px; } - -.day-group { margin-bottom: 6px; } -.day-head { - display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; - color: var(--muted); text-transform: uppercase; letter-spacing: .03em; padding: 6px 4px; user-select: none; -} -.day-head:hover { color: var(--fg); } -.day-caret { display: inline-block; transition: transform .15s; font-size: 10px; } -.day-group.collapsed .day-caret { transform: rotate(-90deg); } -.day-group.collapsed .day-items { display: none; } -.day-count { margin-left: auto; background: var(--accent-soft); color: var(--accent); border-radius: 20px; padding: 1px 8px; font-size: 11px; font-weight: 650; } -.day-items { display: flex; flex-direction: column; gap: 6px; } - -.item.rec { cursor: pointer; transition: border-color .1s, background .1s; } -.item.rec:hover { border-color: var(--accent); } -.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); } - -/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */ -.status-line { margin: 4px 0 10px; } -.detail-cols { display: flex; gap: 12px; align-items: flex-start; } -.mod-col { min-width: 0; display: flex; flex-direction: column; gap: 10px; min-height: 60px; } -.mod-col[data-col="main"] { flex: 1 1 54%; } -.mod-col[data-col="side"] { flex: 1 1 46%; } -.mod-col.empty { flex: 0 0 26px; border: 1px dashed var(--line); border-radius: 12px; align-self: stretch; } -.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; position: sticky; top: 12px; } -.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; } -@media (max-width: 1100px) { - .detail-cols { flex-direction: column; } - .mod-col { width: 100%; } - .mod-col.empty { display: none; } - .transcript-panel { max-height: 60vh; position: static; } -} -.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } -.module.dragging { opacity: .45; border-style: dashed; } -.mod-head { - display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab; - background: var(--surface-2); user-select: none; -} -.mod-head:active { cursor: grabbing; } -.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } -.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; } -.mod-body { padding: 12px; } -.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); } - -/* ---- Sprecher-Verlauf: fester Streifen unter dem Player, volle Breite ---- */ -.viz-top { - position: relative; margin: 4px 0 10px; padding: 10px 12px; background: var(--inset); - border: 1px solid var(--line); border-radius: 12px; -} -.lane-label { cursor: pointer; } -.lane-label:hover { color: var(--fg); text-decoration: underline dotted; } -.spkpop { - position: absolute; left: 12px; z-index: 5; width: 270px; - background: var(--surface); border: 1px solid var(--accent); border-radius: 10px; - padding: 10px 12px; box-shadow: 0 6px 22px rgba(0, 0, 0, .35); -} -.spkpop .input { margin-bottom: 6px; } -.viz-wrap { display: flex; gap: 14px; align-items: stretch; } -.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; } -.lane-row { display: flex; align-items: center; gap: 8px; } -.lane-label { - flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px; - color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; } -.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; } -.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); } -.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); } -.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); } -.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); } -.lane-row.speaking .seg { opacity: 1; } -.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; } -.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; } -@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } } -.playhead { - display: none; position: absolute; top: 0; bottom: 0; width: 2px; - background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2; - transition: left .25s linear; -} -.lanes { flex: 1 1 100%; } - -@media (max-width: 800px) { - #app { padding: 16px; } - .rec-layout { flex-direction: column; } - .rec-list-pane { flex: none !important; width: 100%; position: static; max-height: 340px; } - .rec-splitter { display: none; } - .topbar nav { margin-left: 0; width: 100%; order: 3; } -} - -/* ---- Konferenztisch-Ansicht ---- */ -.hidden { display: none !important; } -.tablescene { position: relative; width: 100%; aspect-ratio: 4 / 3; max-height: 420px; } -.ttable { - position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); - width: 58%; height: 44%; border-radius: 18px; - background: var(--surface-2); border: 2px solid var(--line); - box-shadow: inset 0 0 0 6px var(--inset); -} -.tperson { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 2px; width: 76px; } -.thead { - position: relative; width: 30px; height: 30px; border-radius: 50%; - background: var(--muted); z-index: 2; -} -.tperson.sp0 .thead, .tperson.sp0 .tbody { background: var(--sp0); } -.tperson.sp1 .thead, .tperson.sp1 .tbody { background: var(--sp1); } -.tperson.sp2 .thead, .tperson.sp2 .tbody { background: var(--sp2); } -.tperson.sp3 .thead, .tperson.sp3 .tbody { background: var(--sp3); } -.teyes { position: absolute; left: 0; right: 0; top: 9px; display: flex; justify-content: center; gap: 7px; transition: transform .25s; } -.teye { width: 5px; height: 5px; border-radius: 50%; background: #17130f; } -:root[data-theme="light"] .teye { background: #fff; } -.tmouth { - position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); - width: 9px; height: 3px; border-radius: 2px; background: rgba(0, 0, 0, .55); opacity: 0; -} -:root[data-theme="light"] .tmouth { background: rgba(255, 255, 255, .8); } -.tperson.talking .tmouth { opacity: 1; animation: talkmouth .35s ease-in-out infinite; } -@keyframes talkmouth { 0%, 100% { height: 2px; } 50% { height: 7px; } } -.tperson.talking .thead { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 10px var(--accent-soft); } -.tbody { width: 46px; height: 20px; border-radius: 12px 12px 5px 5px; background: var(--muted); margin-top: -6px; opacity: .85; } -.tname { font-size: 11px; color: var(--muted); max-width: 76px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tperson.talking .tname { color: var(--fg); font-weight: 650; } -.tbubble { - position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); - width: 54%; z-index: 3; - background: var(--surface); border: 2px solid var(--line); border-radius: 14px; - padding: 8px 10px; font-size: 13px; line-height: 1.55; - transition: border-color .2s; -} -.tb-scroll { max-height: 150px; overflow-y: auto; } -.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; } -.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; } -.tbubble .bw:hover { background: var(--surface-2); } -.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); } -.tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); } -.tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); } -.tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); } -.tbubble.sp3 { border-color: var(--sp3); } .tbubble.sp3 .tb-name { color: var(--sp3); } -.tbubble::after { - content: ''; position: absolute; width: 12px; height: 12px; - background: inherit; border: inherit; border-width: 0 2px 2px 0; - display: none; -} -.tbubble[class*="arrow-"]::after { display: block; } -.tbubble.arrow-top::after { top: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(-135deg); } -.tbubble.arrow-bottom::after { bottom: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(45deg); } -.tbubble.arrow-left::after { left: -7px; top: 50%; transform: translateY(-50%) rotate(135deg); } -.tbubble.arrow-right::after { right: -7px; top: 50%; transform: translateY(-50%) rotate(-45deg); } -.tidle { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: 13px; text-align: center; } -#viewswitch svg { display: block; } - -@media (prefers-reduced-motion: reduce) { - * { transition: none !important; animation: none !important; } -} - -/* ========================================================================== - Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine - dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen. - ========================================================================== */ -.search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; } -/* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */ -.search-page.landing { padding-top: 12vh; } -.search-page.has-results { padding-top: 1.25rem; } - -.search-box { - display: flex; align-items: center; gap: .5rem; - border: 1px solid var(--line); border-radius: 2rem; - background: var(--surface); box-shadow: var(--shadow); - padding: .35rem .35rem .35rem 1rem; - transition: box-shadow .15s, border-color .15s; -} -.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -.search-ico { opacity: .55; font-size: 1rem; } -.search-input { - flex: 1; border: 0; outline: 0; background: transparent; color: var(--fg); - font: inherit; font-size: 1.05rem; padding: .55rem 0; -} -.search-clear { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: .95rem; padding: .25rem .4rem; } -.search-clear:hover { color: var(--fg); } -.search-go { - border: 0; border-radius: 1.6rem; cursor: pointer; - background: var(--accent); color: var(--accent-fg); - font: inherit; font-weight: 600; padding: .55rem 1.1rem; -} -.search-go:hover { filter: brightness(1.07); } - -.search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; } -.search-hints .example { - border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2); - color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left; -} -.search-hints .example:hover { border-color: var(--accent); color: var(--accent); } - -.searching { padding: 1.5rem 0; } -.search-empty { padding: 1.5rem 0; } - -/* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */ -.answer-box { - margin: 1.25rem 0 .5rem; padding: .9rem 1rem; - background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem; -} -.answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; } -.answer-body { line-height: 1.55; white-space: pre-wrap; } -/* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */ -.answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; } -.answer-body.skeleton span { - height: .7rem; border-radius: .35rem; background: var(--line); opacity: .55; - animation: pulse 1.4s ease-in-out infinite; -} -.answer-body.skeleton span:nth-child(1) { width: 92%; } -.answer-body.skeleton span:nth-child(2) { width: 78%; animation-delay: .18s; } -.answer-body.skeleton span:nth-child(3) { width: 45%; animation-delay: .36s; } -@keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: .7; } } -.answer-src { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .4rem; } -.src-chip { - border: 1px solid var(--line); background: var(--surface); color: var(--fg); - border-radius: 1rem; font: inherit; font-size: .8rem; padding: .2rem .6rem; cursor: pointer; -} -.src-chip:hover { border-color: var(--accent); color: var(--accent); } - -.result-meta { margin: .9rem 0 .3rem; } -.results { list-style: none; margin: 0; padding: 0; } -.result { padding: .85rem 0; cursor: pointer; border-bottom: 1px solid var(--line); } -.result:hover .result-title { text-decoration: underline; } -.result-src { color: var(--muted); font-size: .78rem; margin-bottom: .15rem; } -.result-title { display: block; color: var(--accent); font-size: 1.05rem; text-decoration: none; } -.result-at { color: var(--muted); font-size: .8rem; font-weight: 400; margin-left: .4rem; } -.result-snip { color: var(--fg); font-size: .9rem; line-height: 1.5; margin-top: .2rem; } -.result-snip mark { background: var(--warn-soft); color: inherit; font-weight: 600; padding: 0 .1em; border-radius: .15em; } - -/* ========================================================================== - Papierkorb + Umbenennen - ========================================================================== */ -.list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); } -.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); } -.btn.ghost:hover { color: var(--fg); border-color: var(--line); } -.btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } -.trash-hint, .trash-empty { padding: .6rem .75rem; } -.item.rec.trashed { opacity: .8; } -.item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); } -.days-left { color: var(--muted); } -.days-left.urgent { color: var(--danger); font-weight: 600; } -/* Der Stift sitzt neben der H2 des Detailkopfs, ohne die Zeile umzubrechen. */ -.rename-btn { vertical-align: middle; margin-left: -.25rem; opacity: .55; } -.rename-btn:hover { opacity: 1; } -.item.rec [data-ren] { opacity: .45; } -.item.rec:hover [data-ren] { opacity: 1; } - -@media (max-width: 640px) { - .search-page.landing { padding-top: 6vh; } - .search-go { padding: .55rem .8rem; } -} -/* Abschnittstitel in der Zusammenfassung ("Neuer Projektleiter vorgestellt:"). - Etwas Luft darüber, damit die Blöcke optisch trennen — der Text steht in - white-space: pre-line, die Zeilenumbrüche kommen also aus dem Text selbst. */ -.summary .sum-head { - font-weight: 700; - color: var(--fg); - line-height: 2.2; -} -/* ---- Projektansicht --------------------------------------------------------- - Ordnergitter (Ebene 1) und darin eine Reiterleiste für die Unterreiter - (Ebene 2). Die Reiter sehen bewusst anders aus als die Kopfzeilen-Tabs, damit - klar bleibt: das ist eine Ebene INNERHALB eines Ordners. */ -.proj-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } -.proj-head h3 { margin: 0; flex: 1; } -.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin-top: 10px; } -.proj-card { - display: flex; align-items: center; gap: 10px; cursor: pointer; - background: var(--surface-2); border: 1px solid var(--line); - border-radius: 10px; padding: 12px 14px; transition: border-color .1s, background .1s; -} -.proj-card:hover { border-color: var(--accent); } -.proj-card .grow { flex: 1; min-width: 0; } -.proj-card b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.proj-ico { font-size: 22px; flex: none; } - -.proj-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0 0; border-bottom: 1px solid var(--line); padding-bottom: 8px; } -.ptab { - background: transparent; border: 1px solid transparent; color: var(--muted); - border-radius: 8px 8px 0 0; padding: 6px 12px; cursor: pointer; font: inherit; font-size: .9rem; -} -.ptab:hover { color: var(--fg); background: var(--surface-2); } -.ptab.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); font-weight: 600; } -.ptab.add { color: var(--muted); border-style: dashed; border-color: var(--line); } -.ptab.add:hover { color: var(--accent); border-color: var(--accent); } -.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; } - -.proj-level { margin-top: 14px; } -.proj-level-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } -.proj-level-head .grow { flex: 1; } -.proj-level .item.rec { cursor: pointer; } - -/* Stecknadel in der Aufnahmenliste: blass, solange die Aufnahme nirgends - hängt — angeheftet wird sie gruen (dieselbe Farbe wie „KI ✓“, also - „erledigt/zugeordnet“). Das Icon ist ein SVG mit currentColor; ein 📌-Emoji - wäre in jedem Zustand rot geblieben. */ -.pinbtn { opacity: .45; position: relative; display: inline-flex; align-items: center; gap: 1px; } -.item.rec:hover .pinbtn { opacity: 1; } -.pinbtn.pinned { opacity: 1; border-color: var(--ok); color: var(--ok); background: var(--ok-soft); } -.pin-n { font-size: 10px; font-weight: 700; vertical-align: super; } - -/* ---- Modal ---- */ -.modal-back { - position: fixed; inset: 0; background: rgba(0, 0, 0, .5); - display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; -} -.modal { width: min(520px, 100%); max-height: 85vh; overflow: auto; margin: 0; } -.modal h3 { margin-top: 0; } -.modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } -.modal select[size] { height: auto; padding: 4px; } -.pin-current .item { padding: 6px 10px; } - -/* Logo + Schriftzug führen zurück auf die Aufnahmen. */ -.brand { cursor: pointer; user-select: none; } -.brand:hover .brand-wave { filter: brightness(1.15); } -.brand:hover h1 { color: var(--accent); } +/* Aime — Anruf-Assistent. Zwei Themes über CSS-Variablen; data-theme setzt + index.html vor dem ersten Paint, der Header-Toggle schaltet um. + Akzent = warmes Kupfer (Tonband-Anmutung), Rot bleibt der Aufnahme reserviert. */ + +:root[data-theme="dark"] { + --bg: #14161b; + --surface: #1d222a; + --surface-2: #262c36; + --inset: #10141a; + --line: #303845; + --fg: #e9ecf1; + --muted: #98a2b3; + --accent: #e8804c; + --accent-fg: #17130f; + --accent-soft: rgba(232, 128, 76, .14); + --ok: #55b381; + --ok-soft: rgba(85, 179, 129, .14); + --warn: #e2b93b; + --warn-soft: rgba(226, 185, 59, .14); + --danger: #e5595e; + --danger-soft: rgba(229, 89, 94, .12); + --shadow: none; + --sp0: #5bc0eb; --sp1: #ef8fc0; --sp2: #a8cc4e; --sp3: #e6b84c; + --sp0-w: #8fd6f5; --sp1-w: #f5b5d6; --sp2-w: #c5e07e; --sp3-w: #f0d08a; +} +:root[data-theme="light"] { + --bg: #f4f2ee; + --surface: #ffffff; + --surface-2: #eceae4; + --inset: #f7f6f2; + --line: #ddd8cf; + --fg: #23272e; + --muted: #6d7683; + --accent: #c05f2d; + --accent-fg: #ffffff; + --accent-soft: rgba(192, 95, 45, .1); + --ok: #2f8a5d; + --ok-soft: rgba(47, 138, 93, .12); + --warn: #9a7514; + --warn-soft: rgba(154, 117, 20, .12); + --danger: #c73e43; + --danger-soft: rgba(199, 62, 67, .09); + --shadow: 0 1px 3px rgba(35, 39, 46, .07); + --sp0: #1179a8; --sp1: #c2417f; --sp2: #5f7d16; --sp3: #a4700d; + --sp0-w: #0e6b95; --sp1-w: #ad3a72; --sp2-w: #556f14; --sp3-w: #92640b; +} + +* { box-sizing: border-box; } + +/* Scrollbalken im Logo-Kupfer (Firefox: scrollbar-color; Chromium/WebKit: ::-webkit-*) */ +* { scrollbar-width: thin; scrollbar-color: var(--accent) transparent; } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--accent); border-radius: 5px; + border: 2px solid transparent; background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { background: var(--accent); filter: brightness(1.15); } +::-webkit-scrollbar-corner { background: transparent; } +html { color-scheme: dark; } +:root[data-theme="light"] { color-scheme: light; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif; + transition: background .2s, color .2s; +} +/* Volle Fensterbreite für ALLE Tabs. Früher war nur „Aufnahmen“ breit (.wide) + und der Rest auf 860px zentriert — dadurch sprang die Kopfzeile beim + Tabwechsel in die Mitte. */ +#app { max-width: none; margin: 0 auto; padding: 16px 28px; } + +/* ---- Topbar / Brand ---- */ +.topbar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 6px 0 14px; } +.brand { display: flex; align-items: center; gap: 10px; } +.brand-wave { width: 30px; height: 18px; color: var(--accent); flex: none; } +.topbar h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.01em; } +.topbar .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 6px; } +nav { display: flex; gap: 6px; flex-wrap: wrap; } +.topbar nav { margin-left: 0; } + +/* Aufnahme-Steuerung in der Kopfzeile (zwischen Logo und Tabs) */ +.rec-ctl { display: flex; align-items: center; gap: 10px; margin: 0 auto; } +.recbtn { + width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--danger); + background: var(--danger-soft); color: var(--danger); font-size: 15px; line-height: 1; + cursor: pointer; transition: background .15s, transform .1s; +} +.recbtn:hover { background: var(--danger); color: #fff; } +.recbtn.rec { background: var(--danger); color: #fff; animation: recpulse 1.4s ease-in-out infinite; } +@keyframes recpulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 89, 94, .45); } 50% { box-shadow: 0 0 0 9px rgba(229, 89, 94, 0); } } +.recbtn:disabled { opacity: .55; cursor: default; animation: none; } +.rec-ctl .rectime { font-size: 14px; font-variant-numeric: tabular-nums; color: var(--muted); min-width: 44px; } + +/* Namensmarker während der Aufnahme (Kopfzeile) */ +.rec-ctl .mkin { width: 210px; padding: 6px 10px; font-size: 13px; } +@media (max-width: 1100px) { .rec-ctl .mkin { width: 130px; } } + +/* Live-Platzhalter in der Aufnahmen-Liste */ +.item.rec-live { border-color: var(--danger); background: var(--danger-soft); } +.rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); flex: none; animation: recpulse 1.4s ease-in-out infinite; } +.item.rec-live .rectime { color: var(--danger); font-weight: 650; } +.mkchips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } +.mkchip { + display: inline-flex; align-items: center; gap: 5px; font-size: 12px; + background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 4px 2px 8px; +} +.mkchip .mono { color: var(--muted); } +.mkdel { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 12px; line-height: 1; padding: 2px 4px; } +.mkdel:hover { color: var(--danger); } + +/* Namensmarker-Liste im Detail */ +.markers { list-style: none; margin: 6px 0; padding: 0; } +.markers li { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 13px; } +.mkseek { + background: var(--surface-2); border: 1px solid var(--line); border-radius: 7px; + color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 7px; +} +.mkseek:hover { border-color: var(--accent); } +.mkadd-row { margin-top: 8px; } +.mkadd-row .input { flex: 1; min-width: 120px; } +.tab { + background: transparent; color: var(--muted); border: 1px solid transparent; + padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; + transition: color .12s, background .12s; +} +.tab:hover { color: var(--fg); background: var(--surface-2); } +.tab.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; } +.btn.icon.theme { border-radius: 999px; font-size: 15px; line-height: 1; } + +/* ---- Bausteine ---- */ +.card { + background: var(--surface); border: 1px solid var(--line); border-radius: 14px; + padding: 18px; margin-bottom: 16px; box-shadow: var(--shadow); +} +h2 { margin: 0 0 12px; font-size: 18px; letter-spacing: -.01em; } +.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.grow { flex: 1; } +.btn { + background: var(--surface-2); color: var(--fg); border: 1px solid var(--line); + border-radius: 9px; padding: 8px 14px; cursor: pointer; font-size: 14px; + transition: border-color .12s, background .12s; +} +.btn:hover { border-color: var(--accent); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; } +.btn.primary:hover { filter: brightness(1.07); } +.btn.danger { background: var(--danger-soft); border-color: transparent; color: var(--danger); } +.btn.danger:hover { border-color: var(--danger); } +.btn.big { font-size: 17px; padding: 14px 22px; border-radius: 12px; } +.btn.small { font-size: .8rem; padding: 3px 8px; } +.btn.icon { padding: 6px 9px; flex: none; } +.btn:disabled { opacity: .55; cursor: default; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.lbl { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: .04em; } +.input, select { + width: 100%; background: var(--inset); color: var(--fg); + border: 1px solid var(--line); border-radius: 9px; padding: 9px 12px; font-size: 14px; +} +.input:focus, select:focus { border-color: var(--accent); outline: none; } +.list { display: flex; flex-direction: column; gap: 8px; } +.item { + display: flex; gap: 10px; align-items: center; + background: var(--inset); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; +} +.badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; background: var(--surface-2); color: var(--muted); } +.badge.ok { background: var(--ok-soft); color: var(--ok); } +.badge.warn { background: var(--warn-soft); color: var(--warn); } +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, monospace; font-size: 12px; } +.err { color: var(--danger); } +.err-inline { color: var(--danger); margin-bottom: 6px; } +.hint { font-size: .8rem; color: var(--muted); margin-bottom: 10px; } + +/* ---- Aufnahme ---- */ +.rec-panel { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 22px 0 8px; } +.timer { font-size: 40px; font-variant-numeric: tabular-nums; font-weight: 600; } +audio { width: 100%; margin-bottom: 10px; } + +/* ---- Transkript ---- */ +.transcript { line-height: 2.1; font-size: 16px; } +.transcript .w { cursor: pointer; padding: 1px 2px; border-radius: 4px; transition: background .1s; } +.transcript .w:hover { background: var(--surface-2); } +.transcript .w.active, .w.active { background: var(--accent); color: var(--accent-fg); } +.w.sp0 { color: var(--sp0-w); } .w.sp1 { color: var(--sp1-w); } +.w.sp2 { color: var(--sp2-w); } .w.sp3 { color: var(--sp3-w); } + +.toast { + position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); + background: var(--surface); border: 1px solid var(--accent); color: var(--fg); + padding: 10px 18px; border-radius: 10px; z-index: 99; max-width: 90%; box-shadow: var(--shadow); +} +.toast.err { border-color: var(--danger); } + +/* ---- Analyse ---- */ +.summary-snip { + font-size: .85rem; color: var(--muted); margin-top: 4px; overflow: hidden; + text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; +} +.analysis { margin: 10px 0; padding: 12px; background: var(--inset); border: 1px solid var(--line); border-radius: 10px; } +.analysis h3 { margin: 8px 0 4px; font-size: .9rem; color: var(--muted); } +.summary { margin: 0 0 6px; line-height: 1.55; white-space: pre-line; } +.todos { margin: 0; padding-left: 20px; } +.todos li { margin: 3px 0; } +.parts { margin: 0; padding-left: 20px; } .parts li { margin: 3px 0; } +.clean { line-height: 1.55; background: var(--inset); padding: 8px 10px; border-radius: 8px; margin: 0 0 4px; } + +/* ---- Dialog-Turns / Sprecher ---- */ +.dialog { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; } +.turn { padding: 6px 10px; border-radius: 8px; background: var(--inset); line-height: 1.5; } +.turn .spk { margin-right: 6px; font-weight: 600; } +.turn.sp0 { border-left: 3px solid var(--sp0); } .turn.sp0 .spk { color: var(--sp0); } +.turn.sp1 { border-left: 3px solid var(--sp1); } .turn.sp1 .spk { color: var(--sp1); } +.turn.sp2 { border-left: 3px solid var(--sp2); } .turn.sp2 .spk { color: var(--sp2); } +.turn.sp3 { border-left: 3px solid var(--sp3); } .turn.sp3 .spk { color: var(--sp3); } + +.spk-edit { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; } +.spk-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: var(--inset); border-left: 3px solid var(--line); } +.spk-row.sp0 { border-left-color: var(--sp0); } .spk-row.sp1 { border-left-color: var(--sp1); } +.spk-row.sp2 { border-left-color: var(--sp2); } .spk-row.sp3 { border-left-color: var(--sp3); } +.spk-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex: none; } +.spk-row.sp0 .spk-dot { background: var(--sp0); } .spk-row.sp1 .spk-dot { background: var(--sp1); } +.spk-row.sp2 .spk-dot { background: var(--sp2); } .spk-row.sp3 .spk-dot { background: var(--sp3); } +.spk-name { flex: 1; min-width: 80px; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); color: var(--fg); } +.spk-learn-l { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); white-space: nowrap; } + +.ana-time { font-variant-numeric: tabular-nums; font-weight: 600; } +.ana-time.live { color: var(--accent); } +.ana-line { margin: 2px 0 8px; } + +/* ---- Aufnahmen: Liste links, Detail rechts ---- */ +.rec-layout { display: flex; gap: 8px; align-items: flex-start; } +.rec-splitter { + flex: 0 0 6px; align-self: stretch; cursor: col-resize; border-radius: 3px; + background: transparent; transition: background .12s; +} +.rec-splitter:hover, body.resizing .rec-splitter { background: var(--accent-soft); } +body.resizing { cursor: col-resize; user-select: none; } +.rec-list-pane { + flex: 0 0 340px; position: sticky; top: 12px; max-height: calc(100vh - 90px); + overflow-y: auto; background: var(--surface); border: 1px solid var(--line); + border-radius: 14px; padding: 10px; box-shadow: var(--shadow); +} +.rec-detail-pane { flex: 1 1 auto; min-width: 0; } +.rec-detail-pane .card { margin-bottom: 0; } +.detail-empty { padding: 40px 16px; text-align: center; border: 1px dashed var(--line); border-radius: 14px; } + +.day-group { margin-bottom: 6px; } +.day-head { + display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; + color: var(--muted); text-transform: uppercase; letter-spacing: .03em; padding: 6px 4px; user-select: none; +} +.day-head:hover { color: var(--fg); } +.day-caret { display: inline-block; transition: transform .15s; font-size: 10px; } +.day-group.collapsed .day-caret { transform: rotate(-90deg); } +.day-group.collapsed .day-items { display: none; } +.day-count { margin-left: auto; background: var(--accent-soft); color: var(--accent); border-radius: 20px; padding: 1px 8px; font-size: 11px; font-weight: 650; } +.day-items { display: flex; flex-direction: column; gap: 6px; } + +.item.rec { cursor: pointer; transition: border-color .1s, background .1s; } +.item.rec:hover { border-color: var(--accent); } +.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); } + +/* ---- Detail: Module links, Transkript als eigene Scroll-Spalte rechts ---- */ +.status-line { margin: 4px 0 10px; } +.detail-cols { display: flex; gap: 12px; align-items: flex-start; } +.mod-col { min-width: 0; display: flex; flex-direction: column; gap: 10px; min-height: 60px; } +.mod-col[data-col="main"] { flex: 1 1 54%; } +.mod-col[data-col="side"] { flex: 1 1 46%; } +.mod-col.empty { flex: 0 0 26px; border: 1px dashed var(--line); border-radius: 12px; align-self: stretch; } +.transcript-panel { max-height: calc(100vh - 90px); overflow-y: auto; position: sticky; top: 12px; } +.transcript-panel .mod-head { position: sticky; top: 0; z-index: 1; } +@media (max-width: 1100px) { + .detail-cols { flex-direction: column; } + .mod-col { width: 100%; } + .mod-col.empty { display: none; } + .transcript-panel { max-height: 60vh; position: static; } +} +.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } +.module.dragging { opacity: .45; border-style: dashed; } +.mod-head { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab; + background: var(--surface-2); user-select: none; +} +.mod-head:active { cursor: grabbing; } +.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } +.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; } +.mod-body { padding: 12px; } +.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); } + +/* ---- Sprecher-Verlauf: fester Streifen unter dem Player, volle Breite ---- */ +.viz-top { + position: relative; margin: 4px 0 10px; padding: 10px 12px; background: var(--inset); + border: 1px solid var(--line); border-radius: 12px; +} +.lane-label { cursor: pointer; } +.lane-label:hover { color: var(--fg); text-decoration: underline dotted; } +.spkpop { + position: absolute; left: 12px; z-index: 5; width: 270px; + background: var(--surface); border: 1px solid var(--accent); border-radius: 10px; + padding: 10px 12px; box-shadow: 0 6px 22px rgba(0, 0, 0, .35); +} +.spkpop .input { margin-bottom: 6px; } +.viz-wrap { display: flex; gap: 14px; align-items: stretch; } +.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; } +.lane-row { display: flex; align-items: center; gap: 8px; } +.lane-label { + flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px; + color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; } +.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; } +.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); } +.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); } +.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); } +.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); } +.lane-row.speaking .seg { opacity: 1; } +.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; } +.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; } +@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } } +.playhead { + display: none; position: absolute; top: 0; bottom: 0; width: 2px; + background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2; + transition: left .25s linear; +} +.lanes { flex: 1 1 100%; } + +@media (max-width: 800px) { + #app { padding: 16px; } + .rec-layout { flex-direction: column; } + .rec-list-pane { flex: none !important; width: 100%; position: static; max-height: 340px; } + .rec-splitter { display: none; } + .topbar nav { margin-left: 0; width: 100%; order: 3; } +} + +/* ---- Konferenztisch-Ansicht ---- */ +.hidden { display: none !important; } +.tablescene { position: relative; width: 100%; aspect-ratio: 4 / 3; max-height: 420px; } +.ttable { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + width: 58%; height: 44%; border-radius: 18px; + background: var(--surface-2); border: 2px solid var(--line); + box-shadow: inset 0 0 0 6px var(--inset); +} +.tperson { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 2px; width: 76px; } +.thead { + position: relative; width: 30px; height: 30px; border-radius: 50%; + background: var(--muted); z-index: 2; +} +.tperson.sp0 .thead, .tperson.sp0 .tbody { background: var(--sp0); } +.tperson.sp1 .thead, .tperson.sp1 .tbody { background: var(--sp1); } +.tperson.sp2 .thead, .tperson.sp2 .tbody { background: var(--sp2); } +.tperson.sp3 .thead, .tperson.sp3 .tbody { background: var(--sp3); } +.teyes { position: absolute; left: 0; right: 0; top: 9px; display: flex; justify-content: center; gap: 7px; transition: transform .25s; } +.teye { width: 5px; height: 5px; border-radius: 50%; background: #17130f; } +:root[data-theme="light"] .teye { background: #fff; } +.tmouth { + position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); + width: 9px; height: 3px; border-radius: 2px; background: rgba(0, 0, 0, .55); opacity: 0; +} +:root[data-theme="light"] .tmouth { background: rgba(255, 255, 255, .8); } +.tperson.talking .tmouth { opacity: 1; animation: talkmouth .35s ease-in-out infinite; } +@keyframes talkmouth { 0%, 100% { height: 2px; } 50% { height: 7px; } } +.tperson.talking .thead { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 10px var(--accent-soft); } +.tbody { width: 46px; height: 20px; border-radius: 12px 12px 5px 5px; background: var(--muted); margin-top: -6px; opacity: .85; } +.tname { font-size: 11px; color: var(--muted); max-width: 76px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tperson.talking .tname { color: var(--fg); font-weight: 650; } +.tbubble { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + width: 54%; z-index: 3; + background: var(--surface); border: 2px solid var(--line); border-radius: 14px; + padding: 8px 10px; font-size: 13px; line-height: 1.55; + transition: border-color .2s; +} +.tb-scroll { max-height: 150px; overflow-y: auto; } +.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; } +.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; } +.tbubble .bw:hover { background: var(--surface-2); } +.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); } +.tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); } +.tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); } +.tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); } +.tbubble.sp3 { border-color: var(--sp3); } .tbubble.sp3 .tb-name { color: var(--sp3); } +.tbubble::after { + content: ''; position: absolute; width: 12px; height: 12px; + background: inherit; border: inherit; border-width: 0 2px 2px 0; + display: none; +} +.tbubble[class*="arrow-"]::after { display: block; } +.tbubble.arrow-top::after { top: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(-135deg); } +.tbubble.arrow-bottom::after { bottom: -7px; left: var(--ax, 50%); transform: translateX(-50%) rotate(45deg); } +.tbubble.arrow-left::after { left: -7px; top: 50%; transform: translateY(-50%) rotate(135deg); } +.tbubble.arrow-right::after { right: -7px; top: 50%; transform: translateY(-50%) rotate(-45deg); } +.tidle { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: 13px; text-align: center; } +#viewswitch svg { display: block; } + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} + +/* ========================================================================== + Suche — Google-Anmutung, aber in der Aime-Palette: viel Weißraum, eine + dominante Eingabezeile, Treffer als Textblöcke ohne Karten-Rahmen. + ========================================================================== */ +.search-page { max-width: 46rem; margin: 0 auto; padding: 1rem 1rem 4rem; } +/* Startzustand: Suchfeld optisch mittig, wie eine leere Suchseite. */ +.search-page.landing { padding-top: 12vh; } +.search-page.has-results { padding-top: 1.25rem; } + +.search-box { + display: flex; align-items: center; gap: .5rem; + border: 1px solid var(--line); border-radius: 2rem; + background: var(--surface); box-shadow: var(--shadow); + padding: .35rem .35rem .35rem 1rem; + transition: box-shadow .15s, border-color .15s; +} +.search-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search-ico { opacity: .55; font-size: 1rem; } +.search-input { + flex: 1; border: 0; outline: 0; background: transparent; color: var(--fg); + font: inherit; font-size: 1.05rem; padding: .55rem 0; +} +.search-clear { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: .95rem; padding: .25rem .4rem; } +.search-clear:hover { color: var(--fg); } +.search-go { + border: 0; border-radius: 1.6rem; cursor: pointer; + background: var(--accent); color: var(--accent-fg); + font: inherit; font-weight: 600; padding: .55rem 1.1rem; +} +.search-go:hover { filter: brightness(1.07); } + +.search-hints { margin-top: 1.5rem; display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; } +.search-hints .example { + border: 1px solid var(--line); border-radius: 1rem; background: var(--surface-2); + color: var(--fg); font: inherit; font-size: .88rem; padding: .35rem .8rem; cursor: pointer; text-align: left; +} +.search-hints .example:hover { border-color: var(--accent); color: var(--accent); } + +.searching { padding: 1.5rem 0; } +.search-empty { padding: 1.5rem 0; } + +/* Antwortkasten: abgesetzt wie eine KI-Übersicht, nicht wie ein Treffer. */ +.answer-box { + margin: 1.25rem 0 .5rem; padding: .9rem 1rem; + background: var(--accent-soft); border: 1px solid var(--line); border-radius: .75rem; +} +.answer-head { font-weight: 600; font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .5rem; } +.answer-body { line-height: 1.55; white-space: pre-wrap; } +/* Platzhalterbalken, solange das LLM rechnet (30-90 s sind hier normal). */ +.answer-body.skeleton { display: flex; flex-direction: column; gap: .45rem; } +.answer-body.skeleton span { + height: .7rem; border-radius: .35rem; background: var(--line); opacity: .55; + animation: pulse 1.4s ease-in-out infinite; +} +.answer-body.skeleton span:nth-child(1) { width: 92%; } +.answer-body.skeleton span:nth-child(2) { width: 78%; animation-delay: .18s; } +.answer-body.skeleton span:nth-child(3) { width: 45%; animation-delay: .36s; } +@keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: .7; } } +.answer-src { margin-top: .7rem; display: flex; flex-wrap: wrap; gap: .4rem; } +.src-chip { + border: 1px solid var(--line); background: var(--surface); color: var(--fg); + border-radius: 1rem; font: inherit; font-size: .8rem; padding: .2rem .6rem; cursor: pointer; +} +.src-chip:hover { border-color: var(--accent); color: var(--accent); } + +.result-meta { margin: .9rem 0 .3rem; } +.results { list-style: none; margin: 0; padding: 0; } +.result { padding: .85rem 0; cursor: pointer; border-bottom: 1px solid var(--line); } +.result:hover .result-title { text-decoration: underline; } +.result-src { color: var(--muted); font-size: .78rem; margin-bottom: .15rem; } +.result-title { display: block; color: var(--accent); font-size: 1.05rem; text-decoration: none; } +.result-at { color: var(--muted); font-size: .8rem; font-weight: 400; margin-left: .4rem; } +.result-snip { color: var(--fg); font-size: .9rem; line-height: 1.5; margin-top: .2rem; } +.result-snip mark { background: var(--warn-soft); color: inherit; font-weight: 600; padding: 0 .1em; border-radius: .15em; } + +/* ========================================================================== + Papierkorb + Umbenennen + ========================================================================== */ +.list-bar { display: flex; justify-content: flex-end; padding: .35rem .5rem; border-bottom: 1px solid var(--line); } +.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); } +.btn.ghost:hover { color: var(--fg); border-color: var(--line); } +.btn.ghost.active { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); } +.trash-hint, .trash-empty { padding: .6rem .75rem; } +.item.rec.trashed { opacity: .8; } +.item.rec.trashed b { text-decoration: line-through; text-decoration-color: var(--muted); } +.days-left { color: var(--muted); } +.days-left.urgent { color: var(--danger); font-weight: 600; } +/* Der Stift sitzt neben der H2 des Detailkopfs, ohne die Zeile umzubrechen. */ +.rename-btn { vertical-align: middle; margin-left: -.25rem; opacity: .55; } +.rename-btn:hover { opacity: 1; } +.item.rec [data-ren] { opacity: .45; } +.item.rec:hover [data-ren] { opacity: 1; } + +@media (max-width: 640px) { + .search-page.landing { padding-top: 6vh; } + .search-go { padding: .55rem .8rem; } +} +/* Abschnittstitel in der Zusammenfassung ("Neuer Projektleiter vorgestellt:"). + Etwas Luft darüber, damit die Blöcke optisch trennen — der Text steht in + white-space: pre-line, die Zeilenumbrüche kommen also aus dem Text selbst. */ +.summary .sum-head { + font-weight: 700; + color: var(--fg); + line-height: 2.2; +} +/* ---- Projektansicht --------------------------------------------------------- + Ordnergitter (Ebene 1) und darin eine Reiterleiste für die Unterreiter + (Ebene 2). Die Reiter sehen bewusst anders aus als die Kopfzeilen-Tabs, damit + klar bleibt: das ist eine Ebene INNERHALB eines Ordners. */ +.proj-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } +.proj-head h3 { margin: 0; flex: 1; } +.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin-top: 10px; } +.proj-card { + display: flex; align-items: center; gap: 10px; cursor: pointer; + background: var(--surface-2); border: 1px solid var(--line); + border-radius: 10px; padding: 12px 14px; transition: border-color .1s, background .1s; +} +.proj-card:hover { border-color: var(--accent); } +.proj-card .grow { flex: 1; min-width: 0; } +.proj-card b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.proj-ico { font-size: 22px; flex: none; } + +.proj-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0 0; border-bottom: 1px solid var(--line); padding-bottom: 8px; } +.ptab { + background: transparent; border: 1px solid transparent; color: var(--muted); + border-radius: 8px 8px 0 0; padding: 6px 12px; cursor: pointer; font: inherit; font-size: .9rem; +} +.ptab:hover { color: var(--fg); background: var(--surface-2); } +.ptab.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); font-weight: 600; } +.ptab.add { color: var(--muted); border-style: dashed; border-color: var(--line); } +.ptab.add:hover { color: var(--accent); border-color: var(--accent); } +.ptab-n { font-size: 11px; opacity: .7; margin-left: 4px; } + +.proj-level { margin-top: 14px; } +.proj-level-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } +.proj-level-head .grow { flex: 1; } +.proj-level .item.rec { cursor: pointer; } + +/* Stecknadel in der Aufnahmenliste: blass, solange die Aufnahme nirgends + hängt — angeheftet wird sie gruen (dieselbe Farbe wie „KI ✓“, also + „erledigt/zugeordnet“). Das Icon ist ein SVG mit currentColor; ein 📌-Emoji + wäre in jedem Zustand rot geblieben. */ +.pinbtn { opacity: .45; position: relative; display: inline-flex; align-items: center; gap: 1px; } +.item.rec:hover .pinbtn { opacity: 1; } +.pinbtn.pinned { opacity: 1; border-color: var(--ok); color: var(--ok); background: var(--ok-soft); } +.pin-n { font-size: 10px; font-weight: 700; vertical-align: super; } + +/* ---- Modal ---- */ +.modal-back { + position: fixed; inset: 0; background: rgba(0, 0, 0, .5); + display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; +} +.modal { width: min(520px, 100%); max-height: 85vh; overflow: auto; margin: 0; } +.modal h3 { margin-top: 0; } +.modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } +.modal select[size] { height: auto; padding: 4px; } +.pin-current .item { padding: 6px 10px; } + +/* Logo + Schriftzug führen zurück auf die Aufnahmen. */ +.brand { cursor: pointer; user-select: none; } +.brand:hover .brand-wave { filter: brightness(1.15); } +.brand:hover h1 { color: var(--accent); } /* Ein Flex-Kind schrumpft von Haus aus nicht unter seine Inhaltsbreite (min-width: auto). In der Aufnahmenliste schob ein langer Titel deshalb die @@ -566,3 +566,20 @@ body.resizing { cursor: col-resize; user-select: none; } Seit der Stecknadel dazukam (drei Knöpfe) fiel das sofort auf. */ .item > .grow { min-width: 0; overflow-wrap: anywhere; } .item > .btn { flex: none; } + +/* Stimmen-Tab: eine Person, mehrere Namen (Chips) und mehrere Stimmproben. + Die Chips müssen umbrechen können — manche Personen tragen drei Namen. */ +.item.person { align-items: flex-start; gap: 8px; flex-wrap: wrap; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 4px; } +.chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 999px; font-size: 12px; + background: var(--surface-2); border: 1px solid var(--line); +} +.chipx { + background: none; border: 0; color: var(--muted); cursor: pointer; + font-size: 11px; line-height: 1; padding: 0; +} +.chipx:hover { color: var(--danger); } +.btn.tiny, .input.tiny { font-size: 12px; padding: 2px 8px; height: auto; } +.input.tiny { max-width: 210px; } diff --git a/docs/04-diarization.md b/docs/04-diarization.md index f74af16..8fcf6ec 100644 --- a/docs/04-diarization.md +++ b/docs/04-diarization.md @@ -25,7 +25,7 @@ flowchart TB MK -- ja --> CON["must-link: gleicher Name = 1 Cluster,
ABER nur wenn die Cluster akustisch passen
(cos ≥ 0,6 × gelernte Schwelle) —
sonst Marker verwerfen (falsch getippter Zeitpunkt)
cannot-link: verschiedene Namen verschmelzen NIE"] MK -- nein --> VP CON --> VP{"Bekannte Stimmabdrücke?"} - VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen
(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zum Zweitbesten,
ein Name höchstens 1 Cluster)
→ zusätzliches cannot-link
sichert vor allem den Box-Besitzer"] + VP -- ja --> VPC["Cluster ohne Marker gegen Voiceprints prüfen
(RAW-Kosinus ≥ 0,55 + Abstand 0,05 zur
nächstbesten PERSON, eine Person höchstens 1 Cluster)
→ zusätzliches cannot-link
sichert vor allem den Box-Besitzer"] VP -- nein --> B VPC --> B["Stufe B: weiter mergen bis Schwelle
(GMM-Wert) ODER bis gepinnte Anzahl"] GMM --> B @@ -86,7 +86,7 @@ sequenceDiagram D-->>S: Segmente S->>S: applyMarkers(): resolveMarkers() → Cluster benennen S->>DB: speaker_names + markers.speaker/conf - S->>DB: upsertVoiceprint(name) je benanntem Sprecher + S->>DB: learnVoice(name) je benanntem Sprecher
(neue Probe der PERSON hinter dem Namen) ``` - **Auflösung** (`diar.resolveMarkers`): asymmetrisches Fenster um den Marker (25 s zurück / 8 s vorwärts, @@ -130,10 +130,14 @@ Jetzt gehen die Voiceprints **in** die Diarisierung (`diarize(wav, n, {markers, sitzt einer sicher darauf, wirkt der Name wie ein Marker-Tag = cannot-link. - Vergleich im **RAW**-Raum (Voiceprints sind unzentriert gespeichert), deshalb streng: `DIAR_VP_MIN` 0,55 - **und** 0,05 Abstand zum Zweitbesten, ein Name beansprucht höchstens ein Cluster (bester Score gewinnt). -- Namensschlüssel = **Vorname, kleingeschrieben**: derselbe Mensch ist als „Sascha" und „Sascha Wolf" - enrolled (ebenso Kim/Kim Nebendahl, Fabienne/Fabienne Nachtsheim) — zwei Einträge dürfen nicht zu einem - cannot-link zwischen seinen eigenen Clustern führen. + **und** 0,05 Abstand zum Zweitbesten, eine **Person** beansprucht höchstens ein Cluster (bester Score gewinnt). +- Schlüssel ist seit 2026-08-07 die **Person** (`diar.identityKey` über `person_names`), nicht mehr der + kleingeschriebene Vorname. Der Vornamens-Trick sollte „Sascha" und „Sascha Wolf" zusammenhalten, setzte dabei + aber auch „Markus Ufer" und „Markus Witsch" gleich (Kosinus 0,241 = zwei Menschen). Siehe „Eine Person, + mehrere Namen" unten. +- Ebenfalls seit 2026-08-07: Score und Abstand rechnen **pro Person über deren besten Abdruck**. Vorher lief + beides über einzelne Abdrücke — zwei Abdrücke desselben Menschen (0,928) rissen die Abstandsprüfung + zuverlässig, die Zwangsbedingung war also faktisch abgeschaltet. - Gegenprobe ohne Regression: #79 → 4 (vorher 3), #70 → 4, #68 → 2, #74 → 20 (unverändert mit und ohne Voiceprints). Auf #79 wird Sascha mit cos 0,861 erkannt, auf #74 fünfzehn Personen (Kai 0,656 … Niklas 0,966). @@ -199,6 +203,68 @@ flowchart LR 726 Turns → 1932 Fenster → **8 Sprecher**, 6 Live-Marker verankert, Diarisierung 13,5 min, keine OOM-Meldung. +## Eine Person, mehrere Namen (seit 2026-08-07) + +Bis dahin war der **Name** die Identität. Das trägt nicht: derselbe Mensch wird in einer Aufnahme als +„Sascha" und in der nächsten als „Sascha Wolf" benannt, und schon existierten zwei Einträge derselben +Stimme. Auf Aufnahme #101 (34 s, **zwei** Menschen) hieß das Ergebnis: `Michael`, `Sascha`, +`Sprecher 3`, `Sascha Wolf` — vier Sprecher, einer davon doppelt. + +**Messung des Altbestands** (42 Abdrücke, `lab/vpcos.mjs`, Kosinus innerhalb gleicher Vornamen): + +| Paar | cos | Befund | +|---|---|---| +| `Jens` ~ `Jens Kuppler` | **1,000** | exakte Dublette | +| `Sascha` ~ `Sascha Wolf` | 0,928 | dieselbe Stimme, zwei Einträge | +| `Kim` ~ `Kim Nebendahl` | 0,899 | dieselbe Stimme | +| `Fabienne` ~ `Fabienne Nachtsheim` | 0,785 | dieselbe Stimme | +| `Markus` ~ `Markus Witsch` | 0,429 | verschiedene Menschen | +| `Fabio` ~ `Fabio Haase` | 0,261 | passt nicht zusammen | +| `Johannes` ~ `Johannes Jeckel` | 0,115 | passt nicht zusammen | +| `Michael Pastler` ~ `Michael Wagner` | −0,011 | klar verschiedene Menschen | + +Daraus folgen zwei Dinge. Erstens: **automatisch nach Vornamen gruppieren wäre falsch** — genau das tat +der alte `normName`-Trick in `diar.js`, und er setzte damit „Markus Ufer" und „Markus Witsch" gleich. +Zweitens: ein Teil des Bestands war **kontaminiert** (`Johannes` lag mit 0,912 an *Sascha Wolf*, aber mit +0,115 an *Johannes Jeckel*), also aus einem falsch verschmolzenen Cluster gelernt — dasselbe Muster wie +bei Merle am 2026-07-29. Der Bestand wurde deshalb geleert (Dump: `backup/voiceprints-2026-08-07.sql`) +und wird neu gelernt. + +**Modell jetzt:** + +```mermaid +erDiagram + persons ||--|{ person_names : "heißt auch" + persons ||--o{ voiceprints : "klingt wie" + persons { int id string display_name } + person_names { string name int person_id } + voiceprints { int id int person_id string vec string model } +``` + +- `person_names.name` ist Primärschlüssel → ein Name gehört zu **genau einer** Person. Die Kollation + `utf8mb4_general_ci` macht das absichtlich case-insensitiv („sascha" = „Sascha"). +- **Mehrere Proben pro Person** statt eines laufenden Mittels (`AIME_MAX_PRINTS`, Standard 8, älteste + fallen raus). Proben aus verschiedenen Sessions und Headsets zu mitteln verwischt gerade das, was die + Stimme ausmacht; bewertet wird eine Person mit ihrem **besten** Abdruck. +- **Abstand zwischen Personen, nicht zwischen Abdrücken** (`spk.bestMatch`, `diar.clusterWindows`). Vorher + rissen zwei Abdrücke desselben Menschen (0,928) die 0,05-Abstandsprüfung — die Zwangsbedingung war + damit faktisch abgeschaltet. +- Identität überall gleich: `diar.identityKey(name, persons)` bildet Marker-Namen UND Abdrücke auf + `p` ab, `db.personNameMap()` liefert die Karte. Ohne Personen-Eintrag zählt der **volle** + Name (nicht mehr nur der Vorname). +- GUI „Stimmen" zeigt Personen mit ihren Namen als Chips: Namen hinzufügen/entfernen, zwei Personen + zusammenführen, Proben verwerfen. Vergibt man einen Namen, der schon einer anderen Person gehört, + werden beide zusammengeführt — dann ist genau das gemeint. + +**Verifiziert an #101:** „Michael" und „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name an dieselbe +Person gehängt → Re-Diarisierung benennt nur noch `{"speaker_00":"Michael","speaker_01":"Sascha"}` statt +vorher drei Namen für zwei Menschen, Log: `Cluster 1 = bekannte Stimme "Sascha" (cos 0,917)`. + +⚠️ **Was das NICHT löst:** die Cluster-Anzahl. #101 bleibt bei 4 Clustern für 2 Menschen (17 Roh-Cluster +→ 13 schwache gefaltet → 4), die beiden unbenannten heißen weiter „Sprecher 1/2". Das ist die +Über-Segmentierung kurzer Clips, diagnostiziert am 2026-07-31 (Mittelwert-Zentrierung erzeugt bei wenigen +Fenstern einen negativen Sockel von −1/(n−1)) — ein eigener, noch offener Punkt. + ## Harte Grenzen / Sackgassen - **Überlappendes gleichzeitiges Sprechen** ist mit Mono-Mix prinzipiell nicht trennbar. @@ -218,14 +284,16 @@ flowchart LR DIAR["Diarization: speaker_00, speaker_01, …"] --> AUTOID["identifySpeakers():
Abgleich gegen gespeicherte Voiceprints"] AUTOID --> MERGE2["mergeSpeakerNames():
füllt nur Lücken, manuelle Namen gewinnen"] GUI["GUI Sprecher-Editor:
Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"] - API --> VP[("voiceprints-Tabelle
1 Zentroid pro Name
(laufender Mittelwert, Spalte n)")] + API --> PERS[("persons + person_names
ein Mensch, mehrere Namen")] + PERS --> VP[("voiceprints-Tabelle
mehrere Proben PRO PERSON
(max. 8, älteste fallen raus)")] API --> NAMES[("recordings.speaker_names
JSON, manuell gewinnt")] VP --> AUTOID ``` - Beim Lernen wird der längste zusammenhängende Sprech-Abschnitt (≥ 1,5 s, **max. 20–25 s**) des Sprechers als - TitaNet-Embedding in den Zentroid gefaltet (`db.upsertVoiceprint`) → zukünftige Aufnahmen identifizieren die - Person automatisch. + TitaNet-Embedding **als neue Probe der Person** gespeichert (`db.learnVoice`) → zukünftige Aufnahmen + identifizieren die Person automatisch. Bis 2026-08-07 wurde stattdessen EIN Zentroid pro Name laufend + gemittelt; das verwischt gerade die Merkmale, wenn die Proben aus verschiedenen Sessions und Headsets kommen. - ⚠️ **Warum die Schnitte kurz sein MÜSSEN (2026-07-27 root-caused):** das TitaNet-ONNX bricht bei langen Eingaben ab — reproduziert bei 180 s: `Non-zero status code returned while running Where node … Attempting to broadcast an axis by a dimension diff --git a/docs/08-changelog.md b/docs/08-changelog.md index cf3db92..e9a3c4c 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,43 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-08-07 (3) — Eine Person darf mehrere Namen haben (und mehrere Stimmproben) + +**Warum:** die Identität war der NAME. Derselbe Mensch, in einer Aufnahme „Sascha" und in der nächsten +„Sascha Wolf" genannt, existierte damit zweimal — und beide Einträge konnten je ein Cluster benennen. +Aufnahme #101 (34 s, **zwei** Menschen) zeigte deshalb `Michael`, `Sascha`, `Sprecher 3`, `Sascha Wolf`. +`diar.js` hat das mit einem Vornamens-Trick überdeckt, der aber „Markus Ufer" und „Markus Witsch" +gleichsetzt (Kosinus 0,241 = zwei Menschen). + +**Neu:** Tabellen `persons` + `person_names` (Name = Primärschlüssel, gehört zu genau einer Person), +`voiceprints.person_id`. Eine Person darf beliebig viele Namen **und** beliebig viele Stimmproben haben +(`AIME_MAX_PRINTS`, Standard 8) — bewertet wird sie mit ihrer **besten** Probe statt mit einem laufenden +Mittel über verschiedene Sessions und Headsets. Score UND Abstand rechnen jetzt zwischen **Personen**; +vorher rissen zwei Abdrücke desselben Menschen die 0,05-Abstandsprüfung, wodurch die Zwangsbedingung in +der Diarisierung faktisch abgeschaltet war. Identität überall: `diar.identityKey()` bildet Marker-Namen +und Abdrücke auf `p` ab. + +**GUI „Stimmen":** Personen statt Abdrücke — Namen als Chips (hinzufügen/entfernen), zwei Personen +zusammenführen, Proben verwerfen. Ein Name, der schon einer anderen Person gehört, führt beide zusammen. + +**Altbestand geleert** (Dump `backup/voiceprints-2026-08-07.sql`, 42 Abdrücke): die Messung mit +`lab/vpcos.mjs` zeigte neben echten Dubletten (`Jens` ~ `Jens Kuppler` = **1,000**) auch kontaminierte +Einträge — `Johannes` lag mit 0,912 an *Sascha Wolf*, aber mit 0,115 an *Johannes Jeckel*, war also aus +einem falsch verschmolzenen Cluster gelernt (wie Merle am 2026-07-29). Neu lernen statt reparieren. + +**Verifiziert an #101:** „Michael" + „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name derselben Person +→ Re-Diarisierung benennt `{"speaker_00":"Michael","speaker_01":"Sascha"}` (vorher drei Namen für zwei +Menschen), Log `Cluster 1 = bekannte Stimme "Sascha" (cos 0,917)`. GUI mit Playwright geprüft: keine +JS-Fehler, zwei Personen, Chips „Sascha" + „Sascha Wolf". + +⚠️ **Nicht behoben, eigener Punkt:** die Cluster-ANZAHL. #101 bleibt 4 Cluster für 2 Menschen; die beiden +unbenannten heißen „Sprecher 1/2". Ursache ist die Über-Segmentierung kurzer Clips (Diagnose 2026-07-31, +Kap. 04) — dafür liegen drei Optionen vor, keine davon ist gewählt. + +Dateien: `app/db.js`, `app/spk.js`, `app/diar.js`, `app/server.js`, `app/recorder.js`, +`app/web/src/main.js`, `app/web/src/style.css` (Board-Backups `.bak-persons`), `app/lab/vpcos.mjs`, +Kapitel [04 – Sprechertrennung](04-diarization.md). + ## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig **Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber