docs: komplette CallRec-Dokumentation mit Mermaid-Diagrammen

Architektur, Hardware/Thermik, PipeWire-Audio-Topologie, Aufnahme-Pipeline,
Diarization (Anchor-Merge), LLM-Analyse (Gemma-3-4B/NPU), iPhone-BT-HFP,
Betrieb/Troubleshooting und Changelog-Chronik.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-16 10:51:22 +02:00
commit db3d946592
9 changed files with 657 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
# 01 — Hardware
## Board: Youyeetoo R1
| Komponente | Detail |
|---|---|
| SoC | Rockchip **RK3588S** — 4× Cortex-A76 (max 2,256 GHz) + 4× A55, **NPU 6 TOPS** (3 Kerne) |
| RAM | 8 GB |
| OS | Armbian, Kernel 6.1.115, auf SD + eMMC |
| Netz | WLAN `192.168.1.200` (primär, auto-join) / LAN `192.168.1.50` |
| Funk | **Intel AX210 M.2**: BT über USB (`hci0`, Name **"Aime"**) + WiFi über PCIe (`wlP4p65s0`). Alter RTL8821CU-USB-Dongle entfernt. |
| Kühlung | **Lüfterlos** (harte Anforderung). Lüfter am roten POWER-Port war unregelbar (Konstantspannung) und wurde entfernt; echter PWM-Header (FAN 2-Pin, hwmon7) bleibt ungenutzt. |
## Audio-Hardware
```mermaid
flowchart TB
subgraph PC["Windows-PC"]
TEAMS["Teams / Medien-Wiedergabe"]
end
subgraph BOARD["Youyeetoo R1"]
UAC2["USB-C: UAC2-Audio-Gadget<br/>(Board erscheint am PC als Headset)"]
H390["USB-A: Logitech H390 Headset<br/>ALSA card3, 046d:0a8f"]
ES(("ES8323 3,5mm<br/>DEFEKT"))
AX["AX210 Bluetooth<br/>'Aime' = BT-Headset fürs iPhone"]
end
IPHONE["iPhone 'Skynet16'"]
TEAMS <--> UAC2
IPHONE <-->|"HFP/eSCO (mSBC)"| AX
H390 --- BOARD
```
- **Logitech H390 USB-Headset** = Mikrofon + Hörer des Nutzers. Ersetzt den **defekten ES8323**-3,5mm-Pfad
(Mikrofonverstärkung stand fest auf −42,14 dB, Treiberdefekt; S16-Modus zusätzlich verbuggt → Umstieg 2026-07-07).
- **USB-C zum PC = UAC2-Gadget:** Der PC sieht das Board als USB-Headset. PC-Audio kommt als ALSA-Capture
`alsa_input.platform-fc000000.usb...` ins Board; Richtung Board→PC (`alsa_output.platform-fc000000.usb`) hört die Gegenseite den Nutzer.
- **Headset-Inline-Tasten** (Vol +/−, Mute) = HID-Events auf `/dev/input/event5` → Daemon `aime-headset-keys.service` → `pactl`-Sink-Volume.
- **Windows-Lautstärkeregler** wirkt nicht auf den Datenstrom, sondern kommt als UAC2-Mixer-Wert (`amixer -c UAC2Gadget numid=7`) →
`aime-volbridge-headset.sh` pollt und setzt die Headset-Sink-Lautstärke.
## NPU-Nutzung
| Aufgabe | Engine | Modell |
|---|---|---|
| Whisper-**Encoder** | RKNN (NPU) | `turbo-encoder-fp16.rknn` (selbst konvertiert, Kosinus-Ähnlichkeit 0,9998 zur CPU-Referenz) |
| LLM-Analyse | rkllm 1.3.0 (NPU) | `gemma3-4b-w8a8g128-16k.rkllm` |
| Diarization | CPU (sherpa-onnx) | bewusst CPU: RTF 0,194 ≈ NPU-Referenzwert |
## Thermik (lüfterlos!)
Whisper/Diarization auf allen A76-Kernen bei Volltakt → `bigcore critical temp` → **Hardware-Schutzabschaltung**
(zweimal passiert, Aufnahmen schienen „verschwunden“). Gegenmaßnahmen:
```mermaid
flowchart LR
START["transcribeWav() startet"] --> CAP["A76-Takt kappen<br/>2256 → 1608 MHz<br/>(aime-cpucap.sh, sudoers NOPASSWD)"]
CAP --> WORK["Whisper + Diarization<br/>(~72 °C statt >90 °C)"]
WORK --> GUARD{"Temp > 90 °C?"}
GUARD -- ja --> STOPP["SIGSTOP Whisper<br/>bis < 78 °C, dann SIGCONT"]
GUARD -- nein --> WORK
WORK --> FIN["finally: Takt zurück auf 2256 MHz"]
```
- Cap umschließt **die ganze** `transcribeWav` (Whisper **und** Diarization) — Absturz #2 kam, weil die Diarization nach Whisper wieder mit Volltakt lief.
- 1,8 GHz wurde getestet und **verworfen** (~90 °C, Guard-Flattern). 1,6 GHz ≈ 72 °C stabil.
- Seit Whisper-Encoder auf NPU: Transkription 90 °C → **59 °C**; Cap kostet nur noch ~7 % Gesamtzeit.
+79
View File
@@ -0,0 +1,79 @@
# 02 — Audio-Topologie (PipeWire)
Herzstück: virtueller Misch-Bus **`callmix`** (Null-Sink). Alles, was aufgenommen werden soll, wird per
Loopback dorthin geroutet; der Monitor von `callmix` wird als echte Quelle **`callrec_mix`** („CallRecMix“)
re-mappt — die wählt die Web-GUI als Aufnahmequelle.
## Gesamtgraph
```mermaid
flowchart LR
PCIN["PC-Audio (Capture des UAC2-Gadgets)<br/>alsa_input.platform-fc000000.usb"]
MIC["Headset-Mikrofon H390<br/>alsa_input.usb-Logi...mono-fallback"]
HS["Headset-Hörer H390<br/>alsa_output.usb-Logi...analog-stereo"]
GADGET["Gadget-Sink (Board→PC)<br/>alsa_output.platform-fc000000.usb"]
GATE["micgate<br/>LADSPA Noise-Gate<br/>(swh gate_1410, Thr −45 dB)"]
MIX[("callmix<br/>Null-Sink")]
CRMIX["callrec_mix ('CallRecMix')<br/>remap von callmix.monitor"]
APP["CallRec-App<br/>Aufnahme"]
PCIN -- "cr_pc2hs (100%)" --> HS
PCIN -- "cr_pc2mix (60% ≈ −13 dB)" --> MIX
MIC -- "cr_mic2mix (100%)" --> MIX
MIC -- "cr_mic2pc (100%)" --> GATE --> GADGET
MIX -.->|"monitor"| CRMIX --> APP
```
Vier Loopbacks, jeder per `media.name`-Tag identifizierbar (robust gegen Index-Verschiebung), Gains setzt
`si_vol()` im Setup-Skript `/opt/callrec/aime-usb-audio-setup.sh`:
| Loopback | Weg | Gain | Zweck |
|---|---|---|---|
| `cr_pc2hs` | PC → Headset | 100 % | Nutzer hört PC/Teams |
| `cr_pc2mix` | PC → callmix | **60 % (≈ −13 dB)** | Gegenseite in die Aufnahme (clip-sicher) |
| `cr_mic2mix` | Mikro → callmix | 100 % | Eigene Stimme in die Aufnahme |
| `cr_mic2pc` | Mikro → micgate → Gadget | 100 % | Gegenseite hört den Nutzer |
## Warum Noise-Gate (micgate)?
Teams-Gegenseite hörte sich selbst verzögert (Selbst-Echo). Nachgewiesen: **akustisches Übersprechen ins
Mikrofon** (nicht elektrisch, nicht Ohrmuschel — Mute-Tests), und Teams-AGC renormalisiert jede Pegelabsenkung.
Lösung: LADSPA-Gate im Mikro→PC-Pfad schließt in Sprechpausen → Gegenseite bekommt Stille → kein Echo.
AEC wurde getestet und **verworfen** (Knistern, kein Nutzen).
```mermaid
flowchart LR
M["Mikrofon<br/>HW-Gain fest Stufe 12 (+1,4 dB)"] --> G{"Pegel > −45 dB?"}
G -- "ja (Sprache)" --> OPEN["Gate offen → Signal zum PC"]
G -- "nein (Pause)" --> CLOSED["Gate zu → Stille zum PC<br/>= kein Selbst-Echo"]
```
**Gotchas (teuer bezahlt):**
- Filter-Chain ist ein **Core-Modul**, kein Pulse-Modul → nicht per `pactl load-module`, sondern Drop-in
`filter-chain.conf.d/micgate.conf` + eigener Dienst `aime-micgate.service`.
- PipeWire liefert einen eigenen preset-aktivierten `filter-chain.service` mit → **doppeltes** micgate →
`systemctl --user mask filter-chain.service`.
- Mikro-HW-Gain **nur** über `pactl set-source-volume $HSMIC 62%` setzen (= HW-Stufe 12). `amixer` direkt
wird von WirePlumber überschrieben; 100 % = +11 dB → Grundrauschen öffnet das Gate wieder.
- 2-Mono-Node-Stereo-Graph im Filter-Chain = kein Audio → 1 Mono-Node verwenden.
- `pkill -f "pipewire -c filter-chain.conf"` killt die eigene SSH-Session (exit 128) → `setsid` nutzen.
## Merksätze
- **PC-Audio = die Quelle `alsa_input.platform-fc000000.usb...`**, NICHT deren `.monitor` (der ist stumm —
hat einmal eine Stunde gekostet). Quellen vor Verwendung mit `parecord` + `ffmpeg astats` auf Signal prüfen.
- Die GUI filtert alle `*.monitor`-Quellen aus → deshalb der Remap auf `callrec_mix`.
- Gain-Staging ist clip-kritisch: Übersteuerung (0 dBFS) hat einmal die Diarization-Embeddings zerstört
→ 14 „Sprecher“ bei einem 2-Personen-Gespräch (Aufnahme #41, bleibt dauerhaft geclippt).
## Persistenz
| Einheit | Aufgabe |
|---|---|
| `aime-usb-audio.service` (user) | baut die komplette Topologie beim Boot (`aime-usb-audio-setup.sh`); wartet auf micgate, Fallback direkt auf Gadget-Sink |
| `aime-micgate.service` (user, enabled) | startet die micgate-Filter-Chain |
| `aime-volume-headset.service` (user) | Windows-Volume-Bridge (UAC2 numid=7 → Headset-Sink) |
| `aime-headset-keys.service` (root) | H390-Inline-Tasten → Lautstärke |
| alte `aime-audio-out` / `aime-volume` | ES8323-Ära, **disabled** |
Kaltstart 2× verifiziert: Topologie, Gate, Volume-Bridge und WLAN kommen ohne Eingriff hoch.
+89
View File
@@ -0,0 +1,89 @@
# 03 — Aufnahme-Pipeline
Vom Klick auf „Stop“ bis zur fertigen Analyse. Crash-sicher: Audio + DB-Zeile werden **vor** der langen
Verarbeitung persistiert; Transkription/Analyse laufen asynchron im Hintergrund (GUI pollt).
## Ablauf
```mermaid
sequenceDiagram
participant GUI as Web-GUI
participant SRV as server.js
participant REC as recorder.js
participant WH as Whisper (NPU-Encoder)
participant DI as diar.js (sherpa, CPU)
participant LLM as llm.js (Gemma, NPU)
participant DB as MariaDB
GUI->>SRV: Stop
SRV->>REC: stopAndSave()
REC->>DB: WAV + MP3 + Zeile (status=transcribing)
Note over REC: kehrt nach ~1,5 s zurück — GUI frei
REC->>REC: A76-Takt kappen (1,6 GHz)
REC->>WH: transcribeWav() [BatchedInferencePipeline, batch=8]
WH-->>REC: Wörter + Zeitstempel
REC->>DI: diarize() [Timeout = Dauer × 2,5, min. 20 min]
DI-->>REC: Sprecher-Segmente
REC->>DB: setTranscript (status=done)
REC->>REC: finally: Takt zurück
SRV->>LLM: analyzeRecording() (fire-and-forget, analysis_status=pending)
LLM->>DB: Titel/Summary/Teilnehmer/Todos (analysis_status=done, analysis_ms)
GUI->>SRV: Polling analysis_status
```
## Pfadwahl Transkription
```mermaid
flowchart TB
A["WAV fertig"] --> B{"Dauer ≤ 30 s?<br/>(SHORT_SEC)"}
B -- ja --> C["Warm-Daemon transcribe_daemon_cpu.py<br/>CPU-only, koexistiert mit residentem rkllm<br/>kein Modell-Swap → 10-s-Clip in ~53 s statt 120 s"]
B -- nein --> D["transcribe_cli: faster-whisper large-v3-turbo<br/>Encoder auf NPU, Decoder CPU, batch=8<br/>RTF ≈ 0,39"]
C --> E["Diarization"]
D --> E
```
- `TRANSCRIBER=npu` (Default) / `=cpu` als Fallback-Schalter in recorder.js.
- Whisper-Kalibrierung: medium beam1 hatte 11,3 % WER vs. large-v3 — beam5 schlechter, `--initial-prompt`
mit Begriffsliste **zerstört** den Output (Echo-Loop). Eigennamen-Korrektur passiert deshalb im Text-Layer (Glossar, Kap. 05).
## Zustandsmodell einer Aufnahme
```mermaid
stateDiagram-v2
[*] --> recording
recording --> transcribing: stopAndSave() — WAV/MP3/Zeile SOFORT persistiert
transcribing --> done_transcript: Transkript + Diarization gespeichert
transcribing --> failed: Absturz/Fehler → GUI zeigt Retry
failed --> transcribing: POST /retranscribe
done_transcript --> analyzing: analysis_status=pending
analyzing --> analyzed: analysis_status=done (analysis_ms gesetzt)
analyzing --> analysis_error: GUI-Button "Erneut versuchen"
analysis_error --> analyzing: POST /reanalyze bzw. /reanalyze-llm
```
- Hängende `transcribing`-Zeile = Absturz mitten in der Diarization (Transkript wird erst NACH Diar gespeichert)
→ kein Auto-Resume, GUI-Button „Neu transkribieren“.
- **NPU-Lock:** alle schweren Jobs (transcribe / analyze / re-diarize) laufen seriell durch `withNpuLock` —
zwei gleichzeitig fertig werdende Aufnahmen haben sich sonst gegenseitig zerlegt.
- **GUI-Anzeige „⏱ Analysezeit“:** `analysis_started_at`/`analysis_ms` in der DB; beim Polling auf
`analysis_status` achten, NICHT auf `status` (sonst wirkt die Summary „leer“).
## API-Endpunkte (Auszug)
| Endpunkt | Zweck |
|---|---|
| `POST /api/recordings/:id/retranscribe` | Hängende/fehlgeschlagene Transkription neu starten |
| `POST /api/recordings/:id/reanalyze` `{numSpeakers?}` | Diarization **immer** neu (leer/0 = automatisch, Zahl = Anzahl gepinnt) + LLM neu |
| `POST /api/recordings/:id/reanalyze-llm` | Nur LLM neu, Diarization/Wörter bleiben |
| `POST /api/recordings/:id/speaker` `{speaker,name,enroll}` | Sprecher benennen + optional Stimme lernen |
| `/api/glossary` (CRUD) | Eigennamen-Glossar |
## Performance-Kennzahlen (71-min-Referenz #69)
| Schritt | Wert |
|---|---|
| Transkription (NPU, batch=8) | RTF ≈ 0,39 |
| Diarization (CPU) | RTF ≈ 0,19 |
| Komplett E2E | RTF ≈ 0,9 |
| LLM-Analyse (Gemma, 10 Chunks + Reduce) | ~20 min |
| Kurzclip (10 s) komplett | ~53 s |
+62
View File
@@ -0,0 +1,62 @@
# 04 — Sprechertrennung (Diarization) & Stimm-Profile
**Anforderung:** akustische Sprecher-ID ist Pflicht (Raumspinne: 3–4 Personen auf einem BT-Kanal).
Sprecheranzahl soll **dynamisch** erkannt werden (Personen kommen/gehen) — kein festes Pinnen nötig.
## Pipeline
```mermaid
flowchart TB
WAV["Mono-WAV"] --> SHERPA["sherpa-onnx Clustering<br/>liefert VIELE Roh-Cluster<br/>(z. B. konstant 27 bei einem 3-Personen-Call — deterministisch)"]
SHERPA --> EMB["Jedes Cluster neu einbetten<br/>TitaNet-Embedding (spk.embed)"]
EMB --> SORT["Cluster nach Länge sortieren<br/>(längste zuerst = stabile Reihenfolge)"]
SORT --> ANCHOR{"Cluster ≥ 3 s?<br/>(DIAR_MIN_SPEAKER_SEC)"}
ANCHOR -- "ja (Anchor)" --> JOIN{"Kosinus ≥ 0,5 zu<br/>bestehendem Sprecher?<br/>(DIAR_MERGE_THRESHOLD)"}
JOIN -- ja --> MERGE["dem Sprecher zuordnen"]
JOIN -- nein --> NEW["neuen Sprecher eröffnen"]
ANCHOR -- "nein (kurz/Streu)" --> NEAREST["IMMER zum nächstliegenden Anchor<br/>(darf NIE neuen Sprecher eröffnen)"]
MERGE --> OUT["finale Sprecher (z. B. 27 → 3)"]
NEW --> OUT
NEAREST --> OUT
```
## Warum Greedy-Anchor-Merge (Geschichte der Fixes)
1. **Schwellwert-Tuning nutzlos:** Sweep auf 2-Personen-Clip: Threshold 0,7 → 12 Sprecher … 0,95 → 8. Nie 2.
2. **Union-Find (1. Konsolidierung) instabil:** paarweises Mergen bildet transitive Ketten (A~B~C zieht alles
zusammen). Ein Grenzfall-Link um Kosinus ≈ 0,5 kippte je nach FP-Rauschen (Multi-Thread-TitaNet unter
CPU-Last) → derselbe Call ergab 2 / 3 / 7 Sprecher in drei Läufen.
3. **Greedy-Anchor-Merge (aktuell, `diar.js consolidateSpeakers`):** nur lange Cluster (verlässliche
Embeddings) dürfen Sprecher eröffnen, Abarbeitung längste-zuerst (ordnungsstabil), kurze Cluster können nur
zugeordnet werden → Rauschen kann nichts mehr fragmentieren. Verifiziert: 5/5 Läufe konstant 27 → 3.
## Harte Grenzen / Sackgassen
- **Überlappendes gleichzeitiges Sprechen** ist mit Mono-Mix prinzipiell nicht trennbar.
- Geclipptes Audio (0 dBFS) zerstört Embeddings → Fantasie-Sprecher (deshalb Gain-Staging, Kap. 02).
- sherpa-Quantisierung q5_0: langsamer UND schlechter auf diesem aarch64-Build — tot.
- Diarization-Timeout skaliert mit Dauer (`diarTimeoutMs()` = Dauer × 2,5, min. 20 min) — der feste
10-min-Timeout hat 46-min-Aufnahmen abgebrochen. (`parse-options.cc:373`-Ausgabe ist harmloses argv-Echo.)
## Sprecher-Namen & Stimm-Profile (Enrollment)
```mermaid
flowchart LR
DIAR["Diarization: speaker_00, speaker_01, …"] --> AUTOID["identifySpeakers():<br/>Abgleich gegen gespeicherte Voiceprints"]
AUTOID --> MERGE2["mergeSpeakerNames():<br/>füllt nur Lücken, manuelle Namen gewinnen"]
GUI["GUI Sprecher-Editor:<br/>Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"]
API --> VP[("voiceprints-Tabelle<br/>1 Zentroid pro Name<br/>(laufender Mittelwert, Spalte n)")]
API --> NAMES[("recordings.speaker_names<br/>JSON, manuell gewinnt")]
VP --> AUTOID
```
- Beim Lernen wird der längste zusammenhängende Sprech-Abschnitt (≥ 1,5 s) des Sprechers als TitaNet-Embedding
in den Zentroid gefaltet (`db.upsertVoiceprint`) → zukünftige Aufnahmen identifizieren die Person automatisch.
- Teilnehmerliste der Analyse kommt seit 2026-07-15 **aus der Diarization** (Namen wo bekannt, sonst
„Sprecher N“) — nicht mehr vom LLM (das listete alle bloß *erwähnten* Namen, 11 statt 3). Siehe Kap. 05.
## Re-Diarization
- GUI „↻ Analyse neu“ → Prompt: leer = automatisch (konsolidiert), Zahl = Anzahl gepinnt
(`--clustering.num-clusters`). Läuft immer unter A76-Takt-Cap (lüfterlos!).
- `db.replaceWords()` tauscht nur die Sprecher-Zuordnung; Text/Zeitstempel bleiben.
+86
View File
@@ -0,0 +1,86 @@
# 05 — LLM-Analyse (Gemma-3-4B auf der NPU)
## Modell
| | |
|---|---|
| Modell | **Gemma-3-4B-it**, `gemma3-4b-w8a8g128-16k.rkllm` |
| Runtime | rkllm **1.3.0** (Upgrade von 1.2.1 — API- und Chat-Template-Änderungen) |
| Quantisierung | **grouped w8a8 (g128)** — plain w8a8 hat Gemma kaputt-quantisiert; w4a16 auf RK3588 nicht unterstützt |
| Kontext | 16k konvertiert, **läuft mit 8192** (KV-Cache-RAM auf 8-GB-Board) ≈ 20 000 Zeichen Prompt-Budget |
| Tempo | ~5 tok/s Decode → Kurzclip ~5 min, 71-min-Aufnahme ~20 min Analyse |
| Historie | Vorher Qwen2.5-3B (fest 4096 Kontext). Gemma ≈ Gemini-Qualität, aber ~3× langsamer. gemma3n-E4B zu groß (10,4 GB). |
**Hart:** niemals Online-LLM — alles lokal auf der NPU.
## Pfadwahl nach Transkriptlänge
```mermaid
flowchart TB
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
A -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
A -- nein --> B{"≤ 20 000 Zeichen?"}
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)"]
B -- nein --> LONG["analyzeLong (Map-Reduce):<br/>Chunks à 8 000 Zeichen"]
LONG --> MAP["MAP: pro Chunk Mini-JSON<br/>(zusammenfassung, teilnehmer, todos)<br/>bewusst minimaler Prompt — lange Regelwände<br/>kippen Gemma in englische Prosa"]
MAP --> SALV{"JSON parsebar?"}
SALV -- ja --> COLLECT["einsammeln"]
SALV -- nein --> PROSE["Prosa retten:<br/>Code-Fences UND rohe {...}-Blobs entfernen<br/>(JSON-Leak-Fix), dann als Teil-Summary nutzen"]
PROSE --> COLLECT
COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen gekappt)<br/>→ EIN JSON: titel, zusammenfassung,<br/>kernpunkte[], entscheidungen[]<br/>2 Parse-Versuche, Tail wird geloggt"]
REDUCE --> OUT["Ergebnis"]
SINGLE --> OUT
WHOLE --> OUT
```
## Strukturierte Zusammenfassung (seit 2026-07-15)
Das LLM liefert getrennte Felder; `composeSummary()` baut daraus den Summary-Text:
```
<Überblick, 2–4 Sätze>
Kernpunkte:
• <Punkt>
• <Punkt>
Entscheidungen:
• <Entscheidung>
```
GUI rendert mit `white-space: pre-line` (style.css) → Bullets und Absätze sichtbar. Todos bleiben eigenes
Feld/eigene Liste. **Teilnehmer kommen NICHT vom LLM**, sondern aus der Diarization (Kap. 04) — das LLM
hatte alle im Gespräch *erwähnten* Namen als Teilnehmer gelistet (11 statt 3 bei Aufnahme #69).
## Robustheit gegen Gemma-Marotten (alle real aufgetreten)
| Marotte | Gegenmaßnahme |
|---|---|
| Antwortet Prosa statt JSON bei großen Inputs | kleine Chunks (8 000), minimaler Chunk-Prompt, Reduce-Input gekappt + Retry, Prosa-Salvage |
| Kaputtes JSON leakt in die Summary | `{...}`-Blobs werden vor Salvage entfernt |
| Todos als Objekte (`{"aufgabe": …}`) statt Strings | `cleanList()` flacht Objekt-Werte zu Strings ab |
| `"null"` / `"[object Object]"` / lose `?` in Listen | `cleanList()` filtert/trimmt |
| Echot Feldbeschreibungen statt Inhalt | `echoed()`-Guard → separater JSON-Call |
| Echot konkrete Beispiele aus dem Prompt (Phantom-Todos, „Kompressor“-Summary) | Korrektur-Regeln bewusst OHNE konkrete Beispiele (`CORRECTION_HINT` abstrakt) |
| Erfindet Fantasie-Komposita („Audio-Dualeintragung“) | `NO_INVENT`-Regel in allen Summary-Prompts |
| Chunk-Parse-Fehler variieren pro Lauf (2–3 von 10) | nichtdeterministisch, wird per Salvage aufgefangen; bei schwachem Ergebnis einfach „Analyse neu“ |
## Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“)
```mermaid
flowchart LR
G[("glossary-Tabelle<br/>GUI-Tab 'Glossar', /api/glossary")] --> Q{"Eintrag hat<br/>Soundalikes?"}
Q -- ja --> DET["DETERMINISTISCH: Ganzwort-Ersetzung<br/>direkt auf rec.words VOR der Analyse<br/>(applyGlossaryText / glossaryMap)"]
Q -- nein --> HINT["nur als Hinweis in den LLM-Prompt<br/>(glossaryHint)"]
DET --> LLM2["LLM sieht schon den korrekten Begriff"]
LLM2 --> BELT["Gürtel+Hosenträger: Ersetzung nochmal<br/>auf Titel/Summary/Todos des LLM-Outputs"]
```
Wichtig: Begriffslisten **niemals** als Whisper-`--initial-prompt` (Echo-Loop, verifiziert) und dem 3–4B-Modell
keine „ersetze X durch Y“-Anweisung anvertrauen (unzuverlässig) — deshalb die deterministische Text-Ersetzung.
## Zusammenspiel mit Whisper auf der NPU
rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich
als `rc=-6`). Kurzclips (≤ 30 s) nutzen deshalb den CPU-Whisper-Daemon ohne rkllm-Swap; lange Aufnahmen
swappen seriell unter dem NPU-Lock.
+74
View File
@@ -0,0 +1,74 @@
# 06 — iPhone-Telefonate über Bluetooth (HFP)
Das Board gibt sich gegenüber dem iPhone als **Bluetooth-Headset** aus („Aime“, AX210/`hci0`). iPhone-Anrufe
laufen dadurch über das Board: Gegenseite → Aufnahme-Mix, Nutzer-Mikrofon (gegated) → iPhone-Uplink.
Läuft **parallel** zum PC/USB-Gadget-Pfad — kein Umschalten; bei eingehendem Anruf pausiert der Nutzer sein
PC-Video manuell.
## Konfiguration (die funktionierende Kombination)
- WirePlumber-Drop-in: `bluez5.roles = [a2dp_sink hfp_hf hsp_hs]` + `hfphsp-backend = native`
(oFono war ein Irrweg, deaktiviert).
- **Frischer Pair nötig** (auf dem iPhone „Gerät ignorieren“, dann neu koppeln) — alte Bonds machen Ärger.
- iPhone „Skynet16“ (B4:96…), bt-agent NoInputNoOutput, Gerät muss **Trusted=yes** sein
(nach Re-Pair stand es auf NO → WirePlumber baut für untrusted Geräte keine HFP-Nodes → Aufnahme #58
hatte nur die Nahseite).
- Einziger Verzicht: BT-Ohrhörer als Aufnahmegerät (AG-Rolle) ist aus — Board ist HF-only.
- `profile="off"` zwischen Anrufen ist **normal**: HFP-Nodes existieren nur während eines aktiven Anrufs
(eSCO/mSBC, 16 kHz).
## Audio-Routing während eines Anrufs
```mermaid
flowchart LR
subgraph IPHONE["iPhone (im Anruf)"]
CALL["Telefonat"]
end
subgraph BOARD["Board (BT 'Aime')"]
FAR["bluez_input.MAC.0<br/>= Gegenseite"]
UP["bluez_output.MAC.1<br/>= Uplink zum iPhone"]
MIX[("callmix")]
GATE["micgate.out (gegatetes Mikro)"]
HS["Headset-Hörer"]
end
CALL --> FAR
FAR --> MIX
FAR --> HS
GATE --> UP --> CALL
```
**Echo-Fix:** WirePlumber verlinkte den Uplink zunächst mit `callrec_mix` (ungegatetes Mikro + Gegenseite
selbst!) → Gegenseite hörte sich selbst. Korrekt: Uplink ← **micgate.out**. Vom Nutzer bestätigt echofrei.
## Automatik
```mermaid
sequenceDiagram
participant IP as iPhone
participant BZ as BlueZ/WirePlumber
participant W as aime-iphone-route.sh (Watcher)
participant PW as PipeWire
IP->>BZ: Anruf beginnt (eSCO aufgebaut)
BZ->>PW: HFP-Nodes erscheinen (bluez_input/.0, bluez_output/.1)
W->>PW: erkennt neue Nodes
W->>PW: Gegenseite → callmix + Headset verlinken
W->>PW: Uplink ← micgate.out verlinken (Echo-Fix)
IP->>BZ: Anruf endet
BZ->>PW: HFP-Nodes verschwinden (profile=off — normal)
```
- Watcher persistent: `/opt/callrec/aime-iphone-route.sh` + `aime-iphone-route.service`; überlebt Reboots
(Config/Service/Bond+Trusted verifiziert).
- Der Watcher-Neustart lässt callmix/Loopbacks intakt.
## Offen
- App-seitiger automatischer Aufnahme-Start bei iPhone-Anrufen.
- Voller E2E-Test „Anruf → Aufnahme → Analyse“ über den BT-Pfad.
## Referenz: BT-Bond-Quirks (Ohrhörer, historisch)
Buds mit No-Bonding-Quirk: LinkKey musste manuell in `/var/lib/bluetooth` persistiert werden
(btmon-Capture-Prozedur). RTL8821CU-Wedge-Recovery (USB-Reset, nur über LAN .50) — betrifft den alten
Dongle, der entfernt wurde; nur noch relevant, falls die Hardware zurückkommt.
+80
View File
@@ -0,0 +1,80 @@
# 07 — Betrieb
> ⚠️ Dieses Repo ist **privat**; hier stehen LAN-Zugangsdaten. Nicht öffentlich stellen.
## Zugänge
| System | Zugang |
|---|---|
| Board SSH | `root@192.168.1.200` (WLAN, primär) / `root@192.168.1.50` (LAN), PW `123456` |
| Web-GUI | `http://192.168.1.200:3000` |
| MariaDB (auf dem Board) | DB `callrec`, User `callrec`, PW `callrec123` (kein `sqlite3`/CLI-Client — `mysql` nutzen) |
| Gitea (diese Doku) | `http://192.168.1.199:3000`, User `sascha` |
| App-User auf dem Board | `callrec`, Code in `/opt/callrec` |
## systemd-Einheiten
| Einheit | Scope | Aufgabe |
|---|---|---|
| `callrec.service` | system | Node-App (Web-GUI :3000, Aufnahme, Pipeline) |
| `callrec-btwatch.service` | system | BT-Headset-Auto-Connect (card-aware) |
| `callrec-perf.service` | system | CPU-Governor `performance` + A76-Pinning |
| `aime-usb-audio.service` | user | PipeWire-Topologie beim Boot (callmix, Loopbacks, Remap) |
| `aime-micgate.service` | user | LADSPA-Noise-Gate-Filter-Chain |
| `aime-volume-headset.service` | user | Windows-Volume → Headset-Sink (UAC2 numid=7) |
| `aime-headset-keys.service` | root | H390-Inline-Tasten → Lautstärke |
| `aime-iphone-route.service` | — | HFP-Routing-Watcher für iPhone-Anrufe |
| `filter-chain.service` (PipeWire-Standard) | user | **MASKIERT** — würde micgate doppeln |
| `aime-audio-out` / `aime-volume` | user | ES8323-Ära, **disabled** |
## Wichtige Pfade auf dem Board
| Pfad | Inhalt |
|---|---|
| `/opt/callrec/*.js` | server / recorder / llm / diar / spk / db / bt |
| `/opt/callrec/web/` | Vite-Frontend (`npx vite build` nach Änderungen an `src/`) |
| `/opt/callrec/aime-*.sh` | Setup-/Bridge-/Cap-Skripte |
| `/opt/callrec/milestones/m1-20260709/` | Meilenstein-1-Snapshot inkl. MANIFEST.md (Rollback) |
| `/opt/callrec/*.bak-*` | Datei-Backups je Änderung (z. B. `.bak-structsum`, `.bak-anchor`) |
| `/etc/sudoers.d/callrec-cpucap` | NOPASSWD für `aime-cpucap.sh` (Thermal-Cap) |
## Deploy-Ablauf (von Windows aus)
```mermaid
flowchart LR
DEV["Datei lokal ändern<br/>(pscp vom Board holen)"] --> BAK["Backup auf Board:<br/>cp X X.bak-&lt;thema&gt;"]
BAK --> UP["pscp hochladen"]
UP --> FE{"Frontend<br/>geändert?"}
FE -- ja --> BUILD["ssh: cd /opt/callrec/web && npx vite build"]
FE -- nein --> RESTART
BUILD --> RESTART["systemctl restart callrec"]
RESTART --> VERIFY["Verifizieren: journalctl -u callrec,<br/>GUI-Test, ggf. reanalyze-llm"]
VERIFY --> DOC["Doku + Changelog in Gitea aktualisieren"]
```
- Windows-Seite: PuTTY-Tools (`plink -batch -pw …`, `pscp`) — OpenSSH-Keys sind nicht hinterlegt.
- Node-Syntax vor Upload prüfen: `node --check datei.js`.
## Troubleshooting
| Symptom | Ursache / Fix |
|---|---|
| Zeile hängt auf `transcribing` | Absturz mitten in Diarization → GUI „Neu transkribieren“ (`/retranscribe`) |
| Summary wirkt leer nach Retranskription | GUI muss `analysis_status` pollen, nicht `status` — Analyse ist fire-and-forget |
| Board plötzlich aus | Thermik prüfen — Schutzabschaltung; Cap-Skript/Sudoers intakt? (Kap. 01) |
| Zu viele Sprecher | Audio geclippt? (Gain-Staging Kap. 02) Sonst „Analyse neu“ mit gepinnter Zahl |
| Teilnehmer ≠ Sprecher | Teilnehmer kommen aus Diarization; Namen im Sprecher-Editor vergeben |
| Kein far-end bei iPhone-Aufnahme | BT-Gerät `Trusted=yes`? HFP-Nodes erscheinen nur IM Anruf (Kap. 06) |
| GUI zeigt Quelle ohne Signal | `.monitor`-Falle — Quelle mit `parecord` + `ffmpeg astats` prüfen (Kap. 02) |
| `pactl load-module module-filter-chain` → „No such entity“ | Filter-Chain ist Core-Modul → Drop-in-Config nutzen (Kap. 02) |
| rkllm/Whisper `rc=-6` | RAM-Contention — nicht beide NPU-resident; NPU-Lock beachten (Kap. 05) |
| Analyse „KI Fehler“ bei langen Aufnahmen | Kontext-Überlauf → Chunk-Pfad greift; bei schwachem Ergebnis „Analyse neu“ |
## Offene Punkte (Stand 2026-07-16)
- Bedingter Takt-Cap (nur > 15 min Aufnahmedauer) — angeboten, nicht gebaut (~1,4× schnellere Kurzclips).
- Feinabstimmung Mikro/PC-Balance in einem echten Headset-Call.
- Glossar Phase 2: nächtlicher systemd-Timer + large-v3-Kandidaten-Vorschläge mit Bestätigung (verschoben).
- iPhone-Pfad: automatischer Aufnahme-Start + voller E2E-Test (Kap. 06).
- Qwen-1.5B als schnellerer LLM-Kandidat für Kurzclips (verschoben).
- Aufnahme #41 bleibt dauerhaft geclippt (Referenz für den Gain-Bug).
+59
View File
@@ -0,0 +1,59 @@
# 08 — Changelog
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
## 2026-07-16
- **Doku-Repo angelegt** (dieses Repo, Gitea `callrec-docs`). 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).