Files
Aime/docs/08-changelog.md
SaschaandClaude Opus 5 3a2e8144f7 Drei weitere Kandidaten geprueft, alle drei abgelehnt
Ministral-3-3B: Architektur mistral3 steht nicht in der Liste des
rkllm-Toolkits, dazu FP8-Gewichte -- nie konvertiert. Config auf Llama
umbiegen waere moeglich gewesen, aber ein falsch eingelesenes Modell
haette plausiblen Unsinn produziert.

Phi-4-mini: konvertiert (5,06 GB), laedt aber nicht auf der NPU.
partial_rotary_factor 0.75 ergibt 48 LongRoPE-Faktoren, die Runtime
erwartet starr head_dim/2 = 64. Rockchips "Phi3" meint Phi-3-mini.

SmolLM3-3B: laeuft, in 2 von 3 Faellen schneller als Qwen3, scheitert
aber inhaltlich -- 13 statt 61 Todos, JSON-Schluessel als
Listeneintraege, Englisch-Drift im deutschen Text.

rkllm_server.cpp: Chat-Template kommt jetzt aus der Umgebung
(RKLLM_TMPL_SYSTEM / _USER_PRE / _USER_POST), Defaults = bisheriges
ChatML-Verhalten, mit Qwen3 gegengeprueft. Damit ist der naechste
Kandidat eine Drop-in-Zeile statt einer Neuuebersetzung.

Doku: Changelog 2026-09-29 (4) mit Messwerten und allen Fallstricken,
Kapitel 05 um Architektur-Vorpruefung und Template-Konfiguration
erweitert. Messstrecke: Zeitlimit 45 -> 90 Minuten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A91sWU5aWrqZ2DWNTLVph
2026-09-29 19:59:04 +02:00

107 KiB
Raw Permalink Blame History

08 — Changelog

Chronik der Entwicklungsschritte. Regel: jede zukünftige Änderung bekommt hier einen Eintrag (Datum, Was, Warum, betroffene Dateien/Backups).

2026-09-29 (4) — Zwei weitere Kandidaten geprüft: Ministral, Phi-4-mini, SmolLM3

Nach dem Qwen3.5-Ergebnis systematisch auf Hugging Face gesucht. Drei Kandidaten, alle drei gescheitert — zwei davon, bevor überhaupt gemessen werden konnte.

Vorab: welche Architekturen das Toolkit überhaupt kennt

Aus rkllm_toolkit-1.3.0 (loader.so/common.so) extrahiert — vor dem nächsten Download prüfen:

Baichuan, ChatGLM, Deepseek, DeepseekOCR, Gemma/2/3/3n/4, InternLM2, Llama,
MiniCPM/3, Phi/Phi3, Qwen2/2VL/2.5VL/3/3VL/3.5, RWKV7, SmolLM3, TeleChat/2

Prüfbefehl: architectures aus der config.json des HF-Repos gegen diese Liste halten (curl -sL https://huggingface.co/<repo>/raw/main/config.json). Das kostet Sekunden statt Gigabyte.

Ministral-3-3B-Instruct-2512 — gar nicht erst konvertierbar

Mistral3ForConditionalGeneration / model_type: mistral3 steht nicht in der Liste; der einzige Mistral-Treffer im Toolkit ist ein Tokenizer-Detail namens fix_mistral_regex. Dazu sind die veröffentlichten Gewichte FP8-quantisiert (quant_method: fp8), das Toolkit erwartet bf16/fp16. Die config.json auf LlamaForCausalLM umzubiegen wäre möglich gewesen — bewusst nicht gemacht: Mistral 3 hat Sliding-Window-Attention und einen multimodalen Wrapper, ein falsch eingelesenes Modell hätte plausiblen Unsinn produziert und den A/B wertlos gemacht.

Phi-4-mini-instruct — konvertiert, lädt aber nicht auf der NPU

Konvertierung gelang erst im dritten Anlauf (5,06 GB, 244 s):

  1. cannot import name 'LossKwargs' from 'transformers.utils' — der mitgelieferte modeling_phi3.py ist gegen ein älteres transformers geschrieben, das Toolkit verlangt 5.8.0.
  2. auto_map auf die native Implementierung umgebogen → Not found AutoModelForCausalLM in the auto_map.
  3. auto_map komplett entfernt (nur architectures: Phi3ForCausalLM bleibt) → Konvertierung sauber.

Auf dem Board dann:

E rkllm: Tensor rope_factors_long.weight has wrong shape, expected 64, got 48
rkllm_init failed

Ursache: Phi-4-mini nutzt partielle Rotary Embeddings (partial_rotary_factor: 0.75). Bei head_dim 128 werden nur 96 Dimensionen rotiert → 48 LongRoPE-Faktoren. Das Modell ist stimmig; die rkllm-Runtime rechnet in ihrem Phi3-Pfad starr mit head_dim/2 = 64 und kennt nur die Geometrie von Phi-3-mini. Rockchips „Phi3" meint Phi-3, nicht Phi-4-mini. Die LongRoPE-Skalierung auszubauen hätte den Kontext auf die Basislänge zurückgeworfen — wir brauchen 12288. Abgebrochen.

SmolLM3-3B — gemessen und verloren

Konvertierung sauber (3,74 GB, 181 s; Warnung „pad token has two ids 128012/128004" stammt aus dem Modell, ohne Folgen für Einzelgenerierung). Template: ChatML plus /no_think in der Systemzeile plus leerer Denkblock — SmolLM3 denkt sonst per Default.

Aufnahme Zeichen Qwen3-4B SmolLM3-3B Tempo Summary Todos
#86 8 390 10,5 min 8,8 min −17 % 2 896 → 1 167 6 → 2
#123 19 275 24,5 min 27,6 min +13 % 6 071 → 1 988 18 → 5
#114 34 843 41,8 min 38,1 min −9 % 9 288 → 2 386 37 → 6

SmolLM3 ist stellenweise schneller (2 von 3 Fällen) — erwartbar bei 3B gegen 4B. Es scheitert am Inhalt:

  • Todos strukturell kaputt: ["task","Melanie trifft sich …","user","Philipp","status"] — das Modell schreibt die JSON-Schlüssel als Listeneinträge. Über alle drei Gespräche 13 statt 61 Einträge, und die vorhandenen sind teils Schrott.
  • Englisch-Drift: „Their Plan ist nun: am 28., Freitag …" mitten im deutschen Fließtext.
  • Platzhalter-Artefakte: [...] als Auslassungszeichen im Ergebnistext.
  • Zeilenumbrüche landen als literales \n bzw. \\n im Text (wie schon bei Qwen3.5).

Fazit nach drei Kandidaten an einem Tag

Qwen3-4B-Instruct-2507 bleibt, und der Abstand ist größer als gedacht. Die eigentliche Erkenntnis ist nicht „Modell X ist schlechter", sondern: die Menge der Modelle, die auf dieser NPU überhaupt laufen, ist deutlich kleiner als Rockchips Unterstützungsliste suggeriert — eine Architektur kann gelistet sein und trotzdem an der Geometrie scheitern (Phi-4-mini). Und von denen, die laufen, scheitert bisher jedes an derselben Stelle: sauberes JSON über lange deutsche Transkripte hinweg, nicht am Sprachverständnis.

Code-Änderung, die bleibt: Chat-Template aus der Umgebung

rkllm_server.cpp liest das Template jetzt aus der Umgebung, weil rkllm das HF-Template nicht selbst anwendet (ohne passende Marker faselt das Modell generisches Füllmaterial):

Variable Default (= bisheriges Verhalten)
RKLLM_TMPL_SYSTEM leer
RKLLM_TMPL_USER_PRE <|im_start|>user\n
RKLLM_TMPL_USER_POST <|im_end|>\n<|im_start|>assistant\n
RKLLM_SUPPRESS_THINK aus; 1 hängt <think>\n\n</think>\n\n an USER_POST

\n und \t werden im Server dekodiert, weil systemd-Environment=-Zeilen keine echten Umbrüche enthalten können. Ohne die Variablen ist das Verhalten unverändert — mit Qwen3 gegengeprüft (97,9 s, chat template: sys=0 user_pre=17 user_post=33 bytes). Backups rkllm_server{,.cpp}.bak-tmplenv. Der nächste Kandidat ist damit eine Drop-in-Zeile statt einer Neuübersetzung.

GOTCHA: printf mit \n schreibt echte Zeilenumbrüche in die Unit-Datei und zerlegt die Environment=-Zeile. Zitiertes Heredoc nehmen, damit das literale \n stehen bleibt:

cat > /etc/systemd/system/callrec.service.d/gemma-model.conf <<'EOF'
[Service]
Environment="RKLLM_TMPL_SYSTEM=<|im_start|>system\n/no_think<|im_end|>\n"
EOF

Aufgeräumt

Beide Testmodelle vom Board gelöscht, Drop-in zurück auf Qwen3, Analysen von #86/#114/#123 per restore wiederhergestellt (19,4 min/7 Todos, 58,5 min/32, 38,4 min/16).

#112 brauchte den Umweg über das Backup: der Rauchtest-Schnappschuss hatte den Fehlerzustand des Phi-Versuchs eingefangen, restore hätte den zurückgeschrieben. Stattdessen db-callrec.sql.gz aus der Sicherung in eine Hilfsdatenbank callrec_restore geladen, die eine Zeile per JOIN zurückkopiert, Hilfsdatenbank gelöscht — #112 steht wieder auf 124,0 s / 118 Zeichen. Lehre: snapshot nie auf einer Aufnahme laufen lassen, die gerade einen fehlgeschlagenen Lauf hinter sich hat.

2026-09-29 (3) — A/B-Ergebnis: Qwen3.5-4B verliert, Qwen3-4B bleibt produktiv

Durchgeführt: Qwen3.5-4B selbst konvertiert (RTX 3090, rkllm-toolkit 1.3.0 im Container, w8a8_g128, max_context 16384, 3 NPU-Kerne — identisch zum produktiven Modell), 5,53 GB, Konvertierung 308 s. Übertragung aufs Board per md5 verifiziert. Danach beide Modelle über lab/ab-llm.mjs gegen dieselben drei Aufnahmen.

Aufnahme Zeichen Qwen3-4B Qwen3.5-4B Tempo Summary Todos
#86 8 390 10,5 min 13,1 min +25 % 2 896 → 848 6 → 0
#123 19 275 24,5 min 29,7 min +21 % 6 071 → 3 064 18 → 2
#114 34 843 41,8 min 60,8 min +45 % 9 288 → 2 292 37 → 0

Urteil: Qwen3-4B-Instruct-2507 bleibt. Qwen3.5 ist auf unserer NPU durchgängig langsamer, und der Ausschlag ist nicht das Tempo, sondern die Todos: von 61 extrahierten Aufgaben über die drei Gespräche bleiben 2 übrig. Die Aufgabenliste ist eine Kernfunktion — damit ist das Modell für uns unbrauchbar, unabhängig von Benchmark-Werten (Artificial-Analysis-Index 27 vs. 18).

Fair gegenüber Qwen3.5: die Prosa ist nicht schlechter, eher dichter. Bei #123 nennt Qwen3.5 Philipp korrekt als neues Mitglied, während Qwen3 ihn zur „neuen Teilnehmerin" macht. Das Modell scheitert nicht am Verstehen, sondern an der Formatdisziplin: 13 JSON-Parse-Fehler mit Wiederholungen während der Läufe (chunk 12 unparseable -> retry 3), im Ergebnis leere todos-Arrays, und die Zeilenumbrüche landen teils als literales \n im Text.

Neu: RKLLM_SUPPRESS_THINK in rkllm_server.cpp

Erster Versuch scheiterte komplett: Qwen3.5 antwortete mit Gedankengang statt JSON (llm: combined JSON unparseable, Auszug: „Wait, looking at line …"), Analyse brach ab. RKLLMInput.enable_thinking = false reicht bei diesem Modell NICHT. Das Denken schaltet erst ab, wenn der Assistenten-Zug mit einem leeren Denkblock vorbefüllt wird — so macht es das chat_template.jinja des Modells (Zeile 149-152): {{- '<think>\n\n</think>\n\n' }}.

rkllm_server.cpp hängt diesen Block jetzt an das Chat-Template an, wenn RKLLM_SUPPRESS_THINK=1 in der Umgebung steht; ohne die Variable bleibt das Template unverändert (Qwen3, Gemma3, Qwen2.5 brauchen es nicht). Neu übersetzt auf dem Board: g++ -O2 -std=c++17 -o rkllm_server rkllm_server.cpp -I/usr/include -L/lib -lrkllmrt. Backups: rkllm_server.cpp.bak-think, rkllm_server.bak-think. Diese Änderung bleibt — sie kostet nichts und macht jeden künftigen Denk-Modell-Test möglich.

Zur Frage rknn-llm v1.3.1 (erschienen am selben Tag)

Enthält u. a. eine linear attention cache reuse strategy, die auf Issue #519 zielt (unter 1.3.0 meldet RKLLM bei Qwen3.5 „Current model does not support kv cache reuse"). Für uns wirkungslos: unser Server fährt bewusst keep_history = 0 und ruft rkllm_clear_kv_cache() vor jedem Lauf, damit kein Kontext zwischen Aufnahmen durchsickert — es gibt nichts wiederzuverwenden, und die Warnung taucht in unserem Journal gar nicht auf. Der gemessene Rückstand liegt zudem beim Decode (weniger Text in mehr Zeit), nicht beim Prefill, den ein Cache-Reuse entlasten würde. Update daher nicht durchgeführt.

Zustand nach dem Test

Modell-Drop-in auf qwen3-4b-w8a8g128-16k.rkllm zurückgesetzt, restore hat die Originalanalysen von #86, #112, #114 und #123 zurückgeschrieben (geprüft: 19,4 min / 7 Todos, 58,5 min / 32 Todos, 38,4 min / 16 Todos). Das konvertierte qwen35-4b-w8a8g128-16k.rkllm (5,9 GB) bleibt unter /opt/rkllm-models/ liegen — für einen späteren Versuch mit angepasstem Prompt oder neuerer Runtime.

GOTCHA für künftige Läufe: lab/ab-llm.mjs hat ein Zeitlimit von 45 Min pro Aufnahme; #114 brauchte 60,8 Min und wurde deshalb in run-qwen35.json als pending eingefroren, obwohl die Analyse später sauber durchlief. Die echten Werte standen in der DB. Bei langen Gesprächen TIMEOUT_MS hochsetzen.

2026-09-29 (2) — A/B-Aufbau Qwen3.5-4B (Sicherung, Messstrecke, Konvertierungs-Kit)

Anlass: Recherche zur Frage, ob es inzwischen ein besseres LLM gibt als das produktive Qwen3-4B-Instruct-2507. Ergebnis: Qwen3.5 (02.03.2026, Apache 2.0, Größen 0,8/2/4/9B, hybride Gated-DeltaNet-Architektur, 262k Kontext, 201 Sprachen) ist der ernsthafte Kandidat — im Artificial-Analysis-Index springt die 4B von 18 auf 27. rknn-llm v1.3.0 (17.06.2026) hat die Unterstützung dafür gebracht, und genau diese Version läuft bereits auf dem Board (RKLLM SDK 1.3.0 | build 82bf4aa8e 2026-06-16, NPU-Treiber v0.9.8) — kein Runtime-Upgrade nötig.

Sicherung vor dem Test

Gezielte Sicherung nach D:\Vibe Coding\_Spielplatz\Aime-Backup-2026-09-29 (nicht im Repo): /opt/callrec ohne recordings/+models/ (14,7 MB), /etc/systemd/system + sudoers.d + Netz/Host (7,4 KB), WirePlumber/PipeWire-Drop-ins (1,5 KB), MariaDB-Dump (8,0 MB), Zustandsbericht mit den md5 aller .rkllm-Modelle. Alle Prüfsummen nach der Übertragung verifiziert. Aufnahmen (5,4 GB) und Modelle (14 GB) bewusst nicht mitgezogen — die liegen unverändert auf dem Board, die Vollsicherung des rootfs ist die vom 2026-08-03.

Warum der Wechsel billig rückgängig zu machen ist

Das aktive Modell hängt an einer einzigen systemd-Zeile, /etc/systemd/system/callrec.service.d/gemma-model.conf → Environment=RKLLM_MODEL=... (llm.js:10 liest process.env.RKLLM_MODEL). Zurückschalten = Pfad tauschen, daemon-reload, systemctl restart callrec. Die alten .rkllm bleiben liegen.

Neu: Messstrecke lab/ab-llm.mjs

Der Vergleich läuft über POST /api/recordings/:id/reanalyze-llm — ohne Neu-Diarisierung, also gleiches Transkript, gleiche Sprecher, gleiche Kontextnotiz. Gemessen wird nur das Modell.

node lab/ab-llm.mjs snapshot 86 123 114     # Originalanalysen sichern (PFLICHT, reanalyze ueberschreibt)
node lab/ab-llm.mjs run qwen3 86 123 114    # Baseline mit dem aktuellen Modell
# ... Modell umstellen, callrec neu starten ...
node lab/ab-llm.mjs run qwen35 86 123 114
node lab/ab-llm.mjs report qwen3 qwen35     # Tabelle + Volltext-Vergleich als Markdown
node lab/ab-llm.mjs restore                 # Originalanalysen zurueckschreiben

Testfälle bewusst über die Pfadgrenzen gelegt: #86 (8,4k Zeichen, kurzer Whole-Pass), #123 (19,3k — die Größenordnung, an der Gemma im Juli-A/B die Gewichtung verlor), #114 (34,8k, also über WHOLE_MAX_CHARS=32000 → gestückelter Sektions-Pfad).

Neu: tools/rkllm-qwen35/ (Konvertierung auf dem PC)

Dockerfile (CUDA 12.4 + torch 2.6.0-cu124 + rkllm-toolkit 1.3.0, Version muss zur Board-Runtime passen), convert_qwen35.py und run_convert.ps1. Parameter identisch zum produktiven Modell, damit der A/B das Modell misst und nicht die Konvertierung: w8a8_g128, 3 NPU-Kerne, max_context=16384 (Toolkit-Obergrenze; die Runtime nutzt davon 12288, rkllm_server.cpp:52).

GOTCHAs, festgehalten:

  • run_convert.ps1 gehört in die PowerShell, nicht in die Git-Bash — die schreibt Docker-Mount-Pfade um (/data → C:/Program Files/Git/data). Gleiche Falle wie bei der Qwen3-Konvertierung im Juli.
  • torch wird im Dockerfile vor den übrigen Requirements und explizit als cu124-Rad installiert, und torch/torchvision werden aus requirements.txt gefiltert — sonst zieht pip die CPU-Räder nach und die Quantisierung läuft stundenlang auf der CPU.
  • auto_gptq darf fehlschlagen (nur für den GPTQ-Pfad nötig, nicht für w8a8).
  • Thinking ist kein Problem: Qwen3.5 denkt per Default, aber rkllm_server.cpp:89 setzt bereits in.enable_thinking = false.

Kalibrierung: make_calib.py baut aus echten deutschen Transkripten einen Kalibriersatz ([{"input":..,"target":..}], 64 Ausschnitte à ~1400 Zeichen). Der Satz bleibt bewusst auf dem Board (/tmp/calib-de.json) — Gesprächsinhalte wandern nicht auf den PC. Wer ihn nutzen will, konvertiert auf dem Board oder kopiert ihn bewusst; ohne --dataset nimmt das Toolkit seinen eingebauten Datensatz.

Stand: Baseline-Lauf qwen3 gestartet, Modell noch nicht konvertiert — Ergebnis folgt in einem eigenen Eintrag.

2026-09-29 — Aufnahmezeitpunkt in der Detailansicht

Wunsch: „Wenn man eine Aufzeichnung geöffnet hat, soll neben der Analysezeit auch das Datum und Uhrzeit der Aufzeichnung stehen. Denn wenn man z. B. aus der Suche heraus eine Aufzeichnung öffnet, sieht man links im Menü nicht wann diese aufgenommen wurde, da diese dort nicht ausgewählt wurde.“

Warum es fehlte: der Zeitpunkt stand bisher nur in der linken Aufnahmenliste (Tagesgruppe + Uhrzeit, main.js:1134/1156). Wer über die Inhaltssuche (Kap. 09), über einen Projekt-Pin (Kap. 10) oder über einen Direktlink in die Detailansicht springt, hat dort keine markierte Zeile — der Kopf der Detailansicht zeigte Titel, Player und höchstens „⏱ Analysezeit“, aber nie, wann das Gespräch stattfand.

Was geändert wurde (web/src/main.js, Backup main.js.bak-rectime)

  • Neuer Helfer recStamp(r) neben anaTimeSpan(r): formatiert r.created_at als Di., 29.09.2026, 14:23 Uhr (toLocaleDateString/toLocaleTimeString mit de-DE, Wochentag kurz). Leerer String bei fehlendem oder unparsbarem Datum → die Zeile fällt dann einfach weg.
  • In der .status-line der Detailansicht steht die Zeile vor der Analysezeit: 🗓 <Datum, Uhrzeit> / ⏱ Analysezeit: ….
  • Keine Server- oder DB-Änderung nötig: GET /api/recordings/:id liefert created_at bereits mit.

Gegenprobe: npx vite build → Bundle index-DtL7bqdT.js (enthält rec-time), curl http://127.0.0.1/ liefert genau dieses Bundle. md5sum von web/src/main.js auf dem Board und app/web/src/main.js im Repo identisch. Kein systemctl restart (reiner Frontend-Build).

2026-09-09 — GUI-Hinweis „A76-Takt gegen Überhitzung gedrosselt“ entfernt

Wunsch: „Kannst du bitte den Hinweis nicht mehr anzeigen lassen“ (Screenshot des Klammerzusatzes (A76-Takt gegen Überhitzung gedrosselt)).

Was geändert wurde

  • web/src/main.js:1378: der Wartetext während status=transcribing lautet jetzt nur noch „📝 Transkription läuft… lange Aufnahmen dauern.“ — der Klammerzusatz zum Takt-Cap ist weg. Der Hinweis selbst bleibt stehen, weil er die Wartezeit erklärt.
  • Nur Anzeige, keine Technik: das Takt-Cap (aime-cpucap.sh, 2256 → 1608 MHz) läuft unverändert für alle drei schweren Pfade weiter (Kap. 01 und 03) — es wird nur nicht mehr in der GUI erwähnt.
  • Backup auf dem Board: web/src/main.js.bak-throttlehint.

Build-GOTCHA: npx vite build als callrec scheiterte mit EACCES: permission denied, unlink '/opt/callrec/web/dist/assets/index-DAWZGY3G.js' — dist/assets gehörte root (ein früherer Build lief als root), und vite leert outDir vor dem Schreiben. Fix: chown -R callrec:callrec /opt/callrec/web/dist, danach Build als callrec → neues Bundle index-C_J2NgHg.js.

Gegenprobe: grep -c 'A76-Takt' im neuen Bundle = 0, curl http://127.0.0.1/ liefert index-C_J2NgHg.js. Kein systemctl restart nötig (reiner Frontend-Build).

2026-08-26 — Web-GUI auf Port 80 (Aufruf ohne Portnummer)

Wunsch: „Kann man es nicht über Port 80 laufen lassen? Dann müsste man nur die IP bzw. Aime in den Browser eingeben ohne die Portnummer."

Warum es vorher 3000 war: die Unit startet Node als User=callrec (uid 1000). Ports unter 1024 sind privilegiert (net.ipv4.ip_unprivileged_port_start = 1024), ein unprivilegierter Prozess darf sie nicht binden. 3000 war also reine Bequemlichkeit, kein technischer Zwang. Port 80 war frei — kein nginx/Apache auf dem Board (ss -tlnp: nur 22, 3000, 3306, 53/5355).

Was geändert wurde

  • /etc/systemd/system/callrec.service: Environment=PORT=80 statt 3000, dazu AmbientCapabilities=CAP_NET_BIND_SERVICE — damit darf der callrec-Prozess den privilegierten Port binden, ohne als root zu laufen. Kein Reverse-Proxy: nginx hätte 20–40 MB RAM gekostet, die neben dem residenten rkllm (~6,5 GB von 7,74 GB) nicht frei sind (siehe OOM-Historie in Kapitel 05).
  • Kein Code-Eingriff nötig: server.js:941 liest process.env.PORT, und das Frontend spricht die API ausschließlich über relative Pfade (/api/...) an. Einziger :3000-Rest im Code ist der Dev-Proxy in web/vite.config.js (betrifft nur npm run dev).

Gegenprobe: callrec listening on :80, ss -tlnp zeigt 0.0.0.0:80 node, vom PC aus liefern http://192.168.1.48/ und http://aime.local/ je 200, /api/health antwortet. :3000 ist bewusst tot (Entscheidung des Nutzers: keine Parallel-Weiterleitung) — alte Lesezeichen mit :3000 müssen angepasst werden.

Nebenbefund (nicht gefixt): die Doku führte das Board noch unter 192.168.1.200; aktuell hat es per DHCP 192.168.1.48 (WLAN), LAN-Kabel steckt nicht. README und Kapitel 07 nennen jetzt aime.local als stabilen Weg.

Dateien/Backups: /etc/systemd/system/callrec.service.bak-port3000 auf dem Board. systemctl daemon-reload + restart callrec; vorher analysis_status='pending' geprüft (leer) und /api/health auf recording:false (Regel: kein Neustart während einer Analyse).

2026-08-26 — Diagnose: „Aufnahme läuft seit tausenden Minuten" war eine Geisteranzeige

Meldung: Morgens stand in der GUI eine laufende Aufnahme mit vierstelliger Minutenzahl.

Befund: es lief nichts. /api/health → recording:false, kein arecord/ffmpeg-Prozess, letzter Journal-Eintrag von callrec war 25.08. 10:00:53, neueste Datei in recordings/ war rec_1787648427247.wav vom 25.08. 09:21, neueste DB-Zeile #123 (25.08., 21,5 min, done/done). Kein Datenverlust, kein Plattenverbrauch.

Ursache, zwei Teile in web/src/main.js:

  1. Die Aufnahmeuhr läuft rein im Browser (setInterval auf Date.now() - state.recStart, Zeile ~304). Der Serverzustand wird nur einmal beim Laden geprüft (rehydrateRecording(), Zeile ~1951); während „Aufnahme läuft" pollt die GUI /api/health nie und es gibt keinen Abgleich zwischen Tabs. Ein Reiter, der den Stop nicht selbst ausgelöst hat (zweites Gerät, zweiter Tab), zählt darum ewig weiter.
  2. fmt() (Zeile 33) kennt nur MM:SS, keine Stunden — 21 h Geisterlauf erscheinen als 1280:34 und lesen sich wie „1280 Minuten".

Offener Vorschlag (vom Nutzer noch nicht beauftragt): recording auf dem bestehenden 5-s-sysmon- Tick mitliefern, damit ein verwaister Reiter sich selbst aufräumt, und fmt() um Stunden erweitern.

2026-08-20 — Stop-Knopf reagiert sofort (MP3-Encode in den Hintergrund)

Meldung: „Wenn ich eine laufende Aufnahme stoppen will, passiert auf der GUI nichts. Ich muss den Reiter wechseln und zurück, dann kann ich nochmal auf Stopp klicken und dann geht's."

Gemessene Ursache: POST /api/record/stop wartete auf den kompletten loudnorm-MP3-Encode — ~1,4 s pro aufgenommener Minute (#114: 35,5 min → 50,9 s; #118: 30,8 min → 43,8 s; berechnet aus wav_file-Zeitstempel + duration_ms gegen created_at). Die GUI zeigte dabei nur einen ausgegrauten Knopf. Der Reiterwechsel löst render() aus, das den Knopf frisch und klickbar aufbaut, obwohl state.recording noch true ist → der zweite Klick schickte einen zweiten Stop, der an not recording scheiterte (stopAndSave() setzt current in seiner ersten Zeile auf null). Nicht die Kontextnotiz vom 2026-08-17: die Wartezeit gab es vorher genauso (#113 am 14.08.: 44,9 s).

Was geändert wurde

  • recorder.js: stopAndSave() verschiebt das WAV und startet encodeMp3() ohne await. Der Encode schreibt nach <name>.mp3.part und benennt erst danach um (nie eine halbe Datei im Player). MP3_ARGS bekam -f mp3 — ohne das leitet ffmpeg den Muxer aus .part ab und bricht mit Error initializing the muxer … Invalid argument ab (erster Testlauf: MP3 fehlte komplett, beide Encode-Zweige waren still gescheitert).
  • web/src/main.js: state.stopping überlebt render() → Knopf bleibt über Reiterwechsel hinweg gesperrt (⏳ + „speichert…"), Uhr friert ein, Live-Karte sagt „Aufnahme wird gespeichert…", Toast nennt die geschätzte Wartezeit, zweiter Klick wird verworfen, not recording ist kein roter Fehler mehr. Dazu lädt der Player nach (#mp3wait, error-Ereignis → audio.load() alle 5 s, Abbruch nach 5 min).
  • server.js: sweepOrphans erkennt rec_*.mp3.part, damit eine beim Absturz liegengebliebene Halbdatei nach ORPHAN_MIN_AGE_MS mit weggeräumt wird.

Gegenprobe: Stop antwortet in 0,033 s / 0,044 s (12- und 15-s-Testclips #119/#120, danach in den Papierkorb), mp3 fertig: … erscheint ~2 s später im Journal, Datei ist 64 kbit CBR / 48 kHz / mono ohne Xing-Tabelle, /media/… liefert 200.

Dateien/Backups: recorder.js.bak-stopfast, server.js.bak-stopfast, main.js.bak-stopfix, main.js.bak-mp3wait. Backend geändert ⇒ Neustart, vorher analysis_status='pending' geprüft (leer). Der Vite-Build wurde bis zum Ende der laufenden Analyse #118 aufgeschoben: bei 816 MB freiem RAM neben dem residenten rkllm (~6,5 GB) hätte er den OOM-Killer auf genau diese Analyse gezogen. Kapitel 03 aktualisiert (neuer Abschnitt „Der Stop-Knopf" mit Vorher/Nachher-Sequenzdiagramm).

2026-08-17 (Nachtrag 6) — Diagramme zur Systemanzeige

Kapitel 07 nachgerüstet: ein Sequenzdiagramm für den Abfrageweg (GUI-Poll → /api/sysstat → sysstat.js → /proc/statfs, dazu der 60-s-Puffer des recordings/-Scans und der 2-s-Ticker für die CPU-Differenz) und ein Flussdiagramm für die beiden Rechenwege (Ring = Aufnahmen / (frei + Aufnahmen); Ventilator = Drehstufe aus CPU-%, Ampel aus dem schlimmsten Wert von CPU, RAM, CPU-Temperatur). Damit erfüllt die Änderung die Hausregel „neue Abläufe als Mermaid-Diagramm“.

2026-08-17 (Nachtrag 5) — „CPU-Temperatur“ statt Zonenname in Klammern

Die Zeile heißt jetzt „CPU-Temperatur“ und zeigt nur noch 46,2 °C; der Zonenname in Klammern (littlecore-thermal o. ä.) ist weg. Damit die Beschriftung auch stimmt, filtert sysstat.js die Zonen enger: nur soc-thermal, bigcore0/1-thermal, littlecore-thermal — GPU-, NPU- und Center-Zone fielen vorher unter „wärmste Zone“ und hätten den Wert stellen können, obwohl daran „CPU“ steht. Feld temp.zone bleibt in der API (fürs Debuggen), die GUI zeigt es nicht mehr. Backend geändert ⇒ Neustart nach Prüfung pending=0.

2026-08-17 (Nachtrag 4) — Erklärtext und Partitionszeile aus der Speicherkarte

Endstand der Klappkarte: Titel, „Aufnahmen“ (GB + Dateizahl) mit Balken, „Noch frei“, „Gesamt für Aufnahmen“. Der Erklärtext zur 100-%-Bezugsgröße und die Fußzeile „Partition gesamt“ sind gestrichen — die Bezugsgröße ergibt sich aus den drei Zeilen selbst. /api/sysstat liefert partPct/totalBytes unverändert weiter, die GUI zeigt sie nur nicht mehr. Nur Frontend + Vite-Build.

2026-08-17 (Nachtrag 3) — Karte wieder vollständig, dafür umbruchfrei

Der vermeintlich „fehlende Wert“ war ein Zeilenumbruch: bei „Gesamt für Aufnahmen 27,5 GB“ rutschte die Einheit allein in die nächste Zeile. Also alles wieder rein (Balken, „Noch frei“, „Gesamt für Aufnahmen“, Erklärtext, Partitions-Fußzeile) und stattdessen der Umbruch abgestellt: .sys-row { white-space: nowrap } + .sys-pop-disk { min-width: 285px }, der Erklärtext darf als einziger weiter umbrechen. Die in Nachtrag 2 entfernte Media-Query bleibt entfernt — die Prozentzahl am Ring war unter 900 px Fensterbreite ausgeblendet, das soll sie nicht sein.

Nur Frontend (main.js, style.css + Vite-Build), kein Dienstneustart.

2026-08-17 (Nachtrag 2) — Speicherkarte kurzzeitig auf eine Zeile gekürzt

Auf Zuruf alles unterhalb der GB-Zeile entfernt (Balken, „Noch frei“, „Gesamt für Aufnahmen“, Erklärtext, Partitions-Fußzeile) und die Media-Query @media (max-width: 900px) { .sys-val { display: none } } gestrichen, die bei schmalem Fenster die Prozentzahl neben dem Ring ausblendete. Die Kürzung wurde in Nachtrag 3 wieder zurückgenommen, das Entfernen der Media-Query bleibt.

2026-08-17 (Nachtrag) — Ventilator statt Turbine, Speicher relativ zum Aufnahme-Vorrat

Zwei Korrekturen des Nutzers am selben Tag:

  1. Icon soll wie ein Ventilator aussehen. Der erste Entwurf (fünf schmale Tropfen ohne Gehäuse) las sich als Kleeblatt. Jetzt: vier gebogene Pinwheel-Flügel in einem feststehenden Gehäusering; nur die Flügelgruppe dreht. Die Form wurde vor dem Ausrollen ohne Browser geprüft — die Bézier-Segmente der Kandidaten wurden mit einem kleinen Python/Pillow-Skript abgetastet, rotiert und als PNG gerendert (lab/preview_fan.py), weil die Chrome-Anbindung an diesem Tag nicht verfügbar war.
  2. Prozentzahl soll den Aufnahme-Vorrat messen, nicht die Partition. Neue Bezugsgröße: 100 % = freier Platz + Summe aller Audiodateien unter recordings/, belegt = die Audiodateien. Das ist genau der Platz, den ein frisch aufgesetztes System dieser Ausbaustufe für Aufnahmen hätte; System, Modelle und node_modules fallen aus Zähler und Nenner heraus. Der df-Wert bleibt als Fußzeile („Partition gesamt“) in der Klappkarte.

Umsetzung: sysstat.js scannt recordings/ mit rekursivem readdir + stat (nur Audio-Endungen, kein du-Prozess) und puffert das Ergebnis 60 s; nur der allererste Aufruf wartet auf den Scan, sonst zeigte die Anzeige direkt nach dem Start einmal 0 %. Neue Felder recBytes, recFiles, budgetBytes, partPct; usedPct = recBytes / budgetBytes. status() ist dadurch async → die Route läuft jetzt über wrap(). GUI: Balken + Datei-Anzahl in der Karte, erklärende Fußnote, Fußzeile mit dem Partitionswert. Gegenprobe: Scan meldet 1 957 445 371 Bytes, du -sb sagt exakt dasselbe (103 Dateien, 52 mp3 + 51 wav) ⇒ 1,96 GB von 29,6 GB Vorrat = 6,6 % (Partition dagegen 52 %).

2026-08-17 — Systemanzeige in der Kopfzeile: Speicherring + Turbine

Wunsch: zwischen dem Bluetooth-Tab und dem Hell/Dunkel-Schalter eine Anzeige, die den belegten Speicherplatz in Prozent zeigt, dazu ein zweites Symbol, das bei Mauskontakt CPU-Last, RAM und Temperatur aufklappt — als Turbine, die sich mit steigender Last schneller dreht.

Umsetzung:

  • Neues Modul sysstat.js mit status(): Platte über fs.statfsSync (gemessen wird die Partition mit den Aufnahmen, Prozentwert wie df inklusive root-Reserve), RAM aus /proc/meminfo (MemAvailable, nicht MemFree), CPU-% aus der Differenz zweier /proc/stat-Messpunkte, Temperatur als wärmste SoC-/Core-/Center-/GPU-/NPU-Zone (WLAN-Zone iwlwifi_1 ausgeschlossen). Eigener 2-s-Ticker (unref()), sonst wäre die erste CPU-Antwort nach jedem Start 0 %.
  • GET /api/sysstat in server.js — ohne wrap()/Warteschlange, weil es reine /proc-Lesevorgänge sind und die Abfrage keinen Prozess startet.
  • GUI (web/src/main.js): sysmonMarkup() zeichnet beide Chips, paintSysmon() schreibt die Werte ins DOM, tickSysmon() pollt alle 5 s (im Hintergrund-Tab ausgesetzt). Ein einziger Ticker für alle render()-Läufe; direkt nach render() werden die letzten Werte zurückgemalt, weil render() die Kopfzeile komplett neu schreibt und die Chips sonst bis zum nächsten Poll leer stünden.
  • Turbine: fünf Flügel als SVG-Pfade, Drehdauer per CSS-Variable --spin. Die Dauer läuft bewusst in Stufen (7 s Leerlauf … 0,35 s ab 90 %) — eine stufenlos nachgeführte animation-duration setzt die laufende Drehung bei jedem Poll neu an und ruckt sichtbar. prefers-reduced-motion stoppt sie.
  • Speicherring: <circle r="15.9155"> (Umfang exakt 100) ⇒ stroke-dasharray = Prozentwert.
  • Ampel: grün < 75 %, gelb ab 75 %, rot ab 90 %; bei der Turbine zählt der schlimmste Wert aus CPU, RAM und Temperatur (45 °C → 0 %, 90 °C → 100 %).
  • Karten öffnen rein über CSS (.sys-chip:hover .sys-pop, zusätzlich :focus-within für Tastatur) — kein Handler, der einen render()-Durchlauf überleben müsste.

Dateien: neu app/sysstat.js; geändert app/server.js (.bak-sysmon), app/web/src/main.js (.bak-sysmon), app/web/src/style.css (.bak-sysmon). Vite-Build + systemctl restart callrec (vorher geprüft: keine Analyse pending). Verifiziert: /api/sysstat liefert 52,4 % Platte (df sagt 53 %), 46 °C, 8 Kerne. Doku: Kapitel 07.

2026-08-17 — Kontext zum Gespräch: ein Freitextfeld nach dem Stop

Wunsch: nach dem Beenden einer Aufnahme die Möglichkeit haben, Kontext zum gerade geführten Call/Meeting zu ergänzen — das verbessert die Analyse nach dem Transkript erheblich, weil das Modell Personen, Firmen, Anlass und Eigennamen nicht mehr aus fehlerbehaftetem ASR-Text erraten muss.

Entscheidung des Nutzers: EIN Freitextfeld, kein Formular. Ein Vorschlag mit Teilnehmer-, Anlass-, Projekt- und Begriffsfeldern wurde ausdrücklich verworfen: die Box benutzen später auch technisch nicht versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als vier Pflichtfelder. Geführt wird über den Platzhaltertext, die Struktur (Namen, Begriffe) zieht sich die Analyse selbst aus der Prosa.

Umsetzung:

  • Neue Spalten recordings.context_note / context_note_at (ensureSchema), db.setContextNote() (Deckel 4 000 Zeichen, leerer Text löscht) und db.getContextNote().
  • PATCH /api/recordings/:id/context. Die Antwort enthält applies und sagt damit ehrlich, ob die Notiz die laufende Verarbeitung noch erreicht: auto (wird noch transkribiert → fließt sicher ein), running (Analyse läuft schon, kann sie verpasst haben), reanalyze (fertig → nur ein neuer Lauf nutzt sie; die GUI fragt dann nach und startet reanalyze-llm).
  • analyzeRecording() liest die Notiz erst kurz vor dem LLM-Aufruf (db.getContextNote), nicht aus der Zeile vom Anfang: Sprecher-Erkennung und Transkription davor brauchen Minuten, und was der Nutzer in dieser Zeit tippt, soll noch mitzählen.
  • llm.js: neuer contextBlock() — die Notiz kommt in einem eigenen """-Block mit der Auflage, sie nur für Schreibweisen, Zuordnung, Anlass und Gewichtung zu nutzen und daraus keine Aussagen, Ergebnisse oder Aufgaben abzuleiten. Eingespeist in alle Pfade: analyzeSingle (PROMPT_CLEAN, PROMPT_CLEAN_LABELED, PROMPT_JSON), analyzeWhole (PROMPT_WHOLE_ANALYZE) und analyzeLong (jeder Chunk-Prompt + Titel- und Überblick-Mikro-Call). Zwei Deckel: 1 200 Zeichen für die Prompts, die die Notiz einmal sehen, 600 für die Chunk-Prompts (dort landet sie in jedem Chunk von 3 200 Zeichen). Auf dem letzten von drei Chunk-Versuchen fällt sie mit dem hint weg.
  • GUI: Karte „📝 Kontext zum Gespräch“ über den Modulen der Detailansicht. Nach dem Stop steht sie offen und fokussiert (state.ctxPromptFor), sonst eingeklappt mit dem gespeicherten Text und „✏️ Bearbeiten“. Sie zeichnet sich selbst neu statt über openRecording() — ein Neuaufbau der Detailansicht setzt den <audio>-Tag zurück und würde das Abspielen unterbrechen. Getippter, ungespeicherter Text überlebt den Neuaufbau nach Ende der Analyse (state.ctxDrafts). Strg+Enter speichert, Enter macht Absätze.

Verifiziert an der laufenden App: Endpunkt liefert applies:"reanalyze" bei fertiger Analyse und applies:"auto" während einer laufenden Transkription (8-s-Wegwerfaufnahme #115, danach in den Papierkorb). Echter LLM-Lauf auf #97 (37 s, Single-Pfad) mit einer Testnotiz: Log zeigt llm: Kontextnotiz des Nutzers wird mitgegeben (196 Zeichen), die Zusammenfassung wurde messbar besser („Stimmerkennung“ + korrekte Namen Sascha/Fabio statt des ASR-Fehlers „Audioaufnahme … Lichter-Modus“ mit falscher Sprecherzuordnung) — und die absichtlich in die Notiz gelegten Fremdfakten („Rückruf ist für Freitag geplant“, „Halle 3 wurde verkauft“) tauchten nicht in Zusammenfassung oder ToDos auf, die Halluzinationsbremse hält also. Die alte Analyse von #97 war vor dem Test gesichert und wurde danach zurückgeschrieben, die Testnotiz entfernt. vite build sauber, systemctl restart callrec erst nach Prüfung auf analysis_status='pending'.

Offene Grenze: die Notiz verbessert Titel/Zusammenfassung/Teilnehmer/ToDos, nicht das Transkript — Whisper ist zu dem Zeitpunkt schon gelaufen. Dauerhaft falsche Schreibweisen gehören ins Glossar. Ebenfalls noch nicht: die Notiz im Suchindex (passages) und ein Hinweis „kein Kontext“ in der Aufnahmenliste.

Dateien: app/db.js (ensureSchema, setContextNote, getContextNote, CONTEXT_MAX), app/server.js (PATCH …/context, analyzeRecording), app/llm.js (contextBlock, CONTEXT_MAX_CHARS, CONTEXT_CHUNK_CHARS, alle Prompt-Bausteine + analyze/analyzeSingle/analyzeWhole/analyzeLong), app/web/src/main.js (Kontextkarte, state.ctxPromptFor/ctxDrafts, toggleRecord), app/web/src/style.css (.ctxbox, .ctx-*). Board-Backups: .bak-ctxnote (db.js, server.js, llm.js, web/src/main.js, web/src/style.css). Kapitel 05 (neuer Abschnitt „Kontextnotiz des Nutzers" + Flussdiagramm + Deckel-Tabelle), Kapitel 03 (Sequenzdiagramm + API-Tabelle).

2026-08-14 — Angeheftete Aufnahmen per Drag & Drop zwischen Unterreitern verschieben

Wunsch: eine im Projekt abgelegte Aufnahme sollte sich innerhalb des Ordners umhängen lassen, z. B. von DFG/Projektleiter Runden nach DFG/INF-Projektboard. Bisher ging das nur über Lösen + neu Anheften, also zwei Dialoge für eine Bewegung.

Umsetzung: die Pin-Zeilen in der Ordneransicht sind draggable, die Reiter der Reiterleiste sind Ablageziele (bindPinDrag in main.js). Ein Drop schickt PATCH /api/projects/:id/pins/:pinId {tab_id}; db.movePin schreibt project_pins.tab_id um. Verschoben wird ausschließlich die Referenz — an der Aufnahme, ihrer Analyse oder ihren übrigen Anheftungen ändert sich nichts.

Drei Punkte, die beim Bauen aufgefallen sind:

  • Hängt die Aufnahme auf der Zielebene bereits, würde das UPDATE an UNIQUE uniq_pin scheitern. movePin löscht dann den Quell-Pin (Zusammenführen statt Fehler) und meldet {merged:true}.
  • Der Zielreiter wird serverseitig gegen den Ordner geprüft (project_tabs.project_id), sonst könnte ein veralteter Browser-Tab einen Pin in ein fremdes Projekt schieben → sonst 404.
  • .ptab-n (Zähler im Reiter) braucht pointer-events: none, sonst feuert beim Überfahren des Zählers ein dragleave und die Ablagemarkierung flackert.

Verifiziert direkt gegen die laufende App (Ordner „DFG", Pin #12): Verschieben Reiter 2 → 3 → 2 jeweils {"moved":true} und tab_id in der DB entsprechend; fremder Reiter (999) und fremdes Projekt (2) je 404 {"error":"Anheftung oder Zielreiter nicht gefunden"}; gleicher Reiter {"moved":false} ohne Schreibzugriff; Zusammenführen getestet mit Aufnahme #109 (zwei Pins → einer). Testdaten danach auf den Ausgangsstand zurückgesetzt. vite build sauber, systemctl restart callrec erst nach Prüfung auf analysis_status='pending' (keine laufende Analyse, keine laufende Aufnahme).

Dateien: app/db.js (movePin), app/server.js (PATCH …/pins/:pinId), app/web/src/main.js (bindPinDrag, draggable an der Pin-Zeile, Hinweistext in der Ebenen-Kopfzeile), app/web/src/style.css (.ptab.drop, .item.rec.dragging, .ptab-n). Board-Backups: .bak-movepin (db.js, server.js, web/src/main.js, web/src/style.css, web/dist). Kapitel 10 (neuer Abschnitt „Verschieben per Drag & Drop" + Sequenzdiagramm + API-Tabelle).

2026-08-10 — Recorder überlebt das Neuladen (GUI holt den Zustand vom Server)

Symptom: während einer laufenden Aufnahme klickte der Nutzer in eine andere Aufnahme; danach stand der Recorder auf 00:00 und zeigte den Start-Knopf — auch nach F5. Die Aufnahme schien gestorben.

Sie lief die ganze Zeit. Gemessen am Board: parecord PID 236484, Elternprozess = die Node-App, seit 10:59:02 UTC; die WAV wuchs um ~32 kB/s (16 kHz mono s16 = korrekt); GET /api/health meldete {"recording":true,"startedAt":1786359542932} samt allen drei Namensmarkern.

Ursache: web/src/main.js hielt recording/recStart/markers ausschließlich im Browser-state (Zeile 12, Startwert false/0) und fragte den Server nie danach — grep -rn "api/health" web/src/ ergab null Treffer. Jedes Neuladen setzte die GUI also auf „keine Aufnahme" zurück, obwohl recorder.js weiter aufnahm. Fatal daran ist nicht die falsche Anzeige, sondern dass das Kopfband dann ● statt ■ malt: der Stop-Knopf war unerreichbar. Immerhin ohne Datenrisiko — recorder.js:270 if (current) throw new Error('already recording') verhindert eine zweite Aufnahme über dieselbe WAV.

Fix: neue Funktion rehydrateRecording() am Ende von main.js, aufgerufen direkt nach dem ersten render(). Sie liest GET /api/health und stellt recording, recStart (= startedAt, Server-Epoche) und markers wieder her, rendert neu und frischt die Aufnahmenliste auf (Live-Karte). Ein Fehler bleibt still (kein Toast), damit ein nicht erreichbarer Server nur die Normalansicht bedeutet. Nebeneffekt: ein zweites Gerät sieht die laufende Aufnahme jetzt ebenfalls.

Verifiziert: vite build sauber (dist/assets/index-B3z3XrMo.js, api/health im Bundle enthalten, dist/index.html verweist darauf). Kein systemctl restart — nur statische Dateien getauscht, die laufende Aufnahme blieb unangetastet (recording:true vor und nach dem Build, Marker intakt).

Dateien: app/web/src/main.js (Board-Backups src/main.js.bak-rehydrate, web/dist.bak-rehydrate), Kapitel 03 (neuer Abschnitt „Der Aufnahmezustand gehört dem Server" + /api/health in der Endpunkt-Tabelle).

⚠️ Nebenbefund, nicht behoben: enroll-from-recording failed: spk beendet bei #107 und #85 — TitaNet stirbt im ONNX-Where-Knoten (Attempting to broadcast an axis by a dimension other than 1. 12288 by 30733), das Stimmabdruck-Lernen aus langen Aufnahmen fällt also aus. Eigener Punkt.

2026-08-07 (3) — Eine Person darf mehrere Namen haben (und mehrere Stimmproben)

Warum: die Identität war der NAME. Derselbe Mensch, in einer Aufnahme „Sascha" und in der nächsten „Sascha Wolf" genannt, existierte damit zweimal — und beide Einträge konnten je ein Cluster benennen. Aufnahme #101 (34 s, zwei Menschen) zeigte deshalb Michael, Sascha, Sprecher 3, Sascha Wolf. diar.js hat das mit einem Vornamens-Trick überdeckt, der aber „Markus Ufer" und „Markus Witsch" gleichsetzt (Kosinus 0,241 = zwei Menschen).

Neu: Tabellen persons + person_names (Name = Primärschlüssel, gehört zu genau einer Person), voiceprints.person_id. Eine Person darf beliebig viele Namen und beliebig viele Stimmproben haben (AIME_MAX_PRINTS, Standard 8) — bewertet wird sie mit ihrer besten Probe statt mit einem laufenden Mittel über verschiedene Sessions und Headsets. Score UND Abstand rechnen jetzt zwischen Personen; vorher rissen zwei Abdrücke desselben Menschen die 0,05-Abstandsprüfung, wodurch die Zwangsbedingung in der Diarisierung faktisch abgeschaltet war. Identität überall: diar.identityKey() bildet Marker-Namen und Abdrücke auf p<person_id> ab.

GUI „Stimmen": Personen statt Abdrücke — Namen als Chips (hinzufügen/entfernen), zwei Personen zusammenführen, Proben verwerfen. Ein Name, der schon einer anderen Person gehört, führt beide zusammen.

Altbestand geleert (Dump backup/voiceprints-2026-08-07.sql, 42 Abdrücke): die Messung mit lab/vpcos.mjs zeigte neben echten Dubletten (Jens ~ Jens Kuppler = 1,000) auch kontaminierte Einträge — Johannes lag mit 0,912 an Sascha Wolf, aber mit 0,115 an Johannes Jeckel, war also aus einem falsch verschmolzenen Cluster gelernt (wie Merle am 2026-07-29). Neu lernen statt reparieren.

Verifiziert an #101: „Michael" + „Sascha" neu gelernt, „Sascha Wolf" als zweiter Name derselben Person → Re-Diarisierung benennt {"speaker_00":"Michael","speaker_01":"Sascha"} (vorher drei Namen für zwei Menschen), Log Cluster 1 = bekannte Stimme "Sascha" (cos 0,917). GUI mit Playwright geprüft: keine JS-Fehler, zwei Personen, Chips „Sascha" + „Sascha Wolf".

⚠️ Nicht behoben, eigener Punkt: die Cluster-ANZAHL. #101 bleibt 4 Cluster für 2 Menschen; die beiden unbenannten heißen „Sprecher 1/2". Ursache ist die Über-Segmentierung kurzer Clips (Diagnose 2026-07-31, Kap. 04) — dafür liegen drei Optionen vor, keine davon ist gewählt.

Dateien: app/db.js, app/spk.js, app/diar.js, app/server.js, app/recorder.js, app/web/src/main.js, app/web/src/style.css (Board-Backups .bak-persons), app/lab/vpcos.mjs, Kapitel 04 – Sprechertrennung.

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 (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 Abruf der Analyse zeigte die GUI „failed to fetch“. Verdacht lag auf dem eMMC-Umzug vom 2026-08-03 — war es nicht: Speicher-Setup (zram 3,9 GB, kein Swapfile), RAM (7,83 GB) und armbian-zram-config sind vor und nach dem Umzug identisch (alte SD-Root gegengeprüft).

Tatsächliche Ursache (aus dmesg): die Transkription war längst fertig (8666 Wörter, RTF 0,394), gestorben ist der sherpa-Lauf danach — Out of memory: Killed process (sherpa-onnx-off) … anon-rss:5463416kB. Und weil systemd eine Unit per Default komplett stoppt, sobald ein Kindprozess OOM-getötet wird (OOMPolicy=stop), riss das die ganze App mit: callrec.service: Failed with result 'oom-kill' → abgebrochene Analyse, leerer Fetch im Browser. Warum es früher ging: der RAM-Bedarf wächst quadratisch mit der Gesprächslänge — gemessen 349 MB (10 min), 1389 MB (20 min), 2643 MB (30 min), 5,4 GB (54 min). Bis ~45 min passt es, danach nicht.

Fix 1 (Ursache): sherpa läuft jetzt in Abschnitten von 600 s (DIAR_CHUNK_SEC, 0 = aus), je mit ±5 s Kontext; die Turn-Grenzen werden mit Zeit-Offset zusammengesetzt. Zulässig, weil die v2-Pipeline sherpas Labels ohnehin verwirft und nur die Grenzen nutzt. Speicher bleibt damit dauerhaft auf Abschnitts-Niveau und der Lauf ist zusätzlich schneller (quadratische Kosten).

Fix 2 (Schadensbegrenzung): OOMPolicy=continue — ein OOM-getöteter Helfer (sherpa/whisper/rkllm) darf die App nicht mehr mitreißen; Diarisierung und Analyse sind im Code bereits nicht-fatal.

Verifiziert an #99 (Re-Transkription + Re-Diarisierung, 2026-08-07): sherpa-Spitze 363 MB statt 5,4 GB, 6 Abschnitte (131/103/119/160/158/55 Turns) → 726 Turns → 1932 Fenster → 8 Sprecher, alle 6 Live-Marker verankert und alle 8 Sprecher benannt (Fabio Haase, Merle, Dennis, Nico, Lisa, Kai, Kim, Artur), Transkript 51.406 Zeichen, Zusammenfassung 15.930 Zeichen, 20 LLM-Abschnitte, Analyse 5073 s, 189 Passagen indexiert, status=done / analysis_status=done, kein Neustart, kein OOM, 64,7 °C.

Dateien: app/diar.js (Board-Backup diar.js.bak-chunk), system/etc/systemd/system/callrec.service.d/oom.conf (neu), Kapitel 04 – Sprechertrennung.

Nebenbefund (offen): während der Analyse belegt rkllm_server 6,5 GB RSS (NPU-Modell, shared) und das System hat nur noch ~100 MB frei — funktioniert, aber ohne Reserve. Wenn wieder etwas OOM-nah wird, ist das der Kandidat.

2026-08-03 — System von der SD-Karte auf die eMMC umgezogen

Warum: alles lag auf der SD-Karte (System, Datenbank, Aufnahmen, Modelle), die verlötete eMMC lag ungenutzt daneben. SD-Karten sind der übliche Ausfallpunkt im Dauerbetrieb und sterben meist ohne Vorwarnung; Aime schreibt bei jeder Aufnahme WAV + MP3 plus Datenbank und Suchindex. Die eMMC meldet ihren Verschleiß sogar: life_time 0x01, pre_eol_info 0x01 — unter 10 % verbraucht.

Vorher gesichert: vollständige Sicherung der SD (22,4 GB tar + Bootbereich + SQL-Dump), verifiziert über Eintragszahl (94.501 = find / -xdev) und SHA-256-Stichprobe.

Ablauf: eMMC formatiert (die alte Armbian-Karteileiche dort mit einer veralteten callrec-Version ist damit weg), rsync -aHAX --one-file-system in zwei Runden — erste im laufenden Betrieb (28,5 GB in 7:25 min, 61 MB/s), zweite mit gestoppten Diensten als Delta (4 s). Danach fstab und armbianEnv.txt auf die neue UUID, e2fsck, Stichproben, Dateilistenvergleich (Differenz: exakt die zwei Dateien, die ich selbst nach dem Sync angelegt hatte).

Drei Dinge, die dabei zu lernen waren:

  1. orphan_file blockiert U-Boot. mkfs.ext4 (e2fsprogs 1.47) setzt orphan_file und metadata_csum_seed; ersteres bringt das Incompat-Merkmal orphan_present mit, und ein Treiber, der es nicht kennt, muss das Lesen verweigern. Die funktionierende SD-Partition hatte beides nicht. Vor dem ersten Start entfernt: tune2fs -O ^orphan_file,^metadata_csum_seed.
  2. U-Boot liest die Bootdateien von der SD-Karte, solange sie steckt — nachgewiesen, indem die SD-armbianEnv.txt testweise wieder auf die SD-Root zeigte: das Board fuhr prompt das alte System hoch. Kernel kommt also von der SD, Root von der eMMC.
  3. Deshalb aime-bootsync.service (neu): spiegelt /boot beim Start und beim Herunterfahren auf die SD. Ohne das würde ein Kernel-Update auseinanderlaufen — neue Module auf der eMMC, alter Kernel von der SD. Fällt die SD aus, findet U-Boot dort nichts und nimmt die vollständige eMMC-Bootkette.

Ergebnis: / = mmcblk0p1 (aime-emmc), 28 GB von 58 GB belegt. Die SD ist im Betrieb nicht eingehängt. Keine fehlgeschlagene Unit, dieselben fünf Dienste aktiv wie vor dem Umzug, Aime antwortet mit HTTP 200, NPU-Treiber v0.9.8, Suchindex 1799/1799 Passagen, 50,8 °C.

Dateien: app/aime-bootsync.sh (neu), system/aime-bootsync.service (neu), Kapitel 01 – Hardware und 07 – Betrieb.

2026-08-02 (spät) — Knöpfe rutschten aus der Zeile, 242 MB Dateileichen geräumt

1. Zeilenumbruch in der Aufnahmenliste. Bei schmaler Liste schob ein langer Titel die Knöpfe aus der Zeile — der Papierkorb-Knopf der obersten Aufnahme war nicht mehr erreichbar, die Spalte bekam einen waagerechten Scrollbalken. Ursache ist die Vorgabe min-width: auto für Flex-Kinder: der Textblock schrumpft von sich aus nie unter seine Inhaltsbreite. Mit der dritten Schaltfläche (Stecknadel) reichte der Platz dann nicht mehr.

.item > .grow { min-width: 0; overflow-wrap: anywhere; }
.item > .btn  { flex: none; }

2. Gelöschte Aufnahmen lagen weiter auf der Karte. 23 sichtbare Aufnahmen (46 Dateien), aber 152 Dateien im Ordner: 106 verwaiste mit 242 MB, Rückstand des alten deleteRecording, das nur die Datenbankzeile entfernte. Einmalig geräumt (Gegenprobe vorher: keine der 106 Dateien stand in einer Zeile, danach: keine benötigte Datei fehlt), und sweepOrphans in server.js verhindert den Rückfall — Details in 09 – Suche & Verwaltung.

Die Papierkorb-Kette selbst war in Ordnung und wurde mit einer Wegwerf-Zeile nachgewiesen: löschen → Papierkorb, Dateien bleiben; endgültig löschen → {"files":2}, Zeile und beide Dateien weg.

Dateien: app/server.js, app/db.js (allAudioFiles), app/web/src/style.css. Board-Backups: .bak-orphans.

2026-08-02 — Wortmarkierung lief dem Ton voraus: VBR-MP3 war schuld

Symptom: im Transkript war ein Wort markiert, gesprochen wurde aber eins rund vier Zeilen weiter oben. Gemessen an #85: markiert „auf" (20:40,3), zu hören „OneDrive" (20:16,3) — 24 s Vorlauf.

Was es NICHT war (der Reihe nach ausgeschlossen, jeweils mit Gegenprobe):

Verdacht Prüfung Ergebnis
Wortzeiten driften (VAD-Trimming im Batch-Modus) Ausschnitte bei t = 600/1240/3400 s aus der WAV geschnitten und einzeln transkribiert dort stehen exakt die Wörter, die die DB führt — Zeitstempel korrekt
MP3 und WAV haben verschiedene Zeitachsen MP3 komplett dekodiert, dieselben Stellen geprüft identisch
Datei kaputt / ffmpeg-Seek daneben ffmpeg -ss an denselben Stellen trifft genau
Anzeige-Logik (ontimeupdate) Code gelesen: letztes Wort mit t_start <= t korrekt

Was es war: der Sprung im Browser. Die MP3 war VBR, dort gibt es keine feste Byte-pro-Sekunde-Regel, also nutzt der Browser die Xing-Tabelle — 100 Stützstellen, bei 63 Minuten ein Raster von 38 s. Er landet auf der Stützstelle davor, meldet über currentTime aber die angefragte Zeit zurück. Nachgerechnet (lab/tocerr.py, Frames gezählt + Tabelle geparst): Ziel 20:40 → tatsächliche Abspielposition 20:11,5, also −28,5 s. Genau die beobachtete Größenordnung, und der Versatz bleibt bis zum nächsten Sprung stehen.

Fix: MP3 künftig CBR mit 64 kbit und ohne Xing-Tabelle (-b:a 64k -ar 48000 -ac 1 -write_xing 0 statt -qscale:a 3). Dann ist die Byte-Position proportional zur Zeit und jeder Sprung sitzt. Die Tabelle muss auch bei CBR weg — sie rastert Byte-Positionen auf 1/256 der Dateigröße, was bei einer Stunde ~15 s sind (Restfehler gemessen: bis −6,4 s). Ohne sie rechnet der Decoder linear: Restfehler 0,02 s = ein halber Frame, an neun Stichpunkten über die ganze Datei geprüft.

Dateigröße bleibt praktisch gleich (#85: 32,3 → 30,3 MB), 64 kbit CBR ≈ die bisherige Durchschnittsrate von 68 kbit.

Altbestand: lab/recode-cbr.sh schreibt alle vorhandenen Aufnahmen einmalig um — Quelle ist die WAV mit derselben Filterkette wie beim Aufnehmen, für die alten Aufnahmen ohne WAV die MP3 selbst (dann ohne erneutes loudnorm, das steckt dort schon drin). Schreibt erst in eine .tmp, vergleicht die Dauer (±2 s) und ersetzt erst dann; pausiert oberhalb von 88 °C, weil das Board keinen Lüfter hat. Zwei Stolpersteine dabei: -f mp3 ist Pflicht (aus der Endung .mp3.tmp leitet ffmpeg kein Format ab), und eine 0-Byte-Altdatei (rec_1784247236781.mp3) bleibt als Fehler liegen statt den Lauf abzubrechen.

Dateien: app/recorder.js (Board-Backup .bak-cbr), app/lab/recode-cbr.sh (neu), app/lab/tocerr.py (neu, Messwerkzeug), Kapitel 03 – Aufnahme-Pipeline.

2026-08-02 (nachgereicht) — Grüne Nadel, Logo führt zurück zu den Aufnahmen

Nadel grün statt rot: der Anheften-Knopf in der Aufnahmenliste zeigte bei einer angehefteten Aufnahme nur die Zahl, die Nadel selbst blieb rot. Sie ist jetzt grün (--ok, dieselbe Farbe wie das „KI ✓"-Abzeichen). Dafür musste das 📌-Emoji einem SVG mit currentColor weichen (ICON_PIN, gleiches Muster wie ICON_TABLE/ICON_SHEET): ein Farb-Emoji rendert die Schriftart immer rot, color greift daran nicht.

Logo als Heimweg: ein Klick auf Wellen-Logo und Schriftzug „Aime" wechselt auf den Reiter „Aufnahmen" (#brandhome in render()), mit Zeiger-Cursor und Hover-Andeutung als Hinweis darauf.

Dateien: app/web/src/main.js, app/web/src/style.css.

2026-08-02 (nachgereicht) — Ordner-Ebene umbenennbar

Der erste Reiter eines Projekts hieß fest „Ordner". Er trägt jetzt einen frei wählbaren Namen, genau wie die Unterreiter.

Der Name steht in der neuen Spalte projects.root_tab_name (NULL = „Ordner") und nicht als Zeile in project_tabs: die Ordner-Ebene ist kein echter Reiter — es gibt sie immer, sie hat keine eigene id und lässt sich nicht löschen. Eine Zeile in project_tabs hätte für sie eine id gebraucht, die dann in project_pins.tab_id auftauchen müsste, womit die „0 = Ordner-Ebene"-Regel und mit ihr der UNIQUE-Index gefallen wären.

PATCH /api/projects/:id/tabs/0 fängt die 0 ab und schreibt ins Projekt; ein leerer Name setzt zurück auf „Ordner". Auch die Ebenen-Auswahl im 📌-Dialog und die Liste bestehender Anheftungen zeigen den Namen (listPinsForRecording per COALESCE(t.name, p.root_tab_name)).

Dateien: app/db.js, app/server.js, app/web/src/main.js, Kapitel 10 – Projektansicht.

2026-08-02 — Neuer Reiter „Projektansicht": Ordner, Unterreiter, angeheftete Aufnahmen

Was: Aufnahmen lassen sich jetzt in Projektordnern bündeln. Ein Ordner („Project X") nimmt direkt Aufnahmen auf und kann Unterreiter („Projektrelevante Aufzeichnungen") enthalten, die ihrerseits Aufnahmen aufnehmen. Ein Klick auf eine angeheftete Aufnahme springt in den Reiter „Aufnahmen" und öffnet sie dort. Angeheftet wird auf zwei Wegen: aus dem Projekt heraus per Dropdown (mit Filterfeld) oder direkt in der Aufnahmenliste über den neuen 📌-Knopf, der Ordner und Ebene abfragt, neue Ordner anlegen kann und bestehende Anheftungen der Aufnahme zum Lösen anzeigt.

Warum: Die chronologische Aufnahmenliste beantwortet „wann war das", nicht „was gehört zu diesem Projekt". Reihenfolge nach Datum ist für die tägliche Arbeit an mehreren Projekten die falsche Sortierung.

Wie: Drei Tabellen (projects, project_tabs, project_pins), angelegt in ensureSchema wie alle anderen auch. Ein Pin ist eine reine Referenz — die Aufnahme selbst wird nie verschoben oder kopiert und darf in mehreren Ordnern hängen.

Zwei Details, die sonst später weh getan hätten:

  • project_pins.tab_id ist INT NOT NULL DEFAULT 0 (0 = Ordner-Ebene), nicht NULL. MariaDB schließt NULL von UNIQUE-Indizes aus; mit NULL ließe sich dieselbe Aufnahme mehrfach auf dieselbe Ebene anheften. Mit 0 greift UNIQUE (project_id, tab_id, recording_id), und INSERT IGNORE macht das Anheften idempotent.
  • db.purgeRecording räumt project_pins mit ab, und getProject blendet Aufnahmen im Papierkorb aus, ohne ihren Pin zu löschen — nach dem Wiederherstellen ist die Aufnahme wieder im Ordner.

Dateien: app/db.js, app/server.js, app/web/src/main.js, app/web/src/style.css, neues Kapitel 10 – Projektansicht. Board-Backups: .bak-projects.

2026-07-31 (spät) — Zwei Fehler aus dem Suche-Feature behoben

Beide beim Nachsehen aufgefallen, warum die GUI bei einer 29:32-Aufnahme 2 h 34 min Analysezeit anzeigte.

1. Geisterzähler nach Dienst-Neustart

Ein systemctl restart SIGKILLt alle Kindprozesse (whisper, spk_embed, rkllm). Die Datenbankzeile blieb aber auf analysis_status='pending' mit gesetztem analysis_started_at stehen — die GUI zeigte darauf „KI-Analyse läuft… (NPU)" und einen Zähler, der ewig hochlief, für Arbeit die es nicht mehr gab.

Ausgelöst hatte es ein Deploy-Neustart um 13:11 mitten in der laufenden Analyse von #87.

Fix db.failStaleJobs(), aufgerufen beim Start: pending → error, transcribing → error. Zum Startzeitpunkt kann kein Job legitim laufen, also ist das eindeutig. Der Nutzer sieht jetzt den Wiederholen-Knopf statt eines Phantomzählers.

2. /api/search/answer ließ rkllm resident

Die Antwortroute lud rkllm über llm.askRaw() und gab es nie frei. Gemessen: 1 h 48 min lang 82,6 % RAM belegt, nur 237 MB verfügbar.

Das ist genau der Zustand, den analyzeRecording und rediarize mit ihrem llm.stopServer() bewusst vermeiden — der TitaNet-Helfer für die Sprecher-Embeddings stirbt neben dem residenten 5,9-GB-Modell an OOM (auf #71 ergab das seinerzeit 48 Rohsprecher). Die nächste Transkription wäre also mit hoher Wahrscheinlichkeit fehlgeschlagen.

Fix: llm.stopServer() im finally der Route. Danach wieder 7,3 GB verfügbar.

Dateien: app/server.js, app/db.js. Board-Backups: .bak-stalejobs.

2026-07-31 (nachmittags) — Abschnittstitel in der Zusammenfassung fett

Die Section-Zusammenfassung kommt als Abschnittstitel:\nFließtext…-Blöcke zurück (llm.js/analyzeLong), wurde aber als eine flache Textwand gerendert. Titelzeilen erscheinen jetzt fett.

Bewusst im Renderer, nicht in der Datenbank: so gilt es sofort für alle bereits vorhandenen Zusammenfassungen, ohne auf 22 Aufnahmen je eine 20-Minuten-Analyse neu zu fahren.

Erkennungsregel in renderSummary() (main.js): eine kurze, alleinstehende Zeile, die auf einen Doppelpunkt endet und kein . ! ? enthält — /^[^.!?]{3,90}:$/. Beide Bedingungen sind nötig: Fließtext wie „…die Struktur der Projektberichterstattung: kurze Einordnung, aktueller Stand…" hat den Doppelpunkt mitten in der Zeile und läuft weiter.

Gegen echte Daten geprüft (Zusammenfassungen #85, #79, #74): 37 Titel erkannt, keine Fließtextzeile fälschlich fett, keine Doppelpunkt-Zeile übersehen.

Dateien: app/web/src/main.js, app/web/src/style.css (.summary .sum-head). Board-Backups: .bak-sumhead.

2026-07-31 — Inhaltssuche, Papierkorb, Titel umbenennen (+ WLAN-Ausfall behoben)

Drei Funktionen auf Wunsch des Nutzers, dazu die Störung vom Vormittag. Details: 09 – Suche & Verwaltung.

Vorab: Board war nicht erreichbar — SSID-Rename killt den PSK-Hash

Das Board lief weiter als USB-Soundkarte, war aber im Netz unsichtbar. wpa_supplicant hing auf ssid="RupuWolf5"; das WLAN heißt seit dem Zusammenlegen der Bänder nur noch RupuWolf (4 BSSIDs, 2437/2462/5300/5500 MHz). Nach dem SSID-Fix scheiterte die Anmeldung trotz unverändertem Passwort:

wlP4p65s0: WPA: 4-Way Handshake failed - pre-shared key may be incorrect
CTRL-EVENT-SSID-TEMP-DISABLED ssid="RupuWolf" auth_failures=1 reason=WRONG_KEY

Ursache: die Conf speichert psk=<64 Hex>, und WPA-PSK leitet diesen Hash per PBKDF2 mit der SSID als Salt ab — ein SSID-Rename entwertet jeden vorberechneten Hash. Fix: Hash aus dem Klartext der #psk="…"-Kommentarzeile neu rechnen (wpa_passphrase "RupuWolf" "$PLAIN"). Backups: .bak-rupuwolf5 (alte SSID), .bak-oldhash.

Außerdem: Hostname auf AIme gesetzt, avahi-daemon installiert → AIme.local löst per mDNS auf und ersetzt das IP-Suchen. Die FritzBox zeigt weiter armbian-sd, weil sie den Gerätenamen beim ersten DHCP-Kontakt einfriert; ein Mitschnitt belegt, dass das Board korrekt Hostname (12): "AIme" sendet.

Titel umbenennen

PATCH /api/recordings/:id/title + Stift in Liste und Detailkopf. Neue Spalte recordings.title_locked: ein von Hand gesetzter Titel überlebt jede spätere KI-Analyse. Leeres Feld löscht Titel und Sperre.

Papierkorb, 30 Tage

DELETE /api/recordings/:id setzt nur noch deleted_at; Audio, Transkript und Analyse bleiben vollständig liegen. Neue Ansicht über der Liste mit Resttagen (rot ab 3 Tagen), Wiederherstellen, endgültig Löschen. Aufräumlauf beim Boot und täglich — der Boot-Lauf, weil das Board oft tagelang aus ist.

Altfehler mitbehoben: das bisherige Löschen ließ die words-Zeilen verwaist in der Datenbank und die WAV/MP3-Dateien für immer auf der Karte zurück. purgeRecording räumt beides ab.

Inhaltssuche mit Zeitstempel-Verweis

Neue Tabelle passages (1689 Zeilen aus 22 Aufnahmen), neuer Tab „Suche" in Google-Optik: Trefferliste sofort aus SQL, formulierte Antwort mit Belegen [1] [2] wird nachgereicht (Qwen3-4B, ~140 s). Jeder Treffer nennt Meeting, Sprecher und Gesprächsminute und springt beim Klick genau dorthin.

Hybrid aus Volltext (MariaDB FULLTEXT), Passage-Embedding und Dokument-Embedding (multilingual-e5-small, ONNX/CPU auf den A55-Kernen). Ohne das Modell degradiert die Suche sauber auf Volltext statt auszufallen.

Gemessene Grenzen — bewusst dokumentiert statt geschönt:

  • Volltext allein findet „Steueroptimierung" nicht (im Transkript steht eine andere Wortform) → genau dafür ist die semantische Ebene da, die findet #71 mit vec=0,92.
  • e5-small trennt auf verrauschtem ASR-Text schwach: für „Wie werden unsere Daten gesichert" lag die Kosinus-Spanne aller 1689 Passagen bei nur 0,8085–0,8560, und eine irrelevante Passage („Agenda zusammenpacken", 0,8560) schlug die richtige Antwort („Backup-Job startet um 11 Uhr", 0,8419).
  • Gegenmaßnahmen: Dokument-Ebene aus Titel + Zusammenfassung (sauberer LLM-Text statt ASR-Rauschen) und IDF-Wertung (Auswahl per BOOLEAN MODE, Wertung per NATURAL LANGUAGE MODE — Boolean gewichtet nicht nach Seltenheit, weshalb „Daten"-Wiederholung „gesichert" schlug).
  • Ergebnis: richtige Passage auf Rang 11 von 296 statt gar nicht. Auffindbar, aber nicht in den Top-8, die das LLM liest. Nächster Hebel: multilingual-e5-base.

Relative Datumsangaben („letzte Woche") werden deterministisch aufgelöst, nicht vom LLM — ein falsches Kalenderfenster wäre schlimmer als keins. Der Datumsausdruck wird aus den Suchbegriffen entfernt.

Dateien

Neu: app/search.js, app/emb.js, app/inference/embed_text.py, docs/09-suche-und-verwaltung.md. Geändert: app/db.js, app/server.js, app/web/src/main.js, app/web/src/style.css, README.md. Board-Backups: .bak-search. Nicht im Repo: /opt/aime-models/e5-small/ (470 MB Modell).

Symptom (Nutzer, Testanruf 13:51): „Die Gegenseite hat mich nicht mehr gehört." Regression aus v2 vom selben Tag. Das Journal zeigte den Beweis direkt:

iPhone call bluez_output.B4_96_A5_42_BD_D6.1: -callrec_mix

Nur -callrec_mix, kein +micgate, kein +far-end — v2 hat WirePlumbers Link getrennt und dann nichts verlinkt. Uplink ohne Quelle = die Gegenseite hört absolute Stille. Immerhin: v2 hat die HFP-Nodes erstmals überhaupt erkannt, der Portnamen-Fix von vormittags greift also.

Ursache: pw-link -l <node> filtert nicht. Reproduziert auf dem Board: mit einem Node-Namen, der nicht als Port auflösbar ist, gibt pw-link -l den gesamten Graph aus (sogar pw-link -l micgate.out liefert zusätzlich micgate:playback_MONO). Der Graph enthält immer micgate.out und callmix →

  • grep -q micgate.out → wahr → „schon verlinkt" → micgate-Link übersprungen
  • grep -q callmix → wahr → Gegenseiten-Link übersprungen
  • grep -q callrec_mix → wahr → das Trennen war die einzige Aktion, die je lief

Derselbe Ausdruck steckte schon in v1, dort aber hinter dem Einmal-Guard, der ohnehin nie erreicht wurde.

Fix aime-iphone-route.sh v3 (.bak-v2):

  • Neue links_of PORT parst die Peers eines exakten Ports aus einem einzigen Graph-Dump (awk: Header auf Spalte 0 exakt vergleichen, eingerückte |->/|<--Zeilen als Peers). Kein grep mehr über alles.
  • Sicherheitsbedingung: erst verlinken, dann trennen. callrec_mix → Uplink wird nur entfernt, wenn micgate.out am Uplink-Port bestätigt anliegt (Graph danach neu gelesen). Sonst bleibt der WirePlumber-Link stehen und es wird !no-micgate-uplink-keeping-callrec_mix geloggt. Ein Echo ist schlecht, unhörbar sein ist schlimmer.
  • Log-Zustand als Array statt String: CHANGED=" " (nur Leerzeichen) hätte [ -n ] passiert und bei fertig geroutetem Anruf jede Sekunde eine Journal-Zeile geschrieben.

Verifiziert: links_of gegen den echten Graph (verlinkter Port, unverlinkter Port, nicht existierender Port — jeweils exakt). Entscheidungslogik gegen einen synthetischen Mono-HFP-Graph mit gestubbtem pw-link: Pass 1 (WirePlumber-Default aktiv) → +micgate zuerst, dann beide callrec_mix-Links weg, Gegenseite in beide callmix-Kanäle, und genau ein micgate-Link in den Mono-Port (zwei wären +6 dB); Pass 2 (fertig geroutet) → vollständiger No-op, keine Log-Zeile; Pass 3 (micgate nicht bestätigt) → callrec_mix bleibt stehen, kein Trennen. Nach dem Neustart Graph unverändert bei 32 Links, Script idle ohne Anruf.

Noch offen: Gegenprobe am echten Anruf. Erwartet wird eine Zeile mit +micgate, -callrec_mix und +far-end. Steht dort !no-micgate-uplink-keeping-callrec_mix, ist der Uplink hörbar aber mit Echo, und der micgate-Link braucht eine eigene Analyse.

2026-07-30 (vormittags) — iPhone-Anruf: Gegenseite fehlt in der Aufnahme + Gegenseite hört sich doppelt

Ausgangslage (Nutzer): Ein BT-Telefonat wurde aufgenommen, in der Aufnahme ist nur die Nahseite zu hören; die Gegenseite berichtete zusätzlich, sich selbst stark doppelt zu hören. Der Pfad hatte am 2026-07-13 nachweislich funktioniert.

Messung: Aufnahme #80 (13:16, 44 s) → Diarisierung 1 Sprecher (bekannte Stimme „sascha", cos 0,779); #81 (13:38) ebenso 1 Sprecher. journalctl -t aime-iphone-route über den gesamten Boot seit 2026-07-29 12:15 → -- No entries --. Watcher-Prozess lief (PID 1446, 27 h), Service active, WirePlumber- Drop-in intakt (bluez5.roles, hfphsp-backend=native), iPhone Paired/Bonded/Trusted/Connected = yes, Profil-Enum bietet audio-gateway (A2DP Source & HSP/HFP AG). Der Nutzer hat die Gegenseite im H390-Headset gehört → die HFP-Nodes existierten, der Watcher hat sie nur nicht erkannt.

Ursache: eine, mit zwei Symptomen. aime-iphone-route.sh v1 hat nie gegriffen:

  1. Hartcodierte Portnamen. Gesucht wurde bluez_output.<MAC>…:input_FL. HFP/mSBC ist mono, die Ports heißen playback_MONO/capture_MONO → der grep matchte nie, das Script tat buchstäblich nichts. Erklärt die 0 Log-Zeilen.
  2. Einmal-Guard. if ! pw-link -l "$B" | grep -q micgate.out übersprang den gesamten Block, sobald micgate einmal verlinkt war. Da WirePlumber die HFP-Nodes nicht atomar anlegt (Uplink erscheint typischerweise vor der Gegenseite), blieb bluez_input in genau diesem einen Durchlauf leer → die Gegenseite wurde für den ganzen Anruf nie nachverlinkt. Ebenso wurde ein erst mitten im Anruf von WirePlumber gesetztes callrec_mix → Uplink nie mehr entfernt.

Daraus: Gegenseite → callmix fehlt → nicht in der Aufnahme. Und callrec_mix → Uplink bleibt stehen → Uplink führt den ungegateten Mix statt micgate.out → Rückfall auf genau den Echo-Zustand vor dem Fix vom 2026-07-13.

Fix aime-iphone-route.sh v2 (.bak-v1): Portnamen werden über pw-link -i/-o ermittelt statt hartcodiert; neue link_all-Hilfsfunktion legt genau einen Link pro Ziel-Port (mono-Quelle speist beide Kanäle; Stereo-Quelle auf mono-HFP-Port sendet nur FL — beide zu verlinken würde sie in denselben Port summieren = +6 dB auf dem Uplink). Alle drei Links werden in jedem Durchlauf einzeln geprüft und nachgezogen (Takt 2 s → 1 s), das Einmal-Guard ist weg. logger schreibt nur bei Zustandswechsel, damit das Journal nicht flutet.

Verifiziert: bash -n OK; link_all mit gestubbtem pw-link gegen alle drei Fälle getestet (stereo→stereo, stereo→mono, mono→stereo) und gegen leere Arrays; Graph nach dem Service-Neustart unverändert bei 32 Links, Script idle ohne HFP-Nodes. Noch offen: Gegenprobe am echten Anruf — journalctl -t aime-iphone-route muss dann +micgate und +far-end zeigen.

Doku: Kapitel 06 — Watcher-Sequenzdiagramm auf den Ein-Sekunden-Prüfzyklus umgestellt, Klarstellung dass Gegenseite → Headset von WirePlumber kommt und nicht vom Watcher (das alte Diagramm behauptete das Gegenteil), Kontrollkommando ergänzt.

Nebenbefund (offen): callrec.service ist am 2026-07-30 13:39:33 mit Error: write EPIPE in llm.js:84 gestorben (rkllm-Socket beim Schreiben weg), systemd hat neu gestartet (Restart-Counter 1). Der Socket-Write in llm.js hat keinen error-Handler → unhandled 'error' event reißt den ganzen Server mit. Die Zusammenfassung von #81 dürfte dadurch fehlen.

2026-07-29 — #79: verlorener Sprecher + magere Zusammenfassung (Vergleich gegen MS Teams)

Ausgangslage (Nutzer): Aufnahme #79 (23-min-Marketing-Jour-fixe) wurde trotz live getippter Namen falsch analysiert — 3 statt 4 Sprecher, Zusammenfassung 1 082 Zeichen ohne Inhalt. Als Messlatte lagen die Teams-Notizen desselben Meetings vor (5 Themenblöcke + 3 Folgeaufgaben).

Zwei unabhängige Ursachen, beide gemessen:

  1. Diarisierung: Stufe A lieferte korrekt 4 starke Cluster. Von 4 Markern mit 3 Namen landete der zweite „Merle" auf Saschas Cluster; das bedingungslose must-link verschmolz beide (Kosinus −0,727).
  2. LLM: 21k Zeichen < WHOLE_MAX_CHARS 32 000 und 3 < MANY_SPEAKERS 5 → Whole-Pass in einem Call → das erste Thema (Timo/Sabbatical) fiel der Prompt-Kompression zum Opfer.

Fixes diar.js (.bak-marklink): Anker-Gewicht nach Trefferqualität (im Turn 1,0 → 25 s Tippverzögerung 0,2); must-link nur bei cos ≥ 0,6 × gelernter Schwelle (DIAR_MARK_LINK_REL), sonst Marker verwerfen + loggen; Warnung bei mehreren Namen auf einem Cluster; Voiceprints als cannot-link (DIAR_VP_MIN 0,55, Abstand 0,05, Namensschlüssel = Vorname) — damit ist der Box-Besitzer immer ein eigener Sprecher.

Fixes server.js (.bak-vpconstraint, .bak-uniqlabel) / recorder.js: Voiceprints werden in rediarize() und transcribeAndAnalyze() in die Diarisierung durchgereicht; applyMarkers() verschmilzt nur noch bei conf = high (mid benennt weiterhin); buildBasis() vergibt eindeutige Labels (in #79 trugen zwei Cluster „Markus", der LLM verschmolz daraufhin zwei Personen im Text); Stimmabdruck schlägt einen unsicher verankerten Marker; relabelAnalysis() läuft am Ende JEDER Analyse (Namen, die während der ~20 min Laufzeit entstehen, landen sonst nie im fertigen Text — „Sprecher 1" blieb stehen).

Fixes llm.js (.bak-sectionmin, .bak-grounding): SECTION_MIN_CHARS = 9 000 (alles darüber in den Sektions-Pfad), MANY_SPEAKERS 5 → 3, CHUNK_CHARS 4 200 → 3 200, TAIL_CHARS = 700 (wörtlicher Kontext aus dem Vorgänger-Chunk), Prompt-Regeln „jedes Thema mind. ein Satz" + „nicht verallgemeinern", gezielter Prüf-Aufruf bei Grundsatz-Aussagen, Verwerfen einer reinen Verabschiedungs-Sektion am Ende.

Ergebnis #79: 4 Sprecher (Sascha/Markus/Lisa/Merle, alle automatisch benannt), Zusammenfassung 1 082 → ~5 300 Zeichen, 7 Themenblöcke, 15–18 ToDos mit Verantwortlichen; alle Teams-Themen abgedeckt. Regression geprüft: #70 → 4, #68 → 2, #74 → 20 Sprecher (unverändert).

Neu im Labor: app/lab/lab-eval2.mjs (Replay des echten clusterWindows() MIT Voiceprints), app/lab/vp-dump.mjs (Voiceprints als JSON). Damit kostet eine Clustering-Iteration Sekunden statt eines sherpa-Laufs.

Betriebs-GOTCHA: der A76-Takt wurde für einen sherpa-Lab-Lauf auf 2,256 GHz gesetzt — 88–89 °C, kurz darauf hing das Board (kein Ping auf LAN und WLAN, Hard-Reset nötig). Lüfterlos gilt weiter: schwere Jobs seriell und Cap 1,6 GHz. Nach dem Neustart hat der R1 per DHCP 192.168.1.48 bekommen (vorher .200).

2026-07-28 — GUI: alle Tabs nutzen die volle Fensterbreite

  • Problem: nur „Aufnahmen" bekam #app.wide (max-width: none), Stimmen/Glossar/Bluetooth blieben auf 860 px zentriert → beim Tabwechsel sprang die ganze Kopfzeile in die Bildschirmmitte.
  • Fix: .wide-Sonderfall entfernt — #app { max-width: none; padding: 16px 28px } gilt für alle Tabs, auf < 800 px wieder 16 px Padding. render() setzt keine Klasse mehr (main.js). Bundle index-BOK2TehD.js, CSS index-B0V_89UC.css.

2026-07-27 (3) — „(Namen: …)"-Zeilen aus der Zusammenfassung entfernt

  • Problem (Nutzer): unter jedem Absatz der Zusammenfassung stand „(Namen: Kai, Dennis, …)".
  • Fix (llm.js, .bak-nonames): composeSummary() druckt die pro Abschnitt extrahierten Eigennamen nicht mehr in den Body. Die Extraktion bleibt — sie ist Arbeitsdatum für den Rolling Context zwischen den Chunks (stabile Schreibweise von Personen/Firmen) und für die Schluss-Zeile „Genannte Namen: …".
  • Bestand bereinigt: Skript entfernte die Zeilen aus bereits gespeicherten Zusammenfassungen (#74: 7503 → 7203 Zeichen), keine Neu-Analyse nötig.

2026-07-27 (2) — Namen nachträglich in die fertige Zusammenfassung übernehmen

  • Problem (Nutzer): alle 20 Sprecher von #74 benannt, in der Zusammenfassung stand trotzdem weiter „Sprecher 5" statt Katharina. Grund: der Text entsteht mit den Labels, die beim Analysezeitpunkt galten; spätere Namen ändern nur speaker_names. Zusätzlich zählte buildBasis() „Sprecher N" nur über die UNBENANNTEN Sprecher, die Teilnehmerliste und die Spur-Beschriftung dagegen über alle → dieselbe Nummer meinte in Text und GUI verschiedene Personen.
  • Fix: neue Spalte recordings.speaker_labels (JSON) speichert das Label jedes Clusters im LLM-Text; relabelAnalysis() (server.js) ersetzt beim Umbenennen wortgenau in summary, todos und transcript_clean, baut die Teilnehmerliste aus der Diarization neu auf und schreibt das Label auf den neuen Namen fort. Aufgerufen aus POST /recordings/:id/speaker und aus applyMarkers(). Mehrdeutige Labels (ein Label auf zwei Clustern) werden bewusst NICHT ersetzt. Teilnehmerliste der Analyse nutzt jetzt dieselben Labels wie der Dialogtext.
  • #74 nachgezogen: Label-Karte des alten Laufs rekonstruiert (Namen zum Analysezeitpunkt: Kai/Fabio/Fabio) und angewandt → 0 „Sprecher N" mehr in Summary und Todos, Teilnehmerliste = die 20 echten Namen in Reihenfolge des ersten Auftretens. Rest: „Fabio" steht an 10 Stellen, davon einige eigentlich Nico (die Stimm-ID hatte damals zwei Cluster „Fabio" genannt) — sauber nur per „Analyse neu (nur KI)".
  • Dateien: server.js (buildBasis liefert Labels, relabelAnalysis), db.js (speaker_labels, setSpeakerLabels/getSpeakerLabels), web/src/main.js (Toast). Bundle index-TOCXxq47.js.

2026-07-27 — Diarization v2 (Fenster-Clustering, gelernte Schwelle) + Live-Namensmarker

  • Problem: #74 (33-min-Weekly, 22 Teilnehmer + ein Kind) automatisch neu analysiert → 44 Sprecher, dieselbe Stimme mehrfach als verschiedene Personen. Ursache (gemessen, nicht geraten): v1 bildete EIN Zentroid pro sherpa-Cluster aus max. 3 Schnitten und verglich mit fester Kosinus-Schwelle auf ROHEN Embeddings — unzentrierte TitaNet-Vektoren einer Aufnahme haben aber eine große gemeinsame Komponente (Kreuz-Turn-Kosinus ≈ 0,4), damit ist keine Schwelle trennscharf. 51 Roh-Cluster → 44.
  • Fix — diar.js v2 (.bak-v2clust): sherpa liefert nur noch Turn-GRENZEN (--min-duration-off=0, threshold 0,2 — Labels verworfen). Danach: 3-s-Fenster (Hop 1,5 s, PCM-Slicing ohne ffmpeg) → TitaNet-Embedding pro Fenster → L2 / Aufnahme-Mittelwert abziehen / L2 (Kanal-Kompensation: Kreuz-Kosinus ≈ 0, d' 3,5) → Average-Linkage-AHC Stufe A (fein 0,45) → Weak-Fold (< min(20 s, 2 % Sprechzeit)) → Stufe B bis zur pro Aufnahme gelernten Schwelle (2-Gauß-EM auf den Paar-Ähnlichkeiten, mergeThreshold(), Clamp 0,05–0,35) oder bis zur gepinnten Anzahl. Turn-Label = dauergewichtete Mehrheit seiner Fenster. DIAR_MODE=legacy = alter Pfad.
  • Warum gelernte Schwelle: fest 0,30 → #74 21 Sprecher (gut), #70 aber 9 (statt 4); fest 0,15 → #70 4, #74 kollabiert auf 14. Die Verteilung ist pro Aufnahme verschieden (gleicher Sprecher 0,21–0,30 je nach Audioqualität). Gelernt: #74 0,202 · #70 0,070 · #68 0,050.
  • Validierung (Labor + Produktion): #74 44 → 20 · #70 (4 bekannte Sprecher) → 4 · #68 (2 Personen) → 2. Modell-Benchmark auf #74 (1181 Fenster): TitaNet-large d' 3,75/197 s, CAM++ 3,72/82 s, TitaNet-small 3,54/57 s, wespeaker-VoxCeleb-resnet34 2,43/185 s → durch die Pipeline geschickt alle innerhalb eines Sprechers → TitaNet-small bleibt (schnell + alle gelernten Voiceprints bleiben gültig). Voiceprints tragen jetzt die Spalte model.
  • NEU: Live-Namensmarker (Wunsch des Nutzers) — während der Aufnahme den Namen der gerade sprechenden Person eintippen (Kopfzeilen-Feld, Enter). Tabelle markers (t_ms, name, speaker, conf); POST/GET/DELETE /api/record/marker(s) live, POST /api/recordings/:id/markers zum Nachtragen an der Abspielposition, POST /api/recordings/:id/apply-markers zum Neu-Zuordnen ohne sherpa-Lauf. Zuordnung diar.resolveMarkers(): asymmetrisches Fenster 25 s zurück / 8 s vorwärts (man tippt NACH dem Hören), Turn der den Marker überdeckt gewinnt, conf high/mid/low. Zusätzlich Supervision der Trennung: gleicher Name = must-link, verschiedene Namen = cannot-link in Stufe B; ein Name auf mehreren Clustern → Cluster werden zusammengeführt. Benannte Sprecher werden automatisch als Voiceprint gelernt.
  • Re-Diarization löscht jetzt speaker_names (server.js): die Cluster werden neu durchnummeriert, alte Namen zeigten danach auf fremde Stimmen. Marker + Auto-ID füllen sofort neu.
  • Dateien: diar.js (v2 + resolveMarkers), server.js (applyMarkers, Marker-Endpunkte, Voiceprint-Modellbindung), db.js (markers-Tabelle, voiceprints.model), recorder.js (Marker-Puffer während der Aufnahme), spk.js (modelId(), Modell-Doku), web/src/main.js + style.css (Kopfzeilen-Eingabe, Marker-Chips, Marker-Liste im Detail). Backups .bak-v2clust, .bak-markers. Bundle index-q6bHhDgg.js. Labor-Skripte (Segment-Dump, Fenster-Embeddings, Cluster-Grid, Schwellen-Fit) liegen auf dem Board unter /opt/callrec/lab/ und im Repo unter app/lab/ — damit ist das Tuning reproduzierbar.
  • Nachtrag gleicher Tag — „Voice-Auto-Naming tot" (offen seit 2026-07-16) GELÖST: TitaNet-ONNX bricht bei langen Eingaben ab (reproduziert 180 s → Non-zero status code … broadcast … 12288 by 17998, 20/60 s ok). identifySpeakers() embettete den LÄNGSTEN Lauf ungekürzt (auf #74 minutenlang) → Helfer stirbt beim ersten Sprecher; da spk.js über eine Promise-Kette serialisiert und .then() auf einer rejected Promise den Callback überspringt, schlugen ALLE weiteren Sprecher sofort mit „spk beendet" fehl. Fixes: ID_CUT_SEC = 25 s in identifySpeakers(), Kette in spk.js + tdChain in recorder.js failure-isoliert, und pro Name gewinnt nur der beste Kosinus (#74 hatte „Fabio" auf zwei Clustern). Verifiziert per Testskript: 180-s-Schnitt scheitert, direkt danach liefert ein 20-s-Schnitt wieder ein Embedding.
  • GOTCHA (Betrieb): zwei schwere Jobs parallel (Labor-sherpa + App-Analyse, deren finally den Takt auf 2,25 GHz zurückstellt) haben die A76 auf 101,9 °C getrieben — lüfterloses Board, das ist Shutdown-Nähe. Schwere Jobs immer serialisieren, Cap 1,4–1,6 GHz.

2026-07-17 — Round-Robin-Meetings: Sektions-Pfad statt Whole-Pass ab 5 Sprechern (Recency-Bias-Fix)

  • Problem: #74 (33-min-Weekly, 13 Sprecher, 31k Zeichen) lief knapp unter WHOLE_MAX_CHARS als Whole-Pass → Summary enthielt fast nur die letzten Sprecher. 4B kann 15+ gleichwertige Themen in einem ~10k-Token-Prompt nicht gewichten; Whole-Pass taugt nur für narrative Gespräche mit wenigen Sprechern.
  • Fix (llm.js, .bak-standup): analyze() routet labeled Transkripte mit ≥ 5 Sprechern (MANY_SPEAKERS, countSpeakers()) immer in analyzeLong (Sektions-Konglomerat). Sektions-Pfad auf Besprechungsnotizen-Niveau ausgebaut: thema-Überschrift pro Chunk, Pflicht zur Erwähnung JEDER berichtenden Person, Smalltalk-Dämpfung (Privates max. Halbsatz), Todos nur beruflich mit Verantwortlichem in Klammern, Überblick-Mikro-Call (PROMPT_OVERVIEW, Key-tolerant: Qwen schreibt teils "überblick"), Summary-Cap 6 000 → 16 000 (alter Cap schnitt 10 Sektionen mitten im Satz ab).
  • Validierung: 3 Iterationen per reanalyze-llm auf #74 — final alle 13 Sprecher + alle Themen abgedeckt (Intro-Absatz, 10 Themen-Sektionen, 30 saubere Todos mit Verantwortlichen), kein Recency-Bias. Temporärer /api/debug/ask fürs Overview-Prompt-Tuning wieder entfernt (server.js unverändert).

2026-07-17 — GUI: Aufnahme-Tab gestrichen, Record-Button in der Kopfzeile + Stille-MP3-Fix

  • Der Reiter "Aufnahme" ist weg: Quelle (CallRecMix), HFP-Codec (auto) und Sprecherzahl (auto) waren eh die Defaults. Aufnahme-Knopf (roter Kreis) + Laufzeit sitzen jetzt MITTIG in der Kopfzeile zwischen Logo und Tabs; beim Aufnehmen pulsiert er rot. Start legt SOFORT ein rotes "Aufnahme laeuft..."-Kaestchen mit Live-Timer ganz oben in der Liste an; Stop erzeugt daraus die echte Zeile (transcribing) und oeffnet sie rechts. Standard-Tab ist jetzt "Aufnahmen".
  • recorder.js (.bak-silentmp3): lame crashte auf komplett stiller Aufnahme (psymodel.c calc_energy: el >= 0 — seit dem Mic-Gate real: niemand sprach = Null-Samples, loudnorm-Kette triggert die Assertion) -> Fallback-Encode ohne Filter statt Stop-Fehler. E2E getestet (3-s-Stille-Aufnahme: Stop ok, Zeile erstellt, transkribiert, wieder geloescht). Bundle index-UPMXRPJp.js.

2026-07-17 — GUI: Scrollbalken im Logo-Kupfer

Alle Scrollbalken (Liste, Transkript-Panel, Sprechblase, Seite) jetzt im Akzent-Kupfer: Firefox via scrollbar-color, Chromium/WebKit via ::-webkit-scrollbar-* (10 px, runder Daumen, transparente Schiene). Bundle index-U7lT-ig5.js.

2026-07-17 — GUI: Listenbreite per Splitter ziehbar

Zwischen Liste und Detail sitzt jetzt ein 6-px-Splitter (Hover/Ziehen = Kupfer-Schimmer, col-resize-Cursor): Liste zwischen 340 px (alte Festbreite = Minimum) und 680 px (Maximum = doppelt) frei ziehbar, Breite persistiert in localStorage['aime-list-width']. Auf schmalen Screens (gestapelt) ausgeblendet. Playwright-getestet (Clamp 340/680 + Persistenz). Bundle index-BbmdfFEI.js.

2026-07-17 — GUI: Kupfer-Zaehler, Favicon, Tab-Reihenfolge

Tages-Zaehler in der Liste jetzt im Logo-Kupfer (accent-soft-Pille); Wellenform-Logo als Favicon (SVG-data-URI in index.html, Browser-Tab); Tab-Reihenfolge neu: Aufnahme, Aufnahmen, Stimmen, Glossar, Bluetooth (Bluetooth ans Ende). Bundle index-Bt2IsJIF.js.

2026-07-17 — GUI: Liste startet mit nur dem neuesten Tag geoeffnet

Beim Betreten des Aufnahmen-Tabs ist nur noch die aktuellste Tagesgruppe aufgeklappt, alle aelteren sind eingeklappt (Zaehler-Badge zeigt die Anzahl). Manuelles Auf-/Zuklappen bleibt bis zum naechsten Tab-Wechsel erhalten (state.resetCollapse-Flag, angewandt beim ersten Listen-Build). Bundle index-C_-UUeU5.js.

2026-07-17 — GUI: Transkript/Am-Tisch-Panel ebenfalls verschiebbar (zwei Drop-Spalten)

Alle Bloecke — Zusammenfassung, ToDos UND das Transkript-/Tisch-Panel — sind jetzt per Drag & Drop frei anordenbar, auch SPALTENUEBERGREIFEND: zwei Drop-Zonen (links 54 % / rechts 46 %), Layout persistiert als localStorage['aime-detail-layout'] {main:[...], side:[...]} (altes aime-detail-order wird migriert). Eine leere Spalte kollabiert zu einem schmalen gestrichelten Streifen und bleibt Drop-Ziel (JS-Klasse .empty — :empty scheitert an Template-Whitespace). Bundle index-B1y2-Z_b.js.

2026-07-17 — GUI: Sprecher-Modul entfernt, Umbenennen direkt am Verlauf

Das separate "Sprecher"-Modul war redundant zu den Spur-Labels. Jetzt: Klick auf einen Namen links im Sprecher-Verlauf oeffnet ein Popover (Name aendern + "als Stimme lernen" + Speichern) — gleiche API wie vorher (POST /speaker). Klick-Seek in den Spuren zuendet nur noch IN einer Spur, nicht mehr auf der Label-Spalte. Bundle index-Bj1pQQtk.js.

2026-07-17 — GUI: Blasen-Oeffnung zeigt exakt auf den Sprecher

Bei mehreren Sprechern je Tischkante (5-/6-Sitz-Layouts) zeigte der Schwanz nur auf die Kantenmitte. Jetzt wird die Sitz-x-Position in Blasen-Koordinaten umgerechnet (CSS-Variable --ax, BUBBLE_W in main.js synchron zur CSS-Breite) — die Oeffnung wandert entlang der Ober-/Unterkante exakt zum Sprechenden; bottomleft/bottomright-Sonderklassen entfallen. Bundle index-FKbNFs-A.js.

2026-07-17 — GUI: Tisch-Blase fix, Oeffnung wandert zum Sprecher

Feinschliff nach User-Feedback: Die Sprechblase liegt wieder FEST auf dem Tisch (54 % Breite), nur ihr Schwanz/die Oeffnung springt per arrow-Klasse zum jeweils Sprechenden; Rand-/Namensfarbe wechseln weich mit (border-color-Transition). Wort-Highlight, internes Mitscrollen und Klick-Seek bleiben. Bundle index-CpZJOt3u.js.

2026-07-17 — GUI: Sprechblase am Sprecher mit Wort-Highlight + Mitscrollen

Tisch-Ansicht verfeinert: Die Blase haengt jetzt klassisch am MUND des Sprechers (dynamisch zwischen Sitz und Tischmitte positioniert, Schwanz zeigt zum Kopf) statt zentral auf dem Tisch. Inhalt = der komplette Redebeitrag als einzelne Wort-Spans: aktives Wort wird synchron zum Audio gehighlightet (wie im Transkript), die Blase scrollt intern mit (innere .tb-scroll-Huelle, damit der Pfeil nicht mitscrollt), Klick auf ein Wort springt im Audio dorthin. Bundle index-BK0gpRp9.js.

2026-07-17 — GUI: Konferenztisch-Ansicht (Umschalter neben "Analyse neu")

Neuer Umschalt-Button (Icon: drei Personen am Tisch <-> Textblatt) in der Status-Zeile: ersetzt das Transkript rechts durch eine animierte Szene — Sprecher als Figuren in ihren Spurfarben um einen abgerundeten Tisch, Sprechblase AUF dem Tisch (Rand/Name in Sprecherfarbe, Pfeil zeigt zum Sprecher, Text = aktueller Redebeitrag), alle anderen Personen drehen die Pupillen zum Sprechenden, dessen Mund animiert beim Reden (reduced-motion respektiert). Sitzordnungen fuer 1-6 Teilnehmer vordefiniert. Wahl bleibt in localStorage['aime-side-view']. Bundle index-DXTNo40e.js.

2026-07-17 — GUI: Aufnahmen-Tab nutzt die volle Fensterbreite

#app.wide war auf 1180 px gedeckelt — auf breiten Monitoren blieb rechts/links viel Leerraum und die Zusammenfassung wurde unnötig schmal/lang. Deckel entfernt (max-width: none, Padding 28 px); die Liste bleibt 340 px fix, das Detail bekommt den Rest. Bundle index-RpsXFTh8.js.

2026-07-17 — GUI: Live-Text-Box entfernt

Die Box rechts neben den Sprecher-Spuren (aktueller Redebeitrag) flog auf User-Wunsch raus — redundant zum mitscrollenden Transkript daneben. Spuren nutzen jetzt die volle Breite. Bundle index-BiCA5LX5.js.

2026-07-17 — GUI: Sprecher-Verlauf als Vollbreiten-Streifen unter dem Player

Der Sprecher-Verlauf (Spuren + Live-Text) sitzt jetzt fix unter der Audio-Laufleiste und zieht sich ueber BEIDE Spalten (oberhalb von Zusammenfassung und Transkript); aus den Drag&Drop-Modulen entfernt. Lanes ~2/3, Live-Box ~1/3 Breite. Bundle index-COpkdtd3.js.

2026-07-17 — GUI: Transkript als eigene Scroll-Spalte

Transkript (+ Dialog) aus den Drag&Drop-Modulen herausgelöst: eigene, sticky Spalte rechts neben Zusammenfassung/Verlauf/ToDos/Sprecher, Höhe auf Viewport begrenzt, scrollt INTERN durch und folgt dem aktiven Wort im Panel (statt die Seite zu ziehen). Unter 1100 px stapeln die Spalten (Transkript max. 60 vh). Bundle index-DQmY8pnQ.js.

2026-07-17 — GUI: Sprecher-Verlauf-Visualisierung + Drag&Drop-Module

Detail-Ansicht ist jetzt modular: fester Kopf (Titel, Player, Status), darunter Blöcke Zusammenfassung / Sprecher-Verlauf / ToDos / Sprecher / Transkript — jeder Block hat einen Grip-Header (⠿) und lässt sich per Drag & Drop umsortieren; die Reihenfolge liegt in localStorage['aime-detail-order'] und gilt für alle Aufnahmen.

Sprecher-Verlauf (neu): pro Sprecher eine farbige Spur (sp0–sp3) übereinander mit Redeabschnitts-Blöcken (Wörter mit ≤ 1,2 s Lücke zusammengefasst), Kupfer-Playhead wandert beim Abspielen mit, die Spur des gerade Sprechenden leuchtet (Label fett, Punkt pulsiert; respektiert prefers-reduced-motion), Klick in die Spuren springt an die Stelle. Rechts daneben die Live-Box: zeigt Sprecher + Text des aktuellen Redebeitrags. Per Playwright-Screenshots verifiziert (beide Themes, Playback mit aktiver Spur). Bundle index-WaE171bv.js.

2026-07-17 — GUI: „Weggezogen"-Bug gefixt + Redesign mit Dark/Light-Switch (.bak-theme)

  • Bug: lief beim ausgewählten Eintrag eine Analyse, feuerte ein 5-s-Timer openRecording(alteId) als stale closure — auch nachdem der Nutzer längst eine andere Aufnahme hörte → riss ihn zurück und stoppte den Player. Fix: Poll bricht bei Auswahl-/Tab-Wechsel ab, prüft während der Analyse NUR den Status (kein DOM-Rebuild) und baut das Detail genau EINMAL neu, wenn die Ergebnisse da sind — inkl. Wiederherstellung von Player-Position und Play-Zustand.
  • Redesign: Themes über CSS-Variablen (data-theme auf <html>, vor First-Paint aus localStorage/ OS-Einstellung, Toggle ☀️/🌙 im Header). Dunkel = neutrales Anthrazit, Hell = warmes Papier; Akzent = Kupfer (#e8804c/#c05f2d — Rot bleibt der Aufnahme vorbehalten), Wortmarke mit Wellenform-Glyph, Pill-Tabs, weichere Karten, sichtbarer Tastatur-Fokus, prefers-reduced-motion respektiert. Sprecherfarben (sp0–sp3) als Variablen mit hell/dunkel abgestimmten Werten.
  • Bundle index-C2xK4eql.js / index-ClT74fBX.css. Dateien: web/index.html, web/src/main.js, web/src/style.css (Backups .bak-theme).

2026-07-17 — Hall auf der Gegenseite + verschmolzene Sprecher: Mikro→Mix jetzt GEGATET (.bak-gatedmix)

User: Gegenseite klingt „wie in einem schallenden Raum", Diarization macht aus zwei entfernten Personen eine. Diagnose per Cepstral-Analyse auf #71: Gegenseiten-Segment hat einen felsenstabilen Peak bei 79–80 ms (22/26 Frames) = deterministische verzögerte Kopie; User-Segment sauber. Ursache: der direkte cr_mic2mix-Loopback (immer offen) legte die Gegenseite doppelt in den Mix — digital (cr_pc2mix) UND als Kopfhörermuschel-Bleed durchs Mikro (~80 ms Latenz). Der Hall verschmiert zusätzlich die TitaNet-Embeddings → zwei Remote-Stimmen (gleicher Kanal + gleicher „Raum") rücken über die 0,5-Kosinus-Schwelle zusammen.

  • Fix (aime-usb-audio-setup.sh): direkter cr_mic2mix-Loopback ENTFERNT; stattdessen pw-link micgate.out → callmix — dieselbe Gate-Auskopplung wie beim Teams-Selbstecho-/iPhone-Uplink-Fix. In Sprechpausen des Nutzers ist das Mikro komplett aus der Aufnahme. Fallback ohne Gate = alter Loopback.
  • Verifiziert strukturell (pw-link -l: callmix ← cr_pc2mix + micgate.out, kein HSMIC-Direktpfad) und funktional (4 s callrec_mix bei Stille = RMS 0/Peak 0). Echter Call steht noch aus.
  • Nebenbei entdeckt: OOM-Ereignis 17:47 (die fünf 60-s-READY-Timeout-Retries beim ersten Qwen3-Start luden je 5,3 GB, NPU-Speicher der gekillten Loads wurde nicht schnell genug frei) riss mariadb, callrec UND pipewire weg — Audio-Topologie war seitdem TOT. Mit dem 180-s-Timeout (Changelog unten) behoben; Topologie neu aufgebaut. Lehre: nach OOM immer pw-link -l prüfen.

2026-07-16 — Glossar: Einträge bearbeitbar (.bak-gedit)

User-Wunsch: gespeicherte Glossar-Begriffe nachträglich ändern. Neu: PUT /api/glossary/:id (db.updateGlossary), GUI-Stift-Button je Eintrag lädt Begriff+Soundalikes ins Formular (Titel „… bearbeiten", Speichern/Abbrechen), Update statt Neuanlage. Bundle index-NRK6cGUI.js. API-verifiziert (POST→PUT→DELETE). Dateien: server.js, db.js, web/src/main.js.

2026-07-16 — rkllm vor Diar/Identify freigeben (TitaNet-OOM) + #71 validiert

  • Bug: residenter rkllm (5,9 GB) ließ den TitaNet-spk-embed-Helper OOM-sterben („spk beendet" pro Cluster) → Konsolidierung bekam NULL Centroids → #71 bekam still 48 Roh-Sprecher. Fix (server.js): llm.stopServer() am Anfang von rediarize() UND analyzeRecording() (Reload ~96 s, egal bei 15-min-Analysen).
  • #71 E2E danach: Diar 48 → 2 Sprecher (5 schwache gefoldet), Qwen3-Whole-Pass auf 31,3k Zeichen → „Steuergestaltung für wachsendes Unternehmen" mit konkreten Zahlen (30 MA, 3 Mio Umsatz, 15-20 %-Ziel, Kick-off Hamburg, Termin 23.) — alter Titel war generisch, alte Sprecherzahl 28.
  • OFFEN: identify … spk beendet schlägt auch MIT freiem RAM fehl → Voice-Auto-Naming (Voiceprints) aktuell tot; Teilnehmer erscheinen als „Sprecher N". Separater Bug, noch zu untersuchen (spk.js/Helper).

2026-07-16 — ✅ LLM → Qwen3-4B-Instruct-2507 (Nacht; Gemma-Rollback vorhanden)

Auf User-Wunsch Qwen3-4B selbst konvertiert und im A/B gegen Gemma getestet — 3/3 Prompts klar besser, produktiv geschaltet und E2E validiert.

  • Konvertierung: wiederverwendetes Kit (rkllm-convert:1.3.0-Docker neu gebaut), HF Qwen/Qwen3-4B-Instruct-2507 (7,6 GB bf16) → w8a8_g128/grq, max_context 16384, auf RTX 3090 in ~35 min → 5,3 GB .rkllm, pscp aufs Board.
  • A/B (gleiche Prompts wie Gemma-Tests): Chunk 1 (Com4Tec-Story) in 140 s mit ALLEN Nuancen (50 %-Kappa, kein Body Leasing, Perinko/Icon); Auto-Chunk 91 s inkl. Detail-Korrektur „Michael Wagner, nicht Fabienne"; Whole-Pass 21,8k Zeichen in 691 s fehlerfrei gewichtet — exakt der Fall, in dem Gemma englisch schwafelte bzw. nur das Ende zusammenfasste. Qwen3 = ChatML-Template statt Gemma-Turns (rkllm_server.cpp).
  • Produktiv-Swap: Drop-in RKLLM_MODEL=qwen3-4b-w8a8g128-16k.rkllm, prod rkllm_server mit ChatML neu gebaut (Rollback: .bak-gemmatmpl + Drop-in zurückstellen), WHOLE_MAX_CHARS 20 000 → 32 000 (bis ~35-min- Aufnahmen Single-Pass; Sektions-Konglomerat bleibt für längere). GOTCHA: Qwen3 lädt ~96 s → llm.js READY-Timeout 60 s → 180 s (sonst Endlos-Retry-Schleife, real passiert).
  • E2E validiert: #70 Whole-Pass 15,4 min → „Neue Kontakte und Stellenangebote", Auto raus, Comfort Tech/ Adem Marlock/Nico-Profil-Todo konkret; #68 Kurzclip (analyzeSingle+ChatML) 22,2 min inkl. Queue → sauber.
  • Damit ist die 4B-Synthese-Decke aus dem Sektions-Konglomerat-Eintrag (unten) für ≤ 32k Zeichen GESCHLOSSEN; das Konglomerat bleibt als >32k-Pfad und Fallback.

2026-07-16 — A/B-Test Qwen2.5-3B vs Gemma-3-4B im neuen Chunk-Pfad: Gemma bleibt

Auf User-Wunsch das noch vorhandene qwen2.5-3b.rkllm gegen die neuen 4,2k-Chunk-Prompts getestet (Test-Binary mit ctx 4096 in /tmp, direkt übers Stdin-Protokoll — kein App-Umbau). Ergebnis: 3 von 3 Outputs unbrauchbar (Feldbeschreibungs-Echo ohne Inhalt; Abbruch nach ~100 Zeichen mitten im Satz; Struktur-Müll mit der Feldbeschreibung als JSON-Key) — Gemma lieferte im selben Job 8/8 verwertbare Sektionen. Dazu prinzipiell: Qwen2.5 hat fix 4096 Kontext → der validierte Whole-Pass (≤ 20k Zeichen) wäre unmöglich. Testartefakte entfernt. Offene Alternative (ungetestet, ~½ Tag): Qwen3-4B neu konvertieren.

2026-07-16 — Sektions-Konglomerat statt LLM-Reduce (Abend, .bak-sectionsum)

Auftrag: Lang-Transkript-Summary (> 20k Zeichen) inhaltlich retten. Prompt-Lab auf #70 mit temporärem /api/debug/ask-Endpoint (nach Abschluss wieder entfernt), ~15 Reduce-/Chunk-Varianten in je ~2 min statt 9-min-Vollanalysen.

  • Befund 1: Chunk-Größe ist der Fidelity-Hebel. 8k-Chunks → generisches namenloses Blabla; 4k-Chunks → geerdete Summaries mit echten Eigennamen (Com4Tec, René, Tobi+Niklas, 20 Bewerbungen). CHUNK_CHARS 8000 → 4200.
  • Befund 2: Cross-Section-Synthese unmöglich auf Gemma-3-4B. Jede LLM-Reduce-Variante (Gewichtungsregeln, Abschnitts-Labels, Themen-Tabellen) → Regeln ignoriert, Namen durch Rollen ersetzt, Dubletten, Loops. LLM-Reduce abgeschafft.
  • Neue analyzeLong-Pipeline (llm.js): 4,2k-Chunks sequenziell mit Rolling Context (1-Satz- Kurzfassungen der Vorgänger → erkennt geänderte Entscheidungen: „jetzt X, vorher Y") + eigennamen- Extraktionsfeld; Echo-Guard (Feldbeschreibungs-Echo ist INHALTS-abhängig reproduzierbar → bis 3 Versuche mit variiertem Prompt: ohne Kontext, ohne Hint) + Feld-Salvage per Regex + Prosa-Rettung (≥ 60 Zeichen); Titel-Mikro-Call ohne letzten Abschnitt (Smalltalk-Filter); Konglomerat baut Code: „Gesprächsverlauf in n Teilen — genannte Namen: …" + „Teil i/n: …" chronologisch. Kap. 05 (Mermaid + Marotten aktualisiert).
  • E2E #70 (3 Läufe): Titel ohne Auto-Smalltalk ✅, alle Abschnitte mit echten Namen ✅, Auto nur noch in Teil 7 ✅, 12-14 min für 26-min-Aufnahme. Restgrenze: Abschnitts-Summaries variieren pro Lauf in Schärfe (Sampling), keine thematische Verdichtung ÜBER Abschnitte (4B-Decke). ≤ 20k Zeichen weiter Whole-Pass.

2026-07-16 — #70-Debug: Phantom-Sprecher, Summary-Fehlgewichtung, Thermal-Leck (Nachmittag)

Auslöser: Aufnahme #70 (26 min, 4 Personen) — 7 Sprecher erkannt, Titel/Summary aus Randthemen (Auto-Smalltalk der letzten 3 min statt Recruiting-Hauptteil). 9 Analyse-Iterationen auf #70.

  • Diarization Stufe 3 „Weak-Fold" (diar.js, .bak-weakfold): finale Sprecher < DIAR_MIN_TOTAL_SEC (20 s, Segment-Zeiten!) werden in den akustisch nächsten starken Sprecher gefaltet — ein einzelner ≥ 3-s-Streu-Cluster konnte vorher einen Phantom-Anchor eröffnen (#70: 3 Phantome mit 2,5–4,8 s). 10 s reichte NICHT (9,2-s-Phantom kam durch). Verifiziert: #70 konstant 50 Roh-Cluster → exakt 4. Kap. 04.
  • Whole-Pass-Grenze = Modellgrenze bewiesen (Runden 2–5): rkllm-Runtime-Kontext 8192 → 12288 (rkllm_server.cpp, .bak-ctx12k, Rebuild) und WHOLE_MAX_CHARS testweise 32k → Gemma-3-4B kippt

    ~6k Input-Token reproduzierbar: englisches Geplauder statt Analyse, Recency-Bias (fasst nur das Gesprächsende zusammen), JSON-Zerfall (typografische Quotes, Wiederholungsschleifen). WHOLE_MAX_CHARS zurück auf 20 000 (dort validiert gut); ctx 12288 bleibt (Headroom). Kap. 05 neuer Abschnitt.

  • Robustheit llm.js (.bak-ctx12k): End-Instruktion nach dem Transkript (ohne Platzhalter-Skelett — Gemma kopiert ... wörtlich!); JSON-Repair-Call (repariert kaputten Output OHNE das große Transkript); Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback statt Hard-Error; extractJson() repariert typografische Anführungszeichen + trailing commas; 1 Retry pro Chunk (Parse = Sampling- Münzwurf: 3/3 ok vs 1/3 im nächsten Lauf; Retry live bestätigt); Reduce-Input mit „Abschnitt i von n"- Labels + Gewichtungsregeln (Hauptthema = mehrere Abschnitte; Nur-letzter-Abschnitt = Smalltalk).
  • Regression bestätigt: Chunk-Prompt MUSS minimal bleiben — eine zusätzliche Anweisungszeile (Namen/Firmen/Zusagen) kippte 2/3 Chunks in Prosa (Runde 6). Zurückgebaut.
  • Thermal-Leck geschlossen (server.js, .bak-anacap): analyzeRecording() lief als einziger schwerer Pfad OHNE A76-Takt-Cap → 95–97 °C bei langer Gemma-Generierung (lüfterlos!). Jetzt Cap wie transcribeWav/rediarize. Kap. 03/05.
  • Ehrliches Ergebnis #70: Sprecher exakt 4 ✅, Analyse crashfrei in ~9 min ✅, Summary führt jetzt mit Recruiting, ABER Auto-Smalltalk bleibt in Titel/Kernpunkten und konkrete Namen (Com4Tec, Kandidaten) fehlen — Qualitätsdecke des 4B-Modells im Chunk-Pfad (> 20k Zeichen). Aufnahmen ≤ ~23 min laufen über den validierten Whole-Pass.

2026-07-16

  • Codebase ins Repo aufgenommen: app/ = Spiegel von /opt/callrec (Backend, Frontend-Quellen, Inferenz-Python, Skripte, rkllm_server.cpp), system/ = systemd-Units + WirePlumber/PipeWire-Drop-ins + sudoers. Modelle/Binaries/Aufnahmen bewusst außen vor (.gitignore).
  • Projekt umbenannt in „Aime“ (vormals CallRec, wie der BT-Name des Boards): Gitea-Repo → sascha/Aime, Web-GUI-Titel/Header → „Aime“ (Bundle index-CuWeUh1j.js, Backups index.html.bak-aime/main.js.bak-aime), Doku rebranded. Technische Bezeichner auf dem Board bleiben callrec* (Stabilität).
  • Doku-Repo angelegt (dieses Repo). Ab jetzt verbindliche Ablage inkl. Diagrammen.

2026-07-15/16 — Strukturierte Analyse + Teilnehmer-Fix (.bak-structsum)

  • Teilnehmer kommen aus der Diarization statt vom LLM (das listete alle erwähnten Namen → 11 statt 3 bei #69).
  • Summary strukturiert: Überblick + „Kernpunkte“ + „Entscheidungen“ als Bullets (composeSummary, CSS pre-line).
  • Chunk-Salvage-JSON-Leak gefixt; Reduce-Input gekappt + Retry; cleanList filtert null/[object Object]/Objekt-Todos.
  • Referenz #69 (71,6 min): Analyse ~20 min, Ergebnis sauber. Bundle index-B2dSaBty.js.

2026-07-15 — LLM-Wechsel auf Gemma-3-4B + Marker-Fix

  • Qwen2.5-3B → Gemma-3-4B-it (gemma3-4b-w8a8g128-16k.rkllm, grouped w8a8; plain w8a8 kaputt, w4a16 nicht unterstützt).
  • rkllm-Runtime 1.2.1 → 1.3.0. analyzeWhole (≤ 20k Zeichen) ersetzt Map-Reduce im Mittelbereich.
  • Qualität ≈ Gemini, ~3× langsamer. Wort-Highlight-Marker lief ~1 Wort voraus → SYNC_MS=250 (index-CLero27-.js).

2026-07-14 — Kurzclip-Beschleunigung + Analyse-Timer

  • Clips ≤ 30 s über warmen CPU-Whisper-Daemon ohne rkllm-Swap: 10-s-Clip 120 s → 53 s (.bak-shortdaemon).
  • GUI zeigt Analysezeit live (analysis_ms/analysis_started_at, .bak-anatimer).

2026-07-13 — Diarization stabil + NPU-Meilenstein 2 + iPhone-Repair

  • Greedy-Anchor-Merge ersetzt Union-Find: derselbe Call ergab vorher 2/3/7 Sprecher, jetzt konstant 27→3 (.bak-anchor).
  • Meilenstein 2: Whisper-turbo-Encoder auf NPU produktiv — RTF 1,51 → 0,61, Temp 90 °C → 59 °C; batched (batch=8) → RTF 0,389.
  • iPhone neu gekoppelt, Trusted=yes gesetzt (fehlte → keine HFP-Nodes → #58 ohne Gegenseite).

2026-07-09 — Meilenstein-1-Snapshot, Auto-Diarization, Glossar, Whisper-Kalibrierung

  • Snapshot /opt/callrec/milestones/m1-20260709/ (Rollback-Punkt).
  • Auto-Diarization: 2-stufige Konsolidierung (17→2 verifiziert), reanalyze re-diarisiert immer (0=auto / >0=gepinnt).
  • Glossar Phase 1: deterministische Ganzwort-Ersetzung + LLM-Hinweis, GUI-Tab, „Aime“ geseedet.
  • Whisper-Kalibrierung: medium beam1 optimal (11,3 % WER vs large-v3); --initial-prompt mit Termliste = Echo-Loop (tabu).
  • 46-min-E2E über Nacht sauber (Long-Rec-Fixes produktionsvalidiert).

2026-07-08 — Echo-Fix, Thermal-Crashes, Whisper medium, Sprecher-Lernen, Long-Rec-Analyse

  • Selbst-Echo der Gegenseite gelöst: LADSPA-Noise-Gate micgate im Mikro→PC-Pfad (Thr −45 dB, Mic-HW niedrig); AEC verworfen. Reboot-verifiziert.
  • Thermal-Schutzabschaltungen gefixt: A76-Cap 1,6 GHz über die ganze transcribeWav (Whisper+Diar), SIGSTOP-Guard >90 °C; crash-sicheres asynchrones Speichern (stopAndSave persistiert vor Transkription); /retranscribe für hängende Zeilen.
  • Whisper small → medium (Hörfehler).
  • Sprecher-Umbenennen + Stimm-Lernen (Voiceprint-Zentroide) + Leertaste Play/Pause.
  • Lange Aufnahmen (> 20 min) analysierbar: Chunked Map-Reduce gegen 4096-Token-Limit; Diar-Timeout skaliert mit Dauer; Lösch-Confirm.

2026-07-07 — MEILENSTEIN 1: USB-Headset-Pfad komplett

  • Logi H390 ersetzt toten ES8323-Mikrofonpfad; callmix + 4 Loopbacks + callrec_mix-Remap; Volume-Bridge; Inline-Tasten.
  • Kaltstart-verifiziert. Clip-sicheres Gain-Staging (PC→Mix 60 %) — Übersteuerung hatte Diarization zerstört (14 Sprecher, #41).
  • Re-Diarization mit fester Sprecherzahl (numSpeakers), #41 14→2 verifiziert.
  • LLM-Fixes: Phantom-Todo (Prompt-Beispiel-Echo) entfernt, KV-Cache-Clear, leere Todos → [].

2026-07-06

  • ES8323-3,5mm-Mikro als defekt diagnostiziert (Verstärkung fest −42,14 dB) → Entscheidung für USB-Headset.

2026-07-05 — „Helikopter“-Debugging (Referenz)

  • ES8323 S16-Modus = Treiberbug (S24 nutzen); PipeWire/alsaloop-Capture des ES8323 injizierte ~13 Klicks/s, arecord nicht; Windows-Shared-Mode-AGC = separates 20-Hz-Pumpen (exklusiv sauber).
  • Sauberer Fallback damals: dual native-clock arecord. Durch Headset-Pfad obsolet.

Früher

  • iPhone-BT-HFP-Pfad aufgebaut (Rollen-Drop-in, frischer Pair, Echo-Fix Uplink←micgate, Route-Watcher) — Details Kap. 06.
  • BT-Buds No-Bonding-Quirk: LinkKey manuell persistiert (Referenz, Dongle entfernt).