app/ = /opt/callrec (Node-Backend, Vite-Frontend, Python-Inferenz, Audio-Skripte, rkllm_server.cpp), system/ = systemd-Units, WirePlumber/PipeWire-Drop-ins, sudoers. Modelle, Binaries, Aufnahmen und node_modules bleiben draussen (.gitignore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
9.0 KiB
JavaScript
189 lines
9.0 KiB
JavaScript
import mysql from 'mysql2/promise';
|
|
|
|
export const pool = mysql.createPool({
|
|
host: 'localhost', user: 'callrec', password: 'callrec123',
|
|
database: 'callrec', connectionLimit: 5, waitForConnections: true
|
|
});
|
|
|
|
// Idempotent migration: the LLM analysis columns (auto title lives in `title`).
|
|
// MariaDB supports ADD COLUMN IF NOT EXISTS.
|
|
export async function ensureSchema() {
|
|
await pool.query(`ALTER TABLE recordings
|
|
ADD COLUMN IF NOT EXISTS summary TEXT NULL,
|
|
ADD COLUMN IF NOT EXISTS todos TEXT NULL,
|
|
ADD COLUMN IF NOT EXISTS participants TEXT NULL,
|
|
ADD COLUMN IF NOT EXISTS transcript_clean TEXT NULL,
|
|
ADD COLUMN IF NOT EXISTS wav_file VARCHAR(255) NULL,
|
|
ADD COLUMN IF NOT EXISTS speaker_names TEXT NULL,
|
|
ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
|
ADD COLUMN IF NOT EXISTS analysis_started_at BIGINT NULL,
|
|
ADD COLUMN IF NOT EXISTS analysis_ms INT NULL`);
|
|
await pool.query(`ALTER TABLE words ADD COLUMN IF NOT EXISTS speaker VARCHAR(32) NULL`);
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS voiceprints (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(128) NOT NULL,
|
|
dim INT NOT NULL,
|
|
vec MEDIUMTEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
|
// n = number of samples averaged into this voiceprint (running centroid).
|
|
await pool.query(`ALTER TABLE voiceprints ADD COLUMN IF NOT EXISTS n INT NOT NULL DEFAULT 1`);
|
|
// 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.
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS glossary (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
term VARCHAR(190) NOT NULL UNIQUE,
|
|
soundalikes TEXT NULL,
|
|
enabled TINYINT NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
|
}
|
|
|
|
export async function listGlossary() {
|
|
const [rows] = await pool.query('SELECT id, term, soundalikes, enabled, created_at FROM glossary ORDER BY term');
|
|
return rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
|
}
|
|
export async function addGlossary(term, soundalikes) {
|
|
await pool.execute(
|
|
'INSERT INTO glossary (term, soundalikes) VALUES (?,?) ON DUPLICATE KEY UPDATE soundalikes=VALUES(soundalikes), enabled=1',
|
|
[term, soundalikes || null]);
|
|
const [rows] = await pool.execute('SELECT id, term, soundalikes, enabled FROM glossary WHERE term=?', [term]);
|
|
return rows[0] ? { ...rows[0], enabled: !!rows[0].enabled } : null;
|
|
}
|
|
export async function deleteGlossary(id) {
|
|
await pool.execute('DELETE FROM glossary WHERE id=?', [id]);
|
|
}
|
|
|
|
export async function addVoiceprint(name, vec) {
|
|
const [res] = await pool.execute(
|
|
'INSERT INTO voiceprints (name, dim, vec) VALUES (?,?,?)', [name, vec.length, JSON.stringify(vec)]);
|
|
return res.insertId;
|
|
}
|
|
|
|
// 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) {
|
|
const [rows] = await pool.query('SELECT id, vec, n FROM voiceprints WHERE name=? ORDER BY id LIMIT 1', [name]);
|
|
if (!rows.length) {
|
|
const [res] = await pool.execute('INSERT INTO voiceprints (name, dim, vec, n) VALUES (?,?,?,1)',
|
|
[name, vec.length, JSON.stringify(vec)]);
|
|
return res.insertId;
|
|
}
|
|
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;
|
|
}
|
|
export async function listVoiceprints() {
|
|
const [rows] = await pool.query('SELECT id,name,dim,vec,created_at FROM voiceprints ORDER BY name');
|
|
return rows.map(r => ({ ...r, vec: JSON.parse(r.vec) }));
|
|
}
|
|
export async function deleteVoiceprint(id) { await pool.query('DELETE FROM voiceprints WHERE id=?', [id]); }
|
|
|
|
export async function addRecording(r) {
|
|
const [res] = await pool.execute(
|
|
'INSERT INTO recordings (title, mp3_file, wav_file, device, duration_ms, transcript, language, status, analysis_status) VALUES (?,?,?,?,?,?,?,?,?)',
|
|
[r.title, r.mp3_file, r.wav_file || null, r.device, r.duration_ms, r.transcript, r.language, r.status || 'done', 'pending']);
|
|
return res.insertId;
|
|
}
|
|
|
|
export async function addWords(recId, words) {
|
|
if (!words || !words.length) return;
|
|
const values = words.map((w, i) => [recId, w.word, w.t_start_ms, w.t_end_ms, i, w.speaker || null]);
|
|
await pool.query('INSERT INTO words (recording_id, word, t_start_ms, t_end_ms, idx, speaker) VALUES ?', [values]);
|
|
}
|
|
|
|
// Replace a recording's words wholesale (used when re-diarizing: the text is
|
|
// unchanged, only the speaker labels differ). Words are re-indexed by array order.
|
|
export async function replaceWords(recId, words) {
|
|
await pool.query('DELETE FROM words WHERE recording_id=?', [recId]);
|
|
await addWords(recId, words);
|
|
}
|
|
|
|
// Store the transcript + words after async (thermal-guarded) transcription and
|
|
// flip the row out of 'transcribing'. The wav + row were saved before whisper
|
|
// ran, so this just fills them in.
|
|
export async function setTranscript(id, transcript, words) {
|
|
await pool.execute('UPDATE recordings SET transcript=?, status=? WHERE id=?', [transcript || '', 'done', id]);
|
|
await replaceWords(id, words || []);
|
|
}
|
|
|
|
// Stamp the wall-clock start of a processing run (transcribe+diar+LLM). The
|
|
// GUI shows a live counter from here; updateAnalysis('done') freezes the total
|
|
// into analysis_ms. Called at every trigger (record-stop, retranscribe, reanalyze).
|
|
export async function markAnalysisStart(id) {
|
|
await pool.execute('UPDATE recordings SET analysis_started_at=?, analysis_ms=NULL WHERE id=?', [Date.now(), id]);
|
|
}
|
|
|
|
// Update just the coarse recording status ('transcribing' | 'done' | 'error').
|
|
export async function setStatus(id, status) {
|
|
await pool.execute('UPDATE recordings SET status=? WHERE id=?', [status, id]);
|
|
}
|
|
|
|
// Per-recording map { rawSpeaker -> displayName }. Manual edits (setSpeakerName)
|
|
// are authoritative; acoustic identification only fills blanks (mergeSpeakerNames).
|
|
async function getSpeakerNames(id) {
|
|
const [rows] = await pool.query('SELECT speaker_names FROM recordings WHERE id=?', [id]);
|
|
try { return JSON.parse((rows[0] && rows[0].speaker_names) || '{}') || {}; } catch { return {}; }
|
|
}
|
|
export async function setSpeakerName(id, speaker, name) {
|
|
const m = await getSpeakerNames(id);
|
|
if (name) m[speaker] = name; else delete m[speaker]; // empty name clears back to "Sprecher N"
|
|
await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
|
|
}
|
|
// Seed names from acoustic identification WITHOUT clobbering existing (manual) ones.
|
|
export async function mergeSpeakerNames(id, map) {
|
|
if (!map || !Object.keys(map).length) return;
|
|
const m = await getSpeakerNames(id);
|
|
let changed = false;
|
|
for (const k of Object.keys(map)) if (!m[k] && map[k]) { m[k] = map[k]; changed = true; }
|
|
if (changed) await pool.execute('UPDATE recordings SET speaker_names=? WHERE id=?', [JSON.stringify(m), id]);
|
|
}
|
|
|
|
// Store the LLM result. `todos` is an array → persisted as JSON text.
|
|
export async function updateAnalysis(id, { title, summary, todos, participants, transcriptClean, status }) {
|
|
const st = status || 'done';
|
|
await pool.execute(
|
|
`UPDATE recordings SET title=COALESCE(?,title), summary=?, todos=?, participants=?, transcript_clean=?, analysis_status=?,
|
|
analysis_ms=CASE WHEN ?='done' AND analysis_started_at IS NOT NULL THEN ?-analysis_started_at ELSE analysis_ms END
|
|
WHERE id=?`,
|
|
[title || null, summary || null, todos ? JSON.stringify(todos) : null,
|
|
participants ? JSON.stringify(participants) : null, transcriptClean || null, st,
|
|
st, Date.now(), id]);
|
|
}
|
|
|
|
function parseJsonCols(row) {
|
|
for (const c of ['todos', 'participants']) {
|
|
if (row && typeof row[c] === 'string') {
|
|
try { row[c] = JSON.parse(row[c]); } catch { row[c] = []; }
|
|
}
|
|
}
|
|
return row;
|
|
}
|
|
const parseTodos = parseJsonCols;
|
|
|
|
export async function listRecordings() {
|
|
const [rows] = await pool.query(
|
|
'SELECT id,title,mp3_file,device,duration_ms,language,status,summary,todos,participants,analysis_status,analysis_started_at,analysis_ms,created_at FROM recordings ORDER BY id DESC');
|
|
return rows.map(parseTodos);
|
|
}
|
|
|
|
export async function getRecording(id) {
|
|
const [recRows] = await pool.query('SELECT * FROM recordings WHERE id=?', [id]);
|
|
const rec = recRows[0];
|
|
if (!rec) return null;
|
|
const [words] = await pool.query(
|
|
'SELECT word,t_start_ms,t_end_ms,idx,speaker FROM words WHERE recording_id=? ORDER BY idx', [id]);
|
|
let speaker_names = {};
|
|
try { speaker_names = JSON.parse(rec.speaker_names || '{}') || {}; } catch {}
|
|
return { ...parseTodos(rec), speaker_names, words };
|
|
}
|
|
|
|
export async function deleteRecording(id) {
|
|
await pool.query('DELETE FROM recordings WHERE id=?', [id]);
|
|
}
|