feat: komplette Codebase vom Board ins Repo gespiegelt
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>
This commit is contained in:
+391
@@ -0,0 +1,391 @@
|
||||
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';
|
||||
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;
|
||||
}
|
||||
// One request at a time (single warm process), chained like spk.embed.
|
||||
function runDaemonTranscribe(wavPath) {
|
||||
tdChain = tdChain.then(async () => {
|
||||
await startTd();
|
||||
return await new Promise((res, rej) => { tdPending = { resolve: res, reject: rej }; tdProc.stdin.write(wavPath + '\n'); });
|
||||
});
|
||||
return tdChain;
|
||||
}
|
||||
// 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 }
|
||||
|
||||
export function status() {
|
||||
return current ? { recording: true, startedAt: current.startTs, source: current.source } : { recording: false };
|
||||
}
|
||||
export function isRecording() { return !!current; }
|
||||
|
||||
// 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,
|
||||
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).
|
||||
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}`;
|
||||
const mp3Path = path.join(REC_DIR, `${base}.mp3`);
|
||||
// Gentle cleanup for playback: cut low rumble + normalize loudness. Applied to
|
||||
// the MP3 only; whisper still transcribes the untouched WAV.
|
||||
await pexec('ffmpeg', ['-y', '-i', c.wavPath,
|
||||
'-af', 'highpass=f=80,loudnorm=I=-16:TP=-1.5:LRA=11',
|
||||
'-codec:a', 'libmp3lame', '-qscale:a', '3', mp3Path]);
|
||||
|
||||
// Move the wav into REC_DIR (rootfs — survives a reboot); it is NOT left in
|
||||
// /tmp, so a crash during transcription can no longer lose it.
|
||||
const wavFile = `${base}.wav`;
|
||||
try { fs.renameSync(c.wavPath, path.join(REC_DIR, wavFile)); }
|
||||
catch { fs.copyFileSync(c.wavPath, path.join(REC_DIR, wavFile)); try { fs.unlinkSync(c.wavPath); } catch {} }
|
||||
|
||||
return { mp3_file: `${base}.mp3`, wav_file: wavFile, duration_ms: dur,
|
||||
language: 'de', title: c.title, device: c.source, numSpeakers: c.numSpeakers };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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') {
|
||||
await llm.stopServer();
|
||||
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.
|
||||
await setA76Max(A76_CAP);
|
||||
try {
|
||||
const segs = await diar.diarize(wavPath, numSpeakers);
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user