Files
SaschaandClaude Opus 5 ec1658ba85 fix(stop): Antwort in 0,04 s statt bis 51 s — MP3-Encode in den Hintergrund
POST /api/record/stop wartete auf den kompletten loudnorm-Encode: gemessen
~1,4 s pro aufgenommener Minute (#114: 35,5 min -> 50,9 s). Sichtbar war
davon nichts als ein ausgegrauter Knopf; der Reiterwechsel baute den Knopf
per render() wieder klickbar auf und der zweite Klick lief in
"not recording", weil stopAndSave() current sofort auf null setzt.

- recorder.js: encodeMp3() ohne await, schreibt <name>.mp3.part und
  benennt erst danach um. MP3_ARGS + "-f mp3" (ffmpeg leitet den Muxer aus
  der Endung ab und scheitert an .part).
- web/src/main.js: state.stopping ueberlebt render() -> Knopf bleibt
  gesperrt, Uhr friert ein, Toast mit geschaetzter Wartezeit, zweiter Klick
  verworfen, "not recording" kein roter Fehler. Player laedt nach, solange
  /media 404 liefert (#mp3wait, 5-s-Retry, Abbruch nach 5 min).
- server.js: sweepOrphans erkennt rec_*.mp3.part.

Gegenprobe: 0,033 s / 0,044 s Stop-Antwort, "mp3 fertig" ~2 s spaeter,
64 kbit CBR / 48 kHz / mono ohne Xing-Tabelle, /media 200.

Doku: Kapitel 03 (neuer Abschnitt + Vorher/Nachher-Sequenzdiagramm),
Changelog 2026-08-20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:11:48 +02:00

964 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import * as sysstat from './sysstat.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(`(?<![\\p{L}\\p{N}])${esc(p.from)}(?![\\p{L}\\p{N}])`, 'gu'), p.to);
return out;
};
const todos = Array.isArray(rec.todos) ? rec.todos.map(t => 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(() => {});
// Kontextnotiz des Nutzers (Freitext, nach dem Stop getippt) geht als
// Hintergrundwissen mit. Bewusst JETZT frisch gelesen und nicht aus `rec`
// von oben: die Sprecher-Erkennung darüber braucht Minuten, der Nutzer tippt
// in dieser Zeit — was bis hierher gespeichert ist, zählt noch mit.
const note = await db.getContextNote(id).catch(() => '');
const a = await llm.analyze(text, { labeled, glossary, context: note });
// 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() }));
// Kopfzeilen-Anzeige: Platte, CPU, RAM, Temperatur. /proc + statfs, dazu ein
// gepufferter Größenscan über recordings/ — kein df/du-Prozess.
app.get('/api/sysstat', wrap(async (req, res) => res.json(await sysstat.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 });
}));
// Kontextnotiz speichern (leerer Text löscht sie). Der Rückgabewert `applies`
// sagt der GUI ehrlich, ob die Notiz die LAUFENDE Analyse noch erreicht — das
// entscheidet sich daran, wie weit die Verarbeitung ist:
// 'auto' = wird noch transkribiert; die Analyse liest die Notiz danach
// 'running' = Analyse läuft bereits, sie kann die Notiz verpasst haben
// 'reanalyze' = Analyse ist fertig/fehlgeschlagen → nur ein neuer Lauf nutzt sie
app.patch('/api/recordings/:id/context', wrap(async (req, res) => {
const id = +req.params.id;
const row = await db.setContextNote(id, (req.body && req.body.context_note) || '');
if (!row) return res.status(404).json({ error: 'not found' });
const applies = row.status === 'transcribing' ? 'auto'
: row.analysis_status === 'pending' ? 'running' : 'reanalyze';
res.json({ ok: true, id, context_note: row.context_note || '',
context_note_at: row.context_note_at, applies, max: db.CONTEXT_MAX });
}));
// --- 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 });
}));
// Drag&Drop in der Projektansicht: Pin auf eine andere Ebene desselben Ordners
// ziehen. tab_id 0 = zurück auf die Ordner-Ebene.
app.patch('/api/projects/:id/pins/:pinId', wrap(async (req, res) => {
const tabId = +((req.body && req.body.tab_id) || 0);
const r = await db.movePin(+req.params.pinId, +req.params.id, tabId);
if (!r) return res.status(404).json({ error: 'Anheftung oder Zielreiter nicht gefunden' });
res.json(r);
}));
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)) {
// `.mp3.part` = ein Encode, der gerade laeuft (recorder.js encodeMp3).
// Faellt der Prozess mitten im Encode aus, bleibt die Halbdatei liegen —
// die Altersgrenze ORPHAN_MIN_AGE_MS raeumt sie dann wie jede Waise weg.
if (!/^rec_.*\.(mp3|wav)(\.part)?$/.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));
}));