#74 (22 Teilnehmer) ergab automatisch 44 Sprecher, dieselbe Stimme mehrfach. Ursache gemessen: ein Zentroid pro sherpa-Cluster aus max. 3 Schnitten, fester Kosinus auf rohen Embeddings (Kreuz-Turn-Kosinus ~0,4 = keine Trennschaerfe). v2: sherpa liefert nur Turn-Grenzen, Identitaet aus 3-s-Fenster-Embeddings, Mittelwert-Zentrierung, Average-Linkage-AHC, Weak-Fold, Schwelle pro Aufnahme per 2-Gauss-EM gelernt. Validiert: #74 44->20, #70 ->4, #68 ->2. Live-Namensmarker: Namen waehrend der Aufnahme tippen; Zeitstempel benennt spaeter den Cluster und dient als must-/cannot-link-Zwangsbedingung.
505 lines
26 KiB
JavaScript
505 lines
26 KiB
JavaScript
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';
|
||
const pexec = promisify(execFile);
|
||
|
||
// 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.
|
||
function buildBasis(rec, nameMap = {}) {
|
||
const words = rec.words || [];
|
||
const hasSpk = words.some(w => w.speaker);
|
||
if (!hasSpk) return { text: rec.transcript || '', labeled: false };
|
||
const turns = diar.toTurns(words);
|
||
const map = {}; let n = 0;
|
||
const label = sp => nameMap[sp] || (map[sp] || (map[sp] = `Sprecher ${++n}`));
|
||
const text = turns.map(t => `${label(t.speaker)}: ${t.text}`).join('\n');
|
||
return { text, labeled: true };
|
||
}
|
||
|
||
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 };
|
||
}
|
||
const map = {};
|
||
for (const sp of speakers) {
|
||
const r = best[sp]; if (!r) continue;
|
||
const seg = `/tmp/idseg_${recData.id}_${sp}.wav`;
|
||
try {
|
||
await pexec('ffmpeg', ['-y', '-i', wavPath, '-ss', String(r.start / 1000), '-to', String(r.end / 1000), '-ar', '16000', '-ac', '1', seg]);
|
||
const emb = await spk.embed(seg);
|
||
const m = spk.bestMatch(emb, vps);
|
||
if (m) map[sp] = m.name;
|
||
} catch (e) { console.error('identify', sp, e.message); }
|
||
finally { try { fs.unlinkSync(seg); } catch {} }
|
||
}
|
||
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 (only confident hits drive the merge)
|
||
const byName = {};
|
||
for (const m of resolved) {
|
||
if (!m.speaker || m.conf === 'low' || m.conf === null) continue;
|
||
(byName[m.name] = byName[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;
|
||
}
|
||
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;
|
||
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).
|
||
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(() => {});
|
||
const names = { ...nameMap, ...(rec.speaker_names || {}) };
|
||
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 } = buildBasis(rec, names);
|
||
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);
|
||
if (spkOrder.length) a.participants = spkOrder.map((sp, i) => names[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' });
|
||
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)`);
|
||
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(() => []);
|
||
const { transcript, words } = await rec.transcribeWav(wavFile, numSpeakers, { markers });
|
||
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
|
||
}
|
||
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' });
|
||
}));
|
||
app.delete('/api/recordings/:id', wrap(async (req, res) => { await db.deleteRecording(+req.params.id); res.json({ ok: 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).
|
||
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 {
|
||
const segs = await diar.diarize(wav, numSpeakers, { markers: r.markers || [] });
|
||
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);
|
||
|
||
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);
|
||
res.json({ ok: true, speaker_names: r2.speaker_names || {}, enrolled, learnMsg });
|
||
}));
|
||
|
||
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))
|
||
.finally(() => app.listen(PORT, '0.0.0.0', () => console.log(`callrec listening on :${PORT}`)));
|