From db3d946592a4799eca1ec2855a080e301c5b8e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 10:51:22 +0200 Subject: [PATCH] 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 --- README.md | 61 ++++++++++++++++++++++++ docs/01-hardware.md | 67 +++++++++++++++++++++++++++ docs/02-audio-topologie.md | 79 ++++++++++++++++++++++++++++++++ docs/03-aufnahme-pipeline.md | 89 ++++++++++++++++++++++++++++++++++++ docs/04-diarization.md | 62 +++++++++++++++++++++++++ docs/05-llm-analyse.md | 86 ++++++++++++++++++++++++++++++++++ docs/06-iphone-bt.md | 74 ++++++++++++++++++++++++++++++ docs/07-betrieb.md | 80 ++++++++++++++++++++++++++++++++ docs/08-changelog.md | 59 ++++++++++++++++++++++++ 9 files changed, 657 insertions(+) create mode 100644 README.md create mode 100644 docs/01-hardware.md create mode 100644 docs/02-audio-topologie.md create mode 100644 docs/03-aufnahme-pipeline.md create mode 100644 docs/04-diarization.md create mode 100644 docs/05-llm-analyse.md create mode 100644 docs/06-iphone-bt.md create mode 100644 docs/07-betrieb.md create mode 100644 docs/08-changelog.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..731a77d --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# CallRec — Dokumentation + +Selbst gehostete Anruf-Aufnahme- und Analyse-Appliance auf einem **Youyeetoo R1 (RK3588S)** Einplatinenrechner. +Nimmt Gespräche auf (PC/Teams über USB-Gadget **und** iPhone über Bluetooth-HFP), transkribiert mit Whisper +(Encoder auf der NPU), trennt Sprecher akustisch (Diarization + Stimm-Profile) und erzeugt eine strukturierte +KI-Zusammenfassung (Gemma-3-4B auf der NPU) — **komplett offline**, keine Cloud. + +## Inhaltsverzeichnis + +| Dokument | Inhalt | +|---|---| +| [01 – Hardware](docs/01-hardware.md) | Board, Audio-Hardware, USB-Gadget, Thermik, NPU | +| [02 – Audio-Topologie](docs/02-audio-topologie.md) | PipeWire-Graph: callmix, Loopbacks, Noise-Gate, Volume-Bridges | +| [03 – Aufnahme-Pipeline](docs/03-aufnahme-pipeline.md) | Aufnahme → Transkription → Diarization → LLM → Datenbank | +| [04 – Sprechertrennung](docs/04-diarization.md) | Diarization, Anchor-Merge-Konsolidierung, Stimm-Profile | +| [05 – LLM-Analyse](docs/05-llm-analyse.md) | Gemma-3-4B/NPU, Analyse-Pfade, strukturierte Zusammenfassung, Glossar | +| [06 – iPhone-Bluetooth](docs/06-iphone-bt.md) | HFP-Pfad, Routing-Watcher, Echo-Fix | +| [07 – Betrieb](docs/07-betrieb.md) | Services, Zugänge, Backups, Troubleshooting | +| [08 – Changelog](docs/08-changelog.md) | Chronik aller Entwicklungsschritte | + +## System-Überblick + +```mermaid +flowchart LR + subgraph Quellen + PC["Windows-PC
(Teams, Medien)"] + IPHONE["iPhone
(Telefonate)"] + USER(("Nutzer
Logi H390 Headset")) + end + + subgraph R1["Youyeetoo R1 (RK3588S, Armbian, lüfterlos)"] + PW["PipeWire
Audio-Routing
(callmix + micgate)"] + APP["CallRec Web-App
Node.js :3000"] + WH["Whisper large-v3-turbo
Encoder auf NPU"] + DIAR["Diarization
sherpa-onnx + TitaNet"] + LLM["Gemma-3-4B
rkllm auf NPU"] + DB[("MariaDB
callrec")] + end + + PC -- "USB-C (UAC2-Gadget)" --> PW + IPHONE -- "Bluetooth HFP (AX210)" --> PW + USER -- "USB-A" --> PW + PW -- "callrec_mix" --> APP + APP --> WH --> DIAR --> LLM --> DB + APP --- DB +``` + +## Quick Facts + +- **Board:** `192.168.1.200` (WLAN, primär) / `192.168.1.50` (LAN), Login `root` +- **Web-GUI:** `http://192.168.1.200:3000` — Aufnahme, Transkript, Sprecher-Editor, Glossar +- **App-Verzeichnis:** `/opt/callrec` (Node.js, systemd `callrec.service`) +- **Harte Anforderungen:** immer offline (kein Cloud-LLM), akustische Sprecher-ID, kein aktiver Lüfter +- Details und alle Zugangsdaten: [07 – Betrieb](docs/07-betrieb.md) + +## Doku-Regeln + +- Diese Doku ist die **verbindliche Ablage** für alle zukünftigen Änderungen am System. +- Jede Änderung: betroffenes Kapitel aktualisieren **und** Eintrag in [08 – Changelog](docs/08-changelog.md). +- Prozesse/Abläufe immer mit **Mermaid-Diagramm** dokumentieren (Gitea rendert sie direkt). +- Auf dem Board bleiben `.bak-*`-Dateien als Rollback-Punkte; Meilenstein-Snapshots unter `/opt/callrec/milestones/`. diff --git a/docs/01-hardware.md b/docs/01-hardware.md new file mode 100644 index 0000000..dd5f251 --- /dev/null +++ b/docs/01-hardware.md @@ -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
(Board erscheint am PC als Headset)"] + H390["USB-A: Logitech H390 Headset
ALSA card3, 046d:0a8f"] + ES(("ES8323 3,5mm
DEFEKT")) + AX["AX210 Bluetooth
'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
2256 → 1608 MHz
(aime-cpucap.sh, sudoers NOPASSWD)"] + CAP --> WORK["Whisper + Diarization
(~72 °C statt >90 °C)"] + WORK --> GUARD{"Temp > 90 °C?"} + GUARD -- ja --> STOPP["SIGSTOP Whisper
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. diff --git a/docs/02-audio-topologie.md b/docs/02-audio-topologie.md new file mode 100644 index 0000000..5bb9aff --- /dev/null +++ b/docs/02-audio-topologie.md @@ -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)
alsa_input.platform-fc000000.usb"] + MIC["Headset-Mikrofon H390
alsa_input.usb-Logi...mono-fallback"] + HS["Headset-Hörer H390
alsa_output.usb-Logi...analog-stereo"] + GADGET["Gadget-Sink (Board→PC)
alsa_output.platform-fc000000.usb"] + GATE["micgate
LADSPA Noise-Gate
(swh gate_1410, Thr −45 dB)"] + MIX[("callmix
Null-Sink")] + CRMIX["callrec_mix ('CallRecMix')
remap von callmix.monitor"] + APP["CallRec-App
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
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
= 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. diff --git a/docs/03-aufnahme-pipeline.md b/docs/03-aufnahme-pipeline.md new file mode 100644 index 0000000..87d2315 --- /dev/null +++ b/docs/03-aufnahme-pipeline.md @@ -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?
(SHORT_SEC)"} + B -- ja --> C["Warm-Daemon transcribe_daemon_cpu.py
CPU-only, koexistiert mit residentem rkllm
kein Modell-Swap → 10-s-Clip in ~53 s statt 120 s"] + B -- nein --> D["transcribe_cli: faster-whisper large-v3-turbo
Encoder auf NPU, Decoder CPU, batch=8
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 | diff --git a/docs/04-diarization.md b/docs/04-diarization.md new file mode 100644 index 0000000..5168901 --- /dev/null +++ b/docs/04-diarization.md @@ -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
liefert VIELE Roh-Cluster
(z. B. konstant 27 bei einem 3-Personen-Call — deterministisch)"] + SHERPA --> EMB["Jedes Cluster neu einbetten
TitaNet-Embedding (spk.embed)"] + EMB --> SORT["Cluster nach Länge sortieren
(längste zuerst = stabile Reihenfolge)"] + SORT --> ANCHOR{"Cluster ≥ 3 s?
(DIAR_MIN_SPEAKER_SEC)"} + ANCHOR -- "ja (Anchor)" --> JOIN{"Kosinus ≥ 0,5 zu
bestehendem Sprecher?
(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
(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():
Abgleich gegen gespeicherte Voiceprints"] + AUTOID --> MERGE2["mergeSpeakerNames():
füllt nur Lücken, manuelle Namen gewinnen"] + GUI["GUI Sprecher-Editor:
Name eintragen + 'lernen'"] --> API["POST /speaker {speaker, name, enroll}"] + API --> VP[("voiceprints-Tabelle
1 Zentroid pro Name
(laufender Mittelwert, Spalte n)")] + API --> NAMES[("recordings.speaker_names
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. diff --git a/docs/05-llm-analyse.md b/docs/05-llm-analyse.md new file mode 100644 index 0000000..9d791a4 --- /dev/null +++ b/docs/05-llm-analyse.md @@ -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:
EIN Call = bereinigter Dialog + JSON-Analyse
(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"] + A -- nein --> B{"≤ 20 000 Zeichen?"} + B -- ja --> WHOLE["analyzeWhole:
GANZES Gespräch in einem Analyse-Call
(kohärente Summary, kleine JSON-Ausgabe)"] + B -- nein --> LONG["analyzeLong (Map-Reduce):
Chunks à 8 000 Zeichen"] + LONG --> MAP["MAP: pro Chunk Mini-JSON
(zusammenfassung, teilnehmer, todos)
bewusst minimaler Prompt — lange Regelwände
kippen Gemma in englische Prosa"] + MAP --> SALV{"JSON parsebar?"} + SALV -- ja --> COLLECT["einsammeln"] + SALV -- nein --> PROSE["Prosa retten:
Code-Fences UND rohe {...}-Blobs entfernen
(JSON-Leak-Fix), dann als Teil-Summary nutzen"] + PROSE --> COLLECT + COLLECT --> REDUCE["REDUCE: Teil-Summaries (je ≤ 800 Zeichen gekappt)
→ EIN JSON: titel, zusammenfassung,
kernpunkte[], entscheidungen[]
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: +• +• + +Entscheidungen: +• +``` + +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
GUI-Tab 'Glossar', /api/glossary")] --> Q{"Eintrag hat
Soundalikes?"} + Q -- ja --> DET["DETERMINISTISCH: Ganzwort-Ersetzung
direkt auf rec.words VOR der Analyse
(applyGlossaryText / glossaryMap)"] + Q -- nein --> HINT["nur als Hinweis in den LLM-Prompt
(glossaryHint)"] + DET --> LLM2["LLM sieht schon den korrekten Begriff"] + LLM2 --> BELT["Gürtel+Hosenträger: Ersetzung nochmal
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. diff --git a/docs/06-iphone-bt.md b/docs/06-iphone-bt.md new file mode 100644 index 0000000..828075f --- /dev/null +++ b/docs/06-iphone-bt.md @@ -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
= Gegenseite"] + UP["bluez_output.MAC.1
= 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. diff --git a/docs/07-betrieb.md b/docs/07-betrieb.md new file mode 100644 index 0000000..43cabb4 --- /dev/null +++ b/docs/07-betrieb.md @@ -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
(pscp vom Board holen)"] --> BAK["Backup auf Board:
cp X X.bak-<thema>"] + BAK --> UP["pscp hochladen"] + UP --> FE{"Frontend
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,
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). diff --git a/docs/08-changelog.md b/docs/08-changelog.md new file mode 100644 index 0000000..f781b01 --- /dev/null +++ b/docs/08-changelog.md @@ -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).