# 08 — Changelog Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). ## 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` 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](04-diarization.md). ## 2026-08-07 (2) — „KI-Analyse fehlgeschlagen" bei einer 34-Sekunden-Aufnahme: zwei Modelle gleichzeitig **Symptom:** Aufnahme #101 (34 s, 2 Personen) — Transkript und Sprecherspuren da, aber „KI-Analyse fehlgeschlagen". Also **nicht** dieselbe Ursache wie der Vormittags-Fix: der betraf die Länge, dieser die *Reihenfolge*. Gemeinsam ist nur der OOM-Killer. **Ursache 1:** `rkllm_server` braucht 6,5 GB von 7,74 GB. Die 2-Sekunden-Aufnahme #100 unmittelbar davor hatte über den Kurz-Clip-Pfad den warmen CT2-Whisper-Daemon (0,9 GB) gestartet, der per Design resident bleibt. Beim rkllm-Load war er noch da → OOM-getötet, und zwar in **allen 5 Startversuchen**, weil das Retry nichts freigab: `[rkllm] start 5/5 failed: rkllm beendet vor READY`. **Ursache 2:** rkllm blieb nach einer Analyse für immer resident (der Prozess von 10:37 lebte um 13:27 noch). Der Suchindex am Ende von `analyzeRecording()` startet aber e5-small (1,15 GB) — daneben erschlug der Kernel rkllm (13:27:09). Dasselbe Muster war auch der Dauerzustand „nur ~100 MB frei". **Fix:** `gate.js` bekommt eine Freigabe-Registry (`registerReleaser`/`freeRam(keep)`/`killAndWait`, verdrahtet in `server.js` wie schon `setProbe`). Wer ein großes Modell lädt, beendet vorher die anderen Dauerhelfer und wartet auf ihr Prozessende — vor **jedem** der 5 rkllm-Versuche und vor dem NPU-Whisper-Lauf. Dazu gibt `analyzeRecording()` rkllm frei, **bevor** der Suchindex läuft. Alle Helfer starten faul neu; verloren geht nur Ladezeit. **Verifiziert** mit genau der Abfolge, die scheiterte: 12-s-Clip (CT2-Daemon resident) → Analyse #101 → `ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder`, rkllm lud in 22 s, Analyse „Auto und Budget" in 221 s fertig (`done`), kein OOM, danach **5,3 GB frei** statt 100 MB. Dateien: `app/gate.js`, `app/llm.js`, `app/server.js`, `app/recorder.js`, `app/spk.js`, `app/emb.js` (Board-Backups `.bak-ramfree`), Kapitel [05 – LLM-Analyse](05-llm-analyse.md) (neuer Abschnitt „Speicherhaushalt" mit Messtabelle und Diagramm). **Offen, getrennt davon (gemessen, nicht gefixt):** dieselbe Aufnahme zeigt 4 Sprecher statt 2, und die Stimmabdruck-Erkennung ist auf ROHEN Kosinus nicht trennscharf — `Sascha` ↔ `Sascha Wolf` (dieselbe Person, zwei Einträge) liegt bei 0,928, aber `Sascha Wolf` ↔ `Johannes` (verschiedene Personen) bei **0,912** und `Sascha` ↔ `Fabio Haase` bei 0,876. Bei `DIAR_VP_MIN` 0,55 passt damit fast jeder auf jeden; eine höhere Schwelle würde nur alles ablehnen. `Jens` ↔ `Jens Kuppler` ist mit Kosinus **1,000** sogar eine exakte Dublette. Der Clustering-Teil hat dieses Problem 2026-07-27 durch Mittelwert-Zentrierung gelöst — die Abdruck-Erkennung arbeitet noch ohne. Dazu kommt die Über-Segmentierung kurzer Clips (diagnostiziert 2026-07-31, Kap. 04). ## 2026-08-07 — Lange Aufnahmen scheiterten: sherpa-Diarisierung lief in den OOM-Killer **Symptom:** Aufnahme #99 (53:56) endete mit „Transkription fehlgeschlagen“ (Badge „KI-Fehler“), beim 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](04-diarization.md). **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](01-hardware.md) und [07 – Betrieb](07-betrieb.md). ## 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. ```css .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](09-suche-und-verwaltung.md). 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](03-aufnahme-pipeline.md). ## 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](10-projektansicht.md). ## 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](10-projektansicht.md). 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](09-suche-und-verwaltung.md). ### 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). ## 2026-07-30 (nachmittags) — v2 machte den Uplink stumm, v3 behebt es **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 ` 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.…: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 ``, 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).