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>
483 lines
25 KiB
JavaScript
483 lines
25 KiB
JavaScript
import { spawn, execFile } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import readline from 'node:readline';
|
||
import * as diar from './diar.js';
|
||
import * as llm from './llm.js';
|
||
import * as gate from './gate.js';
|
||
const pexec = promisify(execFile);
|
||
|
||
const WHISPER = '/opt/whisper.cpp/build/bin/whisper-cli';
|
||
// medium since 2026-07-08: user reported small mis-hears words on the USB-headset
|
||
// call audio (cleaner than the old BT-mSBC, so the extra accuracy now pays off).
|
||
// ~2x slower than small + more heat on long recordings (thermal throttle >85 C) —
|
||
// acceptable trade for accuracy. Override with WHISPER_MODEL to switch back.
|
||
const MODEL = process.env.WHISPER_MODEL || '/opt/whisper.cpp/models/ggml-medium.bin';
|
||
// NPU turbo transcriber: large-v3-turbo ENCODER on the RK3588 NPU (rknn fp16),
|
||
// decoder on CPU. Benchmarked ~2.4x faster than whisper.cpp medium on the R1
|
||
// (RTF 0.64 vs 1.51) and the encoder runs off-CPU -> far less A76 heat. The
|
||
// Python CLI emits {text, words:[{word,t_start_ms,t_end_ms}]}. Set TRANSCRIBER=cpu
|
||
// to fall back to whisper.cpp medium (the block below).
|
||
const TRANSCRIBER = process.env.TRANSCRIBER || 'npu';
|
||
const NPU_PY = '/opt/aime-venv/bin/python';
|
||
const NPU_CLI = '/opt/callrec/inference/transcribe_cli.py';
|
||
// SHORT clips (<= SHORT_SEC): a warm CPU daemon + resident rkllm instead of
|
||
// the NPU path, whose per-call model load (~28-40s) + rkllm swap (~40s) dwarf
|
||
// the actual work on a tiny clip. See the daemon client below.
|
||
const TD_DAEMON = '/opt/callrec/inference/transcribe_daemon_cpu.py';
|
||
const SHORT_SEC = +(process.env.SHORT_TRANSCRIBE_SEC || 30);
|
||
const REC_DIR = '/opt/callrec/recordings';
|
||
fs.mkdirSync(REC_DIR, { recursive: true });
|
||
|
||
// PipeWire/Pulse runs in the callrec user session; the systemd unit already
|
||
// sets XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS, so just inherit the env.
|
||
function penv() { return process.env; }
|
||
|
||
// --- Thermal safety for whisper on the fanless board -----------------------
|
||
// A 79-min recording transcribed with ggml-medium pinned to all 4 A76 cores at
|
||
// max clock drove the big cores to CRITICAL temp -> the kernel did a HARDWARE
|
||
// PROTECTION shutdown (auto-reboot), losing the in-flight recording. Two guards:
|
||
// 1. cap the A76 max clock while transcribing (biggest heat lever), and
|
||
// 2. a hard backstop that SIGSTOPs whisper if the big cores still cross 90 C
|
||
// and SIGCONTs it once they fall back under 78 C.
|
||
const A76_MAX = 2256000; // restore-to clock (from scaling_available_frequencies)
|
||
const A76_CAP = 1608000; // 1.6 GHz cap during whisper
|
||
const A76_CAP_NPU = 1800000; // relaxed 1.8 GHz for the NPU decoder (encoder is off-CPU;
|
||
// measured only ~2% slower at 1.6 but ~20 C cooler than full 2.25)
|
||
const TEMP_ZONES = ['/sys/class/thermal/thermal_zone1/temp', // bigcore0
|
||
'/sys/class/thermal/thermal_zone2/temp']; // bigcore1
|
||
const TEMP_PAUSE = 90000; // >90 C -> pause whisper
|
||
const TEMP_RESUME = 78000; // <78 C -> resume
|
||
|
||
function readMaxBigTemp() {
|
||
let m = 0;
|
||
for (const z of TEMP_ZONES) {
|
||
try { const v = parseInt(fs.readFileSync(z, 'utf8'), 10); if (v > m) m = v; } catch {}
|
||
}
|
||
return m;
|
||
}
|
||
// callrec has a NOPASSWD sudo rule for exactly /opt/callrec/aime-cpucap.sh.
|
||
function setA76Max(freq) {
|
||
return new Promise(res => execFile('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', String(freq)], () => res()));
|
||
}
|
||
|
||
// Spawn whisper on the A76 cores with the SIGSTOP/SIGCONT temp backstop. The A76
|
||
// CLOCK CAP is NOT managed here — the caller (transcribeWav) holds it across the
|
||
// WHOLE heavy pipeline (whisper + diarization), because diarization also pins the
|
||
// A76 cores and crashed the board when it ran at full clock after whisper's cap
|
||
// was released. Rejects on non-zero exit.
|
||
async function runWhisperGuarded(args) {
|
||
let guard = null, paused = false;
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const p = spawn('taskset', ['-c', '4-7', WHISPER, ...args], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
let err = '';
|
||
p.stderr.on('data', d => { err += d; if (err.length > (1 << 20)) err = err.slice(-(1 << 19)); });
|
||
guard = setInterval(() => {
|
||
const t = readMaxBigTemp();
|
||
if (!paused && t >= TEMP_PAUSE) {
|
||
try { process.kill(p.pid, 'SIGSTOP'); paused = true; console.warn(`whisper PAUSED (A76 ${t / 1000} C)`); } catch {}
|
||
} else if (paused && t > 0 && t <= TEMP_RESUME) {
|
||
try { process.kill(p.pid, 'SIGCONT'); paused = false; console.warn(`whisper resumed (A76 ${t / 1000} C)`); } catch {}
|
||
}
|
||
}, 3000);
|
||
p.on('error', reject);
|
||
p.on('close', code => code === 0 ? resolve() : reject(new Error('whisper exit ' + code + ': ' + err.slice(-400))));
|
||
});
|
||
} finally {
|
||
if (guard) clearInterval(guard);
|
||
}
|
||
}
|
||
|
||
// Run the NPU turbo transcriber (Python) under the same A76 temp backstop as
|
||
// whisper. taskset execs python -> p.pid IS python, so SIGSTOP/SIGCONT reach it.
|
||
// Returns the parsed CLI JSON. The A76 CLOCK CAP is held by the caller.
|
||
async function runNpuTranscribe(wavPath) {
|
||
let guard = null, paused = false, out = '', err = '';
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const p = spawn('taskset', ['-c', '4-7', NPU_PY, NPU_CLI, wavPath],
|
||
{ stdio: ['ignore', 'pipe', 'pipe'], cwd: '/opt/callrec' });
|
||
p.stdout.on('data', d => { out += d; });
|
||
p.stderr.on('data', d => { err += d; if (err.length > (1 << 20)) err = err.slice(-(1 << 19)); });
|
||
guard = setInterval(() => {
|
||
const t = readMaxBigTemp();
|
||
if (!paused && t >= TEMP_PAUSE) {
|
||
try { process.kill(p.pid, 'SIGSTOP'); paused = true; console.warn(`transcribe PAUSED (A76 ${t / 1000} C)`); } catch {}
|
||
} else if (paused && t > 0 && t <= TEMP_RESUME) {
|
||
try { process.kill(p.pid, 'SIGCONT'); paused = false; console.warn(`transcribe resumed (A76 ${t / 1000} C)`); } catch {}
|
||
}
|
||
}, 3000);
|
||
p.on('error', reject);
|
||
p.on('close', code => code === 0 ? resolve() : reject(new Error('npu-transcribe exit ' + code + ': ' + err.slice(-400))));
|
||
});
|
||
} finally {
|
||
if (guard) clearInterval(guard);
|
||
}
|
||
const line = out.trim();
|
||
if (!line) throw new Error('npu-transcribe: leere Ausgabe. stderr: ' + err.slice(-400));
|
||
return JSON.parse(line);
|
||
}
|
||
|
||
// --- Warm CPU transcriber daemon (SHORT clips) -----------------------------
|
||
// large-v3-turbo CT2 on the CPU, loaded ONCE and kept warm. CPU-only, so it
|
||
// coexists with a resident rkllm_server on the NPU (no memory-wall conflict):
|
||
// a short clip needs neither a per-call model load nor an rkllm swap. Lazy-
|
||
// spawned on the first short clip, then persistent. base64 line protocol.
|
||
let tdProc = null, tdReady = null, tdRl = null, tdPending = null, tdChain = Promise.resolve();
|
||
function startTd() {
|
||
if (tdProc) return tdReady;
|
||
tdProc = spawn('taskset', ['-c', '4-7', NPU_PY, TD_DAEMON],
|
||
{ stdio: ['pipe', 'pipe', 'pipe'], cwd: '/opt/callrec', env: { ...process.env, WHISPER_BATCH: process.env.WHISPER_BATCH || '1' } });
|
||
tdProc.stderr.on('data', () => {}); // onnxruntime GPU-probe warnings, ignore
|
||
tdReady = new Promise((res, rej) => {
|
||
const to = setTimeout(() => rej(new Error('transcribe-daemon READY timeout')), 120000);
|
||
tdRl = readline.createInterface({ input: tdProc.stdout });
|
||
tdRl.on('line', line => {
|
||
if (line.startsWith('READY')) { clearTimeout(to); res(); return; }
|
||
if (!tdPending) return;
|
||
const p = tdPending; tdPending = null;
|
||
if (line.startsWith('OK ')) { try { p.resolve(JSON.parse(Buffer.from(line.slice(3), 'base64').toString('utf8'))); } catch (e) { p.reject(e); } }
|
||
else p.reject(new Error(Buffer.from(line.slice(4), 'base64').toString('utf8') || 'daemon error'));
|
||
});
|
||
});
|
||
const cleanup = () => { tdProc = null; tdReady = null; tdRl = null; if (tdPending) { tdPending.reject(new Error('transcribe-daemon beendet')); tdPending = null; } };
|
||
tdProc.on('exit', cleanup); tdProc.on('error', cleanup);
|
||
return tdReady;
|
||
}
|
||
// Beendet den warmen CT2-Daemon und wartet auf sein Ende. Er bleibt sonst per
|
||
// Design für immer resident und hält ~0,9 GB — genug, um den rkllm-Load danach
|
||
// OOM-tötbar zu machen (2026-08-07, #101). Nächster Kurz-Clip startet ihn neu;
|
||
// der einzige Preis ist der erneute Modell-Load. Siehe gate.freeRam.
|
||
export function stopTranscribeDaemon() {
|
||
const p = tdProc;
|
||
tdProc = null; tdReady = null; tdRl = null;
|
||
return gate.killAndWait(p);
|
||
}
|
||
|
||
// One request at a time (single warm process), chained like spk.embed.
|
||
function runDaemonTranscribe(wavPath) {
|
||
const run = tdChain.then(async () => {
|
||
await startTd();
|
||
return await new Promise((res, rej) => { tdPending = { resolve: res, reject: rej }; tdProc.stdin.write(wavPath + '\n'); });
|
||
});
|
||
tdChain = run.then(() => {}, () => {}); // a failed call must not poison the queue
|
||
return run;
|
||
}
|
||
// Wav duration from file size (16 kHz mono s16 = 32000 B/s, 44 B header).
|
||
function wavDurationSec(wavPath) {
|
||
try { return Math.max(0, (fs.statSync(wavPath).size - 44) / 32000); } catch { return 0; }
|
||
}
|
||
|
||
let current = null; // { proc, wavPath, startTs, source, title, markers }
|
||
|
||
export function status() {
|
||
return current ? { recording: true, startedAt: current.startTs, source: current.source,
|
||
markers: current.markers } : { recording: false };
|
||
}
|
||
export function isRecording() { return !!current; }
|
||
|
||
// --- live speaker markers ---------------------------------------------------
|
||
// While recording, the user types the name of whoever is talking. We stamp the
|
||
// offset from recording start; after diarization the name is attached to the
|
||
// acoustic cluster that was speaking around that offset (see diar.resolveMarkers).
|
||
// This is the only reliable way to name 20+ participants of a conference call.
|
||
export function addMarker(name) {
|
||
if (!current) throw new Error('not recording');
|
||
const n = String(name || '').trim();
|
||
if (!n) throw new Error('Name fehlt');
|
||
const m = { t_ms: Date.now() - current.startTs, name: n.slice(0, 128) };
|
||
current.markers.push(m);
|
||
return { ...m, index: current.markers.length - 1, markers: current.markers };
|
||
}
|
||
export function listMarkers() { return current ? current.markers : []; }
|
||
export function removeMarker(index) {
|
||
if (!current) throw new Error('not recording');
|
||
const i = +index;
|
||
if (i >= 0 && i < current.markers.length) current.markers.splice(i, 1);
|
||
return current.markers;
|
||
}
|
||
|
||
// List real capture devices (one entry per recording device). A "source" in
|
||
// PulseAudio/PipeWire includes monitor sources (the loopback of every OUTPUT)
|
||
// and both A2DP/HFP variants of a BT card — confusing for the user. We only
|
||
// want actual microphones/inputs, labelled by their friendly device name.
|
||
export async function listSources() {
|
||
try {
|
||
const { stdout } = await pexec('pactl', ['list', 'sources'], { env: penv() });
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const block of stdout.split(/\nSource #/).map(b => b.trim()).filter(Boolean)) {
|
||
const name = (block.match(/^\s*Name:\s*(.+)$/m) || [])[1];
|
||
if (!name) continue;
|
||
// Skip monitors (loopback of an output) — not a recording device.
|
||
// Every block has a "Monitor of Sink:" line; a real input reads "n/a".
|
||
if (/\.monitor$/i.test(name) || /Monitor of Sink:\s*(?!n\/a)\S/i.test(block)) continue;
|
||
const isBluetooth = /bluez/i.test(name);
|
||
// For BT the physical device is one card; both bluez_input.* names map to
|
||
// the same headset, so collapse by the MAC to a single entry.
|
||
const key = isBluetooth ? (name.match(/([0-9A-F]{2}[:_]){5}[0-9A-F]{2}/i) || [name])[0] : name;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const label = (block.match(/^\s*Description:\s*(.+)$/m) || [])[1] || name;
|
||
out.push({ name, label, isBluetooth });
|
||
}
|
||
return out;
|
||
} catch (e) { return []; }
|
||
}
|
||
|
||
// Find the bluez card name (e.g. bluez_card.98_34_8C_FD_24_17).
|
||
async function bluezCard() {
|
||
const { stdout } = await pexec('pactl', ['list', 'cards', 'short'], { env: penv() });
|
||
return stdout.split('\n').map(l => l.split('\t')[1]).find(n => /bluez/i.test(n || ''));
|
||
}
|
||
|
||
// List available profiles of the bluez card (for diagnostics / picking HFP).
|
||
async function cardProfiles(card) {
|
||
const { stdout } = await pexec('pactl', ['list', 'cards'], { env: penv() });
|
||
const block = stdout.split('Card #').find(b => b.includes(card));
|
||
if (!block) return [];
|
||
const m = block.match(/Profiles:[\s\S]*?(?=\n\tActive Profile:|\n\tPorts:|$)/);
|
||
return m ? [...m[0].matchAll(/^\t\t([\w-]+):/gm)].map(x => x[1]) : [];
|
||
}
|
||
|
||
// Switch the connected BT headset to an HFP profile (bidirectional, mic-capable).
|
||
// A2DP has no microphone, so recording a call REQUIRES HFP (headset-head-unit).
|
||
// codec: 'msbc' (16 kHz wideband), 'cvsd' (8 kHz narrowband), or 'auto' (mSBC first).
|
||
// On this card: headset-head-unit = mSBC, headset-head-unit-cvsd = CVSD.
|
||
export async function enableHeadsetMic(codec = 'auto') {
|
||
try {
|
||
const card = await bluezCard();
|
||
if (!card) return { ok: false, msg: 'no bluez card (headset connected?)' };
|
||
const avail = await cardProfiles(card);
|
||
const lists = {
|
||
// mSBC = the plain "headset-head-unit" here (some stacks name it -msbc).
|
||
msbc: ['headset-head-unit-msbc', 'headset-head-unit', 'handsfree-head-unit-msbc', 'handsfree-head-unit'],
|
||
cvsd: ['headset-head-unit-cvsd', 'handsfree-head-unit-cvsd'],
|
||
auto: ['headset-head-unit-msbc', 'headset-head-unit', 'handsfree-head-unit-msbc',
|
||
'handsfree-head-unit', 'headset-head-unit-cvsd', 'handsfree-head-unit-cvsd'],
|
||
};
|
||
const prefer = lists[codec] || lists.auto;
|
||
const pick = prefer.find(p => avail.includes(p));
|
||
if (!pick) return { ok: false, msg: `codec ${codec} nicht verfügbar (${avail.join(', ')})`, available: avail };
|
||
await pexec('pactl', ['set-card-profile', card, pick], { env: penv() });
|
||
return { ok: true, card, profile: pick, codec, available: avail };
|
||
} catch (e) { return { ok: false, msg: String(e && e.message || e) }; }
|
||
}
|
||
|
||
export async function startRecording({ source, title, codec, numSpeakers } = {}) {
|
||
if (current) throw new Error('already recording');
|
||
// Bluetooth headset: force HFP so the microphone actually delivers audio
|
||
// (A2DP is playback-only). Do this right before capture; the profile holds
|
||
// while the source is open. codec = 'msbc' | 'cvsd' | 'auto' (A/B testing).
|
||
let hfp = null;
|
||
if (source && /bluez/i.test(source)) {
|
||
hfp = await enableHeadsetMic(codec || 'auto');
|
||
if (!hfp.ok) throw new Error('HFP-Profil konnte nicht aktiviert werden: ' + hfp.msg);
|
||
// the source object is recreated under the new profile; wait, then re-resolve
|
||
// the actual bluez input source name (it may differ from the A2DP-era name).
|
||
await new Promise(r => setTimeout(r, 900));
|
||
const srcs = await listSources();
|
||
const bz = srcs.find(s => s.isBluetooth && !/monitor/i.test(s.name));
|
||
if (bz) source = bz.name;
|
||
}
|
||
const ts = Date.now();
|
||
const wavPath = path.join('/tmp', `rec_${ts}.wav`);
|
||
// 16 kHz mono s16 = whisper's native format. NOTE: parecord takes the output
|
||
// filename as a POSITIONAL arg (there is no --file= option).
|
||
const args = ['--file-format=wav', '--rate=16000', '--channels=1', '--format=s16le'];
|
||
if (source) args.push(`--device=${source}`);
|
||
args.push(wavPath);
|
||
const proc = spawn('parecord', args, { env: penv() });
|
||
let err = '';
|
||
proc.stderr.on('data', d => { err += d; });
|
||
proc.on('error', e => { err += String(e); });
|
||
// Label the recording with the actual codec used so A/B tests are obvious
|
||
// in the list (mSBC 16 kHz vs CVSD 8 kHz).
|
||
const codecTag = hfp && hfp.profile ? (/cvsd/i.test(hfp.profile) ? ' [CVSD 8k]' : ' [mSBC 16k]') : '';
|
||
current = {
|
||
proc, wavPath, startTs: ts, source: source || 'default', numSpeakers, markers: [],
|
||
title: (title || `Aufnahme ${new Date(ts).toLocaleString('de-DE')}`) + codecTag, err: () => err
|
||
};
|
||
return { startedAt: ts };
|
||
}
|
||
|
||
// Stop capture and return the raw wav path only (no whisper/mp3/diarization).
|
||
// Used for voiceprint enrollment.
|
||
export async function stopRaw() {
|
||
if (!current) throw new Error('not recording');
|
||
const c = current; current = null;
|
||
c.proc.kill('SIGINT');
|
||
await new Promise(resolve => {
|
||
if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve();
|
||
let done = false; const fin = () => { if (!done) { done = true; resolve(); } };
|
||
c.proc.on('close', fin); setTimeout(fin, 4000);
|
||
});
|
||
if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000)
|
||
throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err());
|
||
return c.wavPath;
|
||
}
|
||
|
||
// Stop capture and PERSIST the wav + mp3 to REC_DIR immediately — BEFORE any
|
||
// transcription. This is the crash-safety fix: whisper on a long clip can crash
|
||
// the board (thermal), so the audio must already be on rootfs (not /tmp) and the
|
||
// DB row created before whisper runs. Call transcribeWav() afterwards (async).
|
||
// Wiedergabe-MP3 aus dem fertigen WAV — laeuft im HINTERGRUND (kein await im
|
||
// Stop-Pfad, siehe stopAndSave). Geschrieben wird nach `<name>.mp3.part` und erst
|
||
// dann umbenannt: so hoert der Browser nie eine halbe Datei, sondern bekommt bis
|
||
// zum Umbenennen ein 404 (die GUI laedt den Player selbst nach).
|
||
// Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to
|
||
// the MP3 only; whisper still transcribes the untouched WAV.
|
||
// lame's psymodel asserts (`calc_energy: el >= 0`) on pure digital silence —
|
||
// real since the gated mic path: nobody spoke = all-zero samples. Fall back
|
||
// to a filterless encode instead of failing the whole stop.
|
||
// KONSTANTE BITRATE, kein VBR (früher `-qscale:a 3`). Grund ist die
|
||
// Wort-Markierung im Transkript: der Browser springt in einer VBR-MP3 über
|
||
// die Xing-Tabelle, und die hat nur 100 Stützstellen — bei einer 63-Minuten-
|
||
// Aufnahme also ein Raster von 38 s. Er landet auf der Stützstelle davor,
|
||
// meldet aber die angefragte Zeit zurück. Gemessen an #85: Sprung auf 20:40
|
||
// spielte in Wahrheit ab 20:11,5 (−28,5 s), die Markierung lief entsprechend
|
||
// dem Ton voraus. Bei CBR ist die Byte-Position proportional zur Zeit, jeder
|
||
// Sprung trifft damit auf Frame-Genauigkeit (~26 ms). 64 kbit ≈ die bisherige
|
||
// Durchschnittsrate (68 kbit), die Dateigröße bleibt also praktisch gleich.
|
||
// `-ar 48000` fest, damit der Fallback-Zweig ohne loudnorm nicht bei 16 kHz
|
||
// landet (= MPEG-2.5, andere Framelänge).
|
||
// `-write_xing 0` muss dazu: die Xing-Tabelle speichert Byte-Positionen nur
|
||
// in 1/256 der Dateigröße, bei einer Stunde Audio also ~15 s grob — ein
|
||
// Browser, der sie benutzt, wäre trotz CBR wieder daneben. Ohne sie rechnet
|
||
// er linear, was bei CBR exakt ist (nachgemessen: 0,02 s = ein halber Frame).
|
||
// '-f mp3' ist Pflicht, weil der Hintergrund-Encode nach '<name>.mp3.part'
|
||
// schreibt: ffmpeg leitet den Muxer aus der Endung ab und bricht bei '.part'
|
||
// mit 'Error initializing the muxer ... Invalid argument' ab.
|
||
const MP3_ARGS = ['-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '48000', '-ac', '1', '-write_xing', '0', '-f', 'mp3'];
|
||
|
||
async function encodeMp3(wavPath, mp3Path) {
|
||
const part = mp3Path + '.part';
|
||
try {
|
||
try {
|
||
await pexec('ffmpeg', ['-y', '-i', wavPath,
|
||
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
||
...MP3_ARGS, part]);
|
||
} catch (e) {
|
||
console.warn('mp3 encode with filters failed (silence?), retrying plain:', String(e.message).slice(0, 200));
|
||
await pexec('ffmpeg', ['-y', '-i', wavPath, ...MP3_ARGS, part]);
|
||
}
|
||
fs.renameSync(part, mp3Path);
|
||
console.log(`mp3 fertig: ${path.basename(mp3Path)}`);
|
||
} catch (e) {
|
||
console.error('mp3 encode endgültig fehlgeschlagen:', String(e.message).slice(0, 300));
|
||
try { fs.unlinkSync(part); } catch { }
|
||
}
|
||
}
|
||
|
||
export async function stopAndSave() {
|
||
if (!current) throw new Error('not recording');
|
||
const c = current; current = null;
|
||
c.proc.kill('SIGINT');
|
||
await new Promise(resolve => {
|
||
if (c.proc.exitCode !== null || c.proc.signalCode !== null) return resolve();
|
||
let done = false;
|
||
const fin = () => { if (!done) { done = true; resolve(); } };
|
||
c.proc.on('close', fin);
|
||
setTimeout(fin, 4000);
|
||
});
|
||
const dur = Date.now() - c.startTs;
|
||
if (!fs.existsSync(c.wavPath) || fs.statSync(c.wavPath).size < 1000)
|
||
throw new Error('Aufnahme leer/fehlgeschlagen. parecord: ' + c.err());
|
||
|
||
const base = `rec_${c.startTs}`;
|
||
// Zuerst das WAV in Sicherheit bringen (REC_DIR liegt im rootfs, nicht /tmp):
|
||
// daran haengt alles Weitere — whisper transkribiert es, das Wiedergabe-MP3
|
||
// entsteht daraus. Ein Absturz danach verliert nichts mehr.
|
||
const wavFile = `${base}.wav`;
|
||
const wavDest = path.join(REC_DIR, wavFile);
|
||
try { fs.renameSync(c.wavPath, wavDest); }
|
||
catch { fs.copyFileSync(c.wavPath, wavDest); try { fs.unlinkSync(c.wavPath); } catch {} }
|
||
|
||
// MP3 NICHT mehr abwarten: der loudnorm-Encode laeuft nur ~40-fach, bei 35 min
|
||
// Audio also ~51 s (gemessen an #114, ~1,4 s pro Aufnahmeminute). Genau diese
|
||
// Zeit hing /api/record/stop, die GUI zeigte einen stumm ausgegrauten Knopf und
|
||
// der Nutzer druckte ein zweites Mal Stop. Jetzt antwortet der Server sofort;
|
||
// bis das MP3 da ist, liefert /media ein 404 und die GUI laedt den Player nach.
|
||
encodeMp3(wavDest, path.join(REC_DIR, `${base}.mp3`));
|
||
|
||
return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur,
|
||
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers,
|
||
markers: c.markers };
|
||
}
|
||
|
||
// Transcribe a saved wav (in REC_DIR) with whisper (thermal-guarded) + diarize.
|
||
// Returns { transcript, words }. Idempotent — safe to re-run for recovery of a
|
||
// recording whose transcription was interrupted (crash) or failed.
|
||
export async function transcribeWav(wavFile, numSpeakers, opts = {}) {
|
||
const wavPath = path.isAbsolute(wavFile) ? wavFile : path.join(REC_DIR, wavFile);
|
||
if (!fs.existsSync(wavPath)) throw new Error('wav-Datei fehlt: ' + wavFile);
|
||
const outBase = `/tmp/${path.basename(wavPath, '.wav')}`;
|
||
const words = [];
|
||
let transcript = '';
|
||
// Diarization (sherpa-onnx over the whole recording) pins all 4 A76 cores and
|
||
// crashed the board at full clock on a 46-min call, so it ALWAYS runs under the
|
||
// A76 clock cap. Transcription is capped ONLY on the whisper.cpp (medium) path
|
||
// (CPU-hot); the NPU-turbo path runs the encoder on the NPU (CPU only decodes,
|
||
// ~59-69 C) so it needs NO cap. rkllm_server (~3.1 GB) is freed first on the NPU
|
||
// path (turbo rknn + CT2 decoder OOM-killed next to it); it respawns for analysis.
|
||
const wavSec = wavDurationSec(wavPath);
|
||
try {
|
||
if (TRANSCRIBER === 'npu' && wavSec > 0 && wavSec <= SHORT_SEC) {
|
||
// SHORT clip: warm CPU daemon + keep rkllm RESIDENT (no NPU swap), at
|
||
// full clock (a few seconds of compute won't overheat the fanless board).
|
||
// Cuts a 10s clip from ~120s (per-call model load + rkllm swap) to ~30s.
|
||
const r = await runDaemonTranscribe(wavPath);
|
||
for (const w of r.words || []) {
|
||
const t = (w.word || '').trim();
|
||
if (!t) continue;
|
||
words.push({ word: t, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms });
|
||
transcript += (transcript ? ' ' : '') + t;
|
||
}
|
||
console.log(`cpu-daemon-transcribe: ${words.length} words, RTF ${r.rtf} (${r.transcribe_s}s / ${r.duration}s audio, warm, rkllm kept resident)`);
|
||
} else if (TRANSCRIBER === 'npu') {
|
||
// Nicht nur rkllm freigeben: der warme CT2-Daemon und die Embedder halten
|
||
// zusammen ~2,7 GB und der turbo-rknn-Encoder + CT2-Decoder brauchen den
|
||
// Platz genauso wie rkllm (siehe gate.freeRam).
|
||
await gate.freeRam('npu-whisper');
|
||
await setA76Max(A76_CAP_NPU);
|
||
// large-v3-turbo, encoder on NPU. CLI already emits word-level timestamps.
|
||
const r = await runNpuTranscribe(wavPath);
|
||
for (const w of r.words || []) {
|
||
const t = (w.word || '').trim();
|
||
if (!t) continue;
|
||
words.push({ word: t, t_start_ms: w.t_start_ms, t_end_ms: w.t_end_ms });
|
||
transcript += (transcript ? ' ' : '') + t;
|
||
}
|
||
console.log(`npu-transcribe: ${words.length} words, RTF ${r.rtf} (load ${r.load_s}s + transcribe ${r.transcribe_s}s / ${r.duration}s audio)`);
|
||
} else {
|
||
await setA76Max(A76_CAP);
|
||
// Fallback: whisper.cpp medium. Greedy (-bs 1 -bo 1) ~= beam-5 on German,
|
||
// ~30% faster + less heat. whisper also has a SIGSTOP temp guard.
|
||
await runWhisperGuarded(['-m', MODEL, '-f', wavPath, '-l', 'de', '-t', '4',
|
||
'-bs', '1', '-bo', '1', '-ml', '1', '-sow', '-oj', '-of', outBase]);
|
||
const json = JSON.parse(fs.readFileSync(`${outBase}.json`, 'utf8'));
|
||
for (const seg of json.transcription || []) {
|
||
const t = (seg.text || '').trim();
|
||
if (!t) continue;
|
||
words.push({ word: t, t_start_ms: seg.offsets.from, t_end_ms: seg.offsets.to });
|
||
transcript += (transcript ? ' ' : '') + t;
|
||
}
|
||
}
|
||
// Acoustic diarization under the A76 clock cap (both paths). Non-fatal.
|
||
// Zweiter Haltepunkt: sherpa nagelt alle 4 A76-Kerne fest, das darf nicht
|
||
// neben einer laufenden Aufnahme passieren (gate.js).
|
||
await gate.waitIdle('diarize');
|
||
await setA76Max(A76_CAP);
|
||
try {
|
||
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 {}
|
||
} finally {
|
||
await setA76Max(A76_MAX);
|
||
}
|
||
return { transcript, words };
|
||
}
|