# 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: (während der Aufnahme) POST /api/record/marker {name} SRV->>REC: addMarker() → Namensmarker mit Offset im Puffer GUI->>SRV: Stop SRV->>REC: stopAndSave() REC->>DB: WAV + MP3 + Zeile (status=transcribing) SRV->>DB: addMarkers() → Tabelle markers 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(…, {markers}) [Timeout = Dauer × 2,5, min. 20 min] Note over DI: v2: Fenster-Embeddings + gelernte Schwelle,
Marker als must-/cannot-link (Kap. 04) DI-->>REC: Sprecher-Segmente REC->>DB: setTranscript (status=done) SRV->>SRV: applyMarkers(): Cluster benennen + Stimmen lernen 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. - **Takt-Cap gilt für ALLE drei schweren Pfade:** `transcribeWav`, `rediarize` UND seit 2026-07-16 auch `analyzeRecording` (LLM lief vorher ungedeckelt auf 95–97 °C, Kap. 05). - **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“). ## Wiedergabe-MP3: konstante Bitrate, keine Xing-Tabelle Die WAV ist die Arbeitskopie (Whisper, Diarization), die MP3 ist nur fürs Abspielen im Browser — `highpass=f=80` gegen Rumpeln, `loudnorm` für gleichmäßige Lautstärke. Beim Codec sind aber **zwei Einstellungen nicht verhandelbar**, sonst läuft die Wortmarkierung im Transkript aus dem Takt: ``` -codec:a libmp3lame -b:a 64k -ar 48000 -ac 1 -write_xing 0 ``` **Kein VBR** (früher `-qscale:a 3`) und **keine Xing-Tabelle**. Der Grund liegt im Sprung, nicht in der Wiedergabe: ```mermaid flowchart LR A["Klick auf 20:40
audio.currentTime = 1240"] --> B{Wie findet der
Browser das Byte?} B -->|"VBR: Xing-Tabelle
100 Stützstellen ≈ 38 s Raster"| C["landet bei 20:11,5
meldet aber 20:40"] B -->|"CBR ohne Tabelle:
Byte = Zeit × Bitrate"| D["landet bei 20:40,0
Fehler 0,02 s"] C --> E["Ton hinkt 28 s nach,
Markierung läuft voraus"] D --> F["synchron"] ``` Nachgemessen an #85 (63 min): ein Sprung auf 20:40 spielte in Wahrheit ab **20:11,5 (−28,5 s)** — genau das Bild, das der Nutzer meldete („markiert ist ein Wort vier Zeilen unter dem, was ich höre"). Die Wortzeitstempel selbst waren dabei korrekt; per Gegenprobe transkribierte Ausschnitte an t = 600/1240/3400 s enthielten exakt die Wörter, die die Datenbank dort führt. Die Tabelle muss auch bei CBR weg: sie speichert Byte-Positionen in 1/256 der Dateigröße, was bei einer Stunde Audio rund 15 s Rundung sind (gemessen: bis −6,4 s Restfehler). Ohne sie rechnet der Decoder linear — bei konstanter Bitrate exakt. 64 kbit CBR entspricht praktisch der bisherigen Durchschnittsrate (68 kbit); #85 schrumpfte von 32,3 auf 30,3 MB. Der Altbestand wurde einmalig mit `lab/recode-cbr.sh` umgeschrieben (Quelle: die WAV, wo noch vorhanden — sonst die MP3 selbst, dann ohne erneute Filter). ## Der Aufnahmezustand gehört dem Server (Reload-Sicherheit) Wer aufnimmt, weiß nur `recorder.js` — dort hält `current` den laufenden `parecord`-Prozess, den WAV-Pfad, den Startzeitpunkt und die Namensmarker. Die GUI ist reiner Betrachter: sie muss sich diesen Zustand beim Laden **holen**, denn ihr eigener `state.recording` ist nach jedem `F5`, Tab-Wechsel des Browsers oder auf einem zweiten Gerät wieder `false`. ```mermaid flowchart TB A["Seite lädt: render()"] --> B["rehydrateRecording()
GET /api/health"] B --> C{"recording?"} C -- nein --> D["Recorder zeigt ●, 00:00 — korrekt"] C -- ja --> E["state.recording = true
recStart = startedAt (Server-Epoche)
markers = Server-Marker"] E --> F["render(): ■ Stop, laufende Zeit,
Namensfeld, Marker-Chips, Live-Karte in der Liste"] ``` `GET /api/health` ist die einzige Quelle dafür und liefert genau das Nötige: ```json { "ok": true, "recording": true, "startedAt": 1786359542932, "source": "default", "markers": [ { "t_ms": 8401, "name": "Markus Ufer" } ] } ``` - `recStart` kommt als **Server-Epoche**; der Timer rechnet `Date.now() - recStart`. Epoch-Werte sind zeitzonenfrei (Board läuft auf UTC, Browser auf CEST) — nur eine echte Uhrabweichung würde verschieben. - Fehlschlag ist absichtlich still: kein Toast, die GUI startet dann einfach im Normalzustand. - Zweites Gerät bekommt dieselbe laufende Aufnahme angezeigt — der Zustand ist nicht mehr an einen Tab gebunden. ⚠️ **Warum das mehr als Kosmetik ist:** ohne den Abgleich malt das Kopfband nach dem Neuladen den Start-Knopf (●), der Stop-Knopf ist also unerreichbar, während `parecord` weiterschreibt. Ein Druck auf ● läuft in `recorder.js` gegen `if (current) throw new Error('already recording')` — es gibt keinen Datenverlust, aber auch keinen Weg mehr, die Aufnahme aus der GUI zu beenden. Notausgang ohne GUI bleibt `curl -X POST localhost:3000/api/record/stop` (speichert regulär und startet die Transkription). ## API-Endpunkte (Auszug) | Endpunkt | Zweck | |---|---| | `GET /api/health` | Läuft eine Aufnahme? (`recording`, `startedAt`, `source`, `markers`) — Grundlage der Reload-Wiederherstellung | | `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 |