From ff285011d2eba43d5626b4e754987066a94d1a2f Mon Sep 17 00:00:00 2001 From: sascha Date: Fri, 7 Aug 2026 15:55:31 +0200 Subject: [PATCH] fix(ram): nur ein grosses Modell resident (Analyse starb im OOM-Killer) rkllm_server braucht 6,5 GB von 7,74 GB. Blieb daneben ein anderer Dauerhelfer resident, wurde rkllm beim Laden OOM-getoetet -- bei #101 in allen 5 Startversuchen, weil das Retry nichts freigab. Ausloeser war der warme CT2-Whisper-Daemon, den die 2-Sekunden-Aufnahme davor gestartet hatte. Zweiter Fall: rkllm blieb nach der Analyse fuer immer resident, und der Suchindex startete e5-small daneben. gate.js bekommt eine Freigabe-Registry (registerReleaser/freeRam/ killAndWait, verdrahtet in server.js wie setProbe). Vor jedem rkllm-Versuch und vor dem NPU-Whisper-Lauf werden die anderen Helfer beendet und ihr Prozessende abgewartet; analyzeRecording gibt rkllm vor dem Suchindex frei. Alle Helfer starten faul neu. Verifiziert mit der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) -> Analyse #101 laeuft durch (221 s, done), rkllm lud in 22 s, kein OOM, danach 5,3 GB frei statt 100 MB. --- app/emb.js | 9 ++++- app/gate.js | 87 ++++++++++++++++++++++++++++++++++++++++++ app/llm.js | 24 +++++++++++- app/recorder.js | 19 ++++++++- app/server.js | 40 +++++++++++++++++-- app/spk.js | 10 +++++ docs/05-llm-analyse.md | 57 +++++++++++++++++++++++++++ docs/08-changelog.md | 38 ++++++++++++++++++ 8 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 app/gate.js diff --git a/app/emb.js b/app/emb.js index f1626f9..772a2ec 100644 --- a/app/emb.js +++ b/app/emb.js @@ -12,6 +12,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import readline from 'node:readline'; import path from 'node:path'; +import * as gate from './gate.js'; const PY = process.env.AIME_PY || '/opt/aime-venv/bin/python'; const DIR = process.env.AIME_EMB_DIR || '/opt/aime-models/e5-small'; @@ -91,4 +92,10 @@ export async function embedMany(texts) { return out; } -export function stop() { if (proc) { try { proc.kill(); } catch {} proc = null; ready = null; } } +// Beendet den Daemon und wartet auf sein Ende (siehe gate.freeRam: das e5-Modell +// hält in der Spitze 1,15 GB und muss weg, bevor rkllm lädt). Startet faul neu. +export function stop() { + const p = proc; + proc = null; ready = null; rl = null; + return gate.killAndWait(p); +} diff --git a/app/gate.js b/app/gate.js new file mode 100644 index 0000000..87ddfb1 --- /dev/null +++ b/app/gate.js @@ -0,0 +1,87 @@ +// Sperre für schwere Hintergrundarbeit, solange eine Aufnahme läuft. +// +// Warum: whisper, die sherpa-Diarisierung und rkllm dürfen nicht neben einer +// laufenden Aufnahme arbeiten. Auf diesem lüfterlosen 8-GB-Board hält rkllm +// allein ~6,7 GB (RssShmem 4,79 GB + RssAnon 1,93 GB von 7,74 GB MemTotal), es +// bleibt also ~1 GB Rest. Am 2026-08-06 lief die Analyse von #96 gleichzeitig +// mit der Transkription von #97 an — der globale OOM-Killer erschlug +// rkllm_server, systemd nahm den ganzen Dienst mit ('oom-kill'), und die GUI +// zeigte "failed to fetch". Dazu treibt die Zusatzlast die A76-Kerne auf ~80 °C, +// während das Mikrofon offen ist, und kostet Audioqualität. +// +// Der Recorder ist die einzige Wahrheit über den Aufnahmezustand; server.js +// hängt die Abfrage beim Start ein. Eigenes Modul, weil recorder.js bereits +// llm.js importiert — llm.js darf recorder.js deshalb nicht zurückimportieren +// (Zyklus). +let probe = () => false; + +// Beim Start aus server.js: gate.setProbe(() => rec.isRecording()). +export function setProbe(fn) { probe = typeof fn === 'function' ? fn : (() => false); } + +export function busy() { + try { return !!probe(); } catch { return false; } +} + +// Parken, bis der Recorder wieder still ist. Ein- und Austritt werden geloggt, +// damit eine lange Pause im Journal als Pause erkennbar ist und nicht wie ein +// hängender Job aussieht. +export async function waitIdle(label) { + if (!busy()) return 0; + const t0 = Date.now(); + console.log(`gate: ${label} pausiert — Aufnahme läuft`); + while (busy()) await new Promise(r => setTimeout(r, 2000)); + const s = Math.round((Date.now() - t0) / 1000); + console.log(`gate: ${label} fortgesetzt nach ${s} s Pause`); + return s; +} + +// --- Speicher freiräumen, bevor ein großes Modell lädt ----------------------- +// +// Auf diesem 7,74-GB-Board darf nur EIN großes Modell resident sein. rkllm_server +// braucht allein ~6,5 GB (davon 4,79 GB unevictable NPU-Shmem), und daneben halten +// die faul gestarteten Dauerhelfer in der Spitze (gemessen, VmHWM): +// e5-small (embed_text.py) 1,15 GB +// CT2-Whisper (transcribe_daemon) 0,90 GB +// TitaNet (spk_embed) 0,70 GB +// Am 2026-08-07 kostete das die Analyse von #101: die 2-Sekunden-Aufnahme #100 +// davor hatte den warmen CT2-Daemon gestartet (Kurz-Clip-Pfad, bleibt per Design +// resident), danach wurde rkllm in ALLEN 5 Startversuchen OOM-getötet → GUI +// "KI-Analyse fehlgeschlagen". zram hilft hier NICHT: der komprimierte Swap liegt +// selbst im RAM, ausgelagerte 0,9 GB kosten immer noch ~0,3-0,45 GB physisch. +// +// Jeder Helfer registriert deshalb, wie er sich beenden lässt; wer ein großes +// Modell laden will, ruft vorher freeRam(). Alle Helfer starten +// beim nächsten Aufruf faul neu — es geht nur Ladezeit verloren, kein Zustand. +const releasers = new Map(); +export function registerReleaser(name, stop) { releasers.set(name, stop); } + +// Beendet einen Kindprozess und wartet, bis er wirklich weg ist (sonst hat der +// Kernel die Seiten beim Laden des nächsten Modells noch nicht zurück). +// Rückgabe: true, wenn etwas beendet wurde. +export function killAndWait(proc, timeoutMs = 5000) { + if (!proc || proc.exitCode !== null || proc.signalCode) return Promise.resolve(false); + return new Promise(resolve => { + let settled = false; + const done = () => { if (!settled) { settled = true; resolve(true); } }; + proc.once('exit', done); + try { proc.kill('SIGTERM'); } catch { done(); return; } + setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} setTimeout(done, 500); }, timeoutMs); + }); +} + +// Alle registrierten Helfer außer `keep` beenden. Wird vor jedem Ladeversuch +// eines großen Modells aufgerufen — auch vor einem Retry, denn ein Retry ohne +// freien Speicher wird genauso OOM-getötet wie der erste Versuch. +export async function freeRam(keep) { + const freed = []; + for (const [name, stop] of releasers) { + if (name === keep) continue; + try { if (await stop()) freed.push(name); } + catch (e) { console.warn(`ram: ${name} nicht freigegeben — ${e.message}`); } + } + if (freed.length) { + console.log(`ram: für ${keep} freigegeben — ${freed.join(', ')}`); + await new Promise(r => setTimeout(r, 1500)); // Kernel-Reclaim abwarten + } + return freed; +} diff --git a/app/llm.js b/app/llm.js index 86e14ba..9b2f2f3 100644 --- a/app/llm.js +++ b/app/llm.js @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import readline from 'node:readline'; +import * as gate from './gate.js'; const SERVER = '/opt/callrec/rkllm_server'; const MODEL = process.env.RKLLM_MODEL || '/opt/rkllm-models/qwen2.5-3b.rkllm'; @@ -30,6 +31,14 @@ function spawnServer() { let done = false; proc = spawn('taskset', ['-c', '4-7', SERVER, MODEL], { stdio: ['pipe', 'pipe', 'pipe'] }); proc.stderr.on('data', d => process.stderr.write('[rkllm] ' + d)); + // Ein write() in die stdin eines toten Kindes loest 'error' (EPIPE) auf + // diesem Socket aus. Ohne Listener behandelt node das als unhandled + // 'error' event und beendet den GANZEN Dienst — genau so starb callrec am + // 2026-08-06 nach #98. Der Fehler gehoert an die offene Anfrage, nicht an + // den Prozess. + proc.stdin.on('error', e => { + if (pending) { const p = pending; pending = null; p.reject(new Error('rkllm stdin: ' + e.message)); } + }); ready = new Promise((resolve, reject) => { settleReject = reject; // 180s: Qwen3-4B (5.3GB) needs ~96s to load on the capped clock — the old @@ -61,6 +70,11 @@ async function startServer() { // loads succeed. Up to 5 attempts, ~4s apart. let lastErr; for (let attempt = 1; attempt <= 5; attempt++) { + // Vor JEDEM Versuch die anderen großen Helfer beenden. rkllm braucht ~6,5 GB + // von 7,74 GB; blieb z. B. der warme CT2-Daemon der vorherigen Kurz-Aufnahme + // resident, wird rkllm OOM-getötet — und ein Retry ohne freien Speicher + // stirbt genauso (2026-08-07: 5/5 Versuche, "KI-Analyse fehlgeschlagen"). + await gate.freeRam('rkllm'); try { spawnServer(); await ready; return; } catch (e) { lastErr = e; @@ -78,10 +92,18 @@ async function startServer() { // next one (otherwise one bad chunk fails all following chunks instantly). function ask(prompt) { const run = chain.then(async () => { + // Haltepunkt zwischen den Chunks: startet mittendrin eine Aufnahme, + // pausiert die Analyse hier, statt neben dem offenen Mikrofon zu rechnen. + await gate.waitIdle('llm'); await startServer(); return await new Promise((resolve, reject) => { + // Zweites Netz: der Prozess kann zwischen startServer() und hier + // gestorben sein (exit-Event noch nicht verarbeitet). + if (!proc || !proc.stdin || proc.stdin.destroyed) + return reject(new Error('rkllm nicht bereit (Prozess weg)')); pending = { resolve, reject }; - proc.stdin.write(b64e(prompt) + '\n'); + try { proc.stdin.write(b64e(prompt) + '\n'); } + catch (e) { pending = null; reject(new Error('rkllm stdin: ' + e.message)); } }); }); chain = run.catch(() => {}); // next request starts from a clean, resolved chain diff --git a/app/recorder.js b/app/recorder.js index 00b0423..446c593 100644 --- a/app/recorder.js +++ b/app/recorder.js @@ -5,6 +5,7 @@ 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'; @@ -145,6 +146,16 @@ function startTd() { 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 () => { @@ -400,7 +411,10 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) { } 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(); + // 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); @@ -426,6 +440,9 @@ export async function transcribeWav(wavFile, numSpeakers, opts = {}) { } } // 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 || [] }); diff --git a/app/server.js b/app/server.js index 33fe333..7879995 100644 --- a/app/server.js +++ b/app/server.js @@ -12,8 +12,26 @@ 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'; 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) { @@ -254,7 +272,7 @@ async function analyzeRecording(id) { // 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 llm.stopServer(); await pexec('sudo', ['-n', '/opt/callrec/aime-cpucap.sh', '1608000']).catch(() => {}); try { const rec = await db.getRecording(id); @@ -312,6 +330,13 @@ async function analyzeRecording(id) { 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 @@ -373,6 +398,10 @@ function withNpuLock(label, fn) { 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--; } }); @@ -411,7 +440,7 @@ async function transcribeAndAnalyze(id, wavFile, numSpeakers) { await db.updateAnalysis(id, { title: null, summary: null, todos: null, status: 'error' }).catch(() => {}); return; // no transcript -> skip LLM analysis } - analyzeRecording(id); + await analyzeRecording(id); } app.post('/api/record/stop', wrap(async (req, res) => { @@ -593,7 +622,7 @@ app.post('/api/search/answer', wrap(async (req, res) => { try { res.json(await withNpuLock('search-answer', () => search.answer(q, hits))); } finally { - llm.stopServer(); + await llm.stopServer(); } })); app.get('/api/search/status', wrap(async (req, res) => { @@ -666,7 +695,10 @@ async function rediarize(id, numSpeakers) { // 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(); + // 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). diff --git a/app/spk.js b/app/spk.js index 9c77fc9..3dbc67e 100644 --- a/app/spk.js +++ b/app/spk.js @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import readline from 'node:readline'; +import * as gate from './gate.js'; const BIN = '/opt/callrec/spk_embed'; const DIR = '/opt/sherpa/sherpa-onnx-v1.13.3-linux-aarch64-shared-cpu'; @@ -78,3 +79,12 @@ export function bestMatch(emb, voiceprints, threshold = THRESHOLD) { } export function status() { return { model: EMB, modelId: modelId(), present: fs.existsSync(EMB), running: !!proc, threshold: THRESHOLD }; } + +// Beendet den TitaNet-Helfer und wartet auf sein Ende (siehe gate.freeRam: hält +// in der Spitze 0,7 GB). Startet beim nächsten embed() faul neu. Wird NUR +// zwischen den Phasen gerufen, nie während der Diarisierung. +export function stop() { + const p = proc; + proc = null; ready = null; rl = null; + return gate.killAndWait(p); +} diff --git a/docs/05-llm-analyse.md b/docs/05-llm-analyse.md index 5fd5e6b..0385e1c 100644 --- a/docs/05-llm-analyse.md +++ b/docs/05-llm-analyse.md @@ -248,3 +248,60 @@ swappen seriell unter dem NPU-Lock. **Thermik:** `analyzeRecording()` läuft seit 2026-07-16 unter dem A76-Takt-Cap (`.bak-anacap`) — rkllm zieht neben der NPU ~1,7 CPU-Kerne, eine lange ungedeckelte Gemma-Generierung erreichte **95–97 °C** (lüfterlos, Schutzabschaltungs-Risiko). Gleicher Cap wie `transcribeWav`/`rediarize` (Kap. 03). + +## Speicherhaushalt: nur EIN großes Modell resident (seit 2026-08-07) + +Bis dahin hat jeder Helfer für sich entschieden, wann er lädt und wer dafür weichen muss — und das ging +schief. **Gemessene Spitzen (`VmHWM`) auf 7,74 GB Gesamtspeicher:** + +| Prozess | Spitze | Anmerkung | +|---|---|---| +| `rkllm_server` (Qwen3-4B w8a8) | **6,5 GB** | davon 4,79 GB `unevictable` NPU-Shmem — nicht auslagerbar | +| `embed_text.py` (e5-small, Suche) | 1,15 GB | faul, bleibt danach resident | +| `transcribe_daemon_cpu.py` (CT2) | 0,90 GB | Kurz-Clip-Pfad, bleibt per Design warm | +| `spk_embed` (TitaNet) | 0,70 GB | faul, bleibt danach resident | + +**Zwei Ausfälle am 2026-08-07, beide aus derselben Wurzel** (Aufnahme #101, 34 s, 2 Personen): + +1. Die 2-Sekunden-Aufnahme #100 davor hatte den warmen CT2-Daemon gestartet. Beim anschließenden + rkllm-Load war er noch resident → `rkllm_server` wurde in **allen 5 Startversuchen** OOM-getötet, + GUI: „KI-Analyse fehlgeschlagen". Ein Retry ohne Aufräumen stirbt zwangsläufig genauso wie der erste + Versuch. +2. Nach einer Analyse blieb rkllm **für immer** resident (belegt: der Prozess von 10:37 lebte noch um + 13:27). Der Suchindex am Ende von `analyzeRecording()` startet aber das e5-Modell — 1,15 GB neben + 6,5 GB → der Kernel erschlug rkllm (13:27:09, `dmesg`). Das war auch der Dauerzustand + „nur noch ~100 MB frei", in dem jeder weitere Helferstart Glückssache war. + +⚠️ **zram rettet das nicht:** der komprimierte Swap liegt selbst im RAM. 0,9 GB ausgelagert kosten +weiter ~0,3–0,45 GB physisch — und rkllms 4,79 GB Shmem sind ohnehin nicht auslagerbar. + +**Regel jetzt:** wer ein großes Modell lädt, räumt vorher auf. `gate.js` führt dazu eine Registry +(`registerReleaser` / `freeRam(keep)`, verdrahtet in `server.js` — dasselbe Muster wie `setProbe`, damit +`gate.js` keines der Module importieren muss). Alle Helfer starten faul neu; es geht nur Ladezeit +verloren, kein Zustand. + +```mermaid +flowchart TB + subgraph REG["gate.js — Registry"] + R1["rkllm → llm.stopServer()"] + R2["cpu-transcriber → rec.stopTranscribeDaemon()"] + R3["text-embedder → emb.stop()"] + R4["spk-embedder → spk.stop()"] + end + A["NPU-Whisper startet
(recorder.js)"] --> FA["freeRam('npu-whisper')
→ beendet ALLE vier"] + B["rkllm lädt
(llm.startServer, vor JEDEM
der 5 Versuche)"] --> FB["freeRam('rkllm')
→ beendet die anderen drei"] + FA --> REG + FB --> REG + REG --> W["SIGTERM, auf Prozessende warten,
1,5 s Kernel-Reclaim
(gate.killAndWait)"] + W --> LOAD["großes Modell lädt allein"] + LOAD --> END["Analyse fertig →
llm.stopServer() VOR dem Suchindex
(analyzeRecording)"] +``` + +**Verifiziert am 2026-08-07** mit genau der Abfolge, die vorher scheiterte: 12-s-Clip → warmer +CT2-Daemon resident → Analyse von #101 → Journal +`ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in **22 s** (nicht +96 s: die Modelldatei lag noch im Seiten-Cache), Analyse in 221 s fertig, kein OOM. Danach **5,3 GB +frei** statt 100 MB. + +**Preis:** die nächste Analyse lädt rkllm neu. Der Kurz-Clip-Pfad verliert damit seinen „rkllm bleibt +warm"-Vorteil (Kap. 03) — dafür ist die Analyse verlässlich statt schnell-aber-manchmal-tot. diff --git a/docs/08-changelog.md b/docs/08-changelog.md index fa49c31..cf3db92 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,44 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig + +**Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber +„KI-Analyse fehlgeschlagen". Also **nicht** dieselbe Ursache wie der Vormittags-Fix: der betraf die +Länge, dieser die *Reihenfolge*. Gemeinsam ist nur der OOM-Killer. + +**Ursache 1:** `rkllm_server` braucht 6,5 GB von 7,74 GB. Die 2-Sekunden-Aufnahme #100 unmittelbar davor +hatte über den Kurz-Clip-Pfad den warmen CT2-Whisper-Daemon (0,9 GB) gestartet, der per Design resident +bleibt. Beim rkllm-Load war er noch da → OOM-getötet, und zwar in **allen 5 Startversuchen**, weil das +Retry nichts freigab: `[rkllm] start 5/5 failed: rkllm beendet vor READY`. + +**Ursache 2:** rkllm blieb nach einer Analyse für immer resident (der Prozess von 10:37 lebte um 13:27 +noch). Der Suchindex am Ende von `analyzeRecording()` startet aber e5-small (1,15 GB) — daneben erschlug +der Kernel rkllm (13:27:09). Dasselbe Muster war auch der Dauerzustand „nur ~100 MB frei". + +**Fix:** `gate.js` bekommt eine Freigabe-Registry (`registerReleaser`/`freeRam(keep)`/`killAndWait`, +verdrahtet in `server.js` wie schon `setProbe`). Wer ein großes Modell lädt, beendet vorher die anderen +Dauerhelfer und wartet auf ihr Prozessende — vor **jedem** der 5 rkllm-Versuche und vor dem +NPU-Whisper-Lauf. Dazu gibt `analyzeRecording()` rkllm frei, **bevor** der Suchindex läuft. Alle Helfer +starten faul neu; verloren geht nur Ladezeit. + +**Verifiziert** mit genau der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) → Analyse #101 → +`ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in 22 s, Analyse +„Auto und Budget" in 221 s fertig (`done`), kein OOM, danach **5,3 GB frei** statt 100 MB. + +Dateien: `app/gate.js`, `app/llm.js`, `app/server.js`, `app/recorder.js`, `app/spk.js`, `app/emb.js` +(Board-Backups `.bak-ramfree`), Kapitel [05 – LLM-Analyse](05-llm-analyse.md) (neuer Abschnitt +„Speicherhaushalt" mit Messtabelle und Diagramm). + +**Offen, getrennt davon (gemessen, nicht gefixt):** dieselbe Aufnahme zeigt 4 Sprecher statt 2, und die +Stimmabdruck-Erkennung ist auf ROHEN Kosinus nicht trennscharf — `Sascha` ↔ `Sascha Wolf` (dieselbe +Person, zwei Einträge) liegt bei 0,928, aber `Sascha Wolf` ↔ `Johannes` (verschiedene Personen) bei +**0,912** und `Sascha` ↔ `Fabio Haase` bei 0,876. Bei `DIAR_VP_MIN` 0,55 passt damit fast jeder auf +jeden; eine höhere Schwelle würde nur alles ablehnen. `Jens` ↔ `Jens Kuppler` ist mit Kosinus **1,000** +sogar eine exakte Dublette. Der Clustering-Teil hat dieses Problem 2026-07-27 durch Mittelwert-Zentrierung +gelöst — die Abdruck-Erkennung arbeitet noch ohne. Dazu kommt die Über-Segmentierung kurzer Clips +(diagnostiziert 2026-07-31, Kap. 04). + ## 2026-08-07 — Lange Aufnahmen scheiterten: sherpa-Diarisierung lief in den OOM-Killer **Symptom:** Aufnahme #99 (53:56) endete mit „Transkription fehlgeschlagen“ (Badge „KI-Fehler“), beim