Files
Aime/docs/03-aufnahme-pipeline.md
T
SaschaandClaude Opus 5 95fab7b9e4 GUI: Hinweis zum A76-Takt-Cap aus dem Transkriptions-Wartetext entfernt
Der Wartetext bei status=transcribing lautet jetzt nur noch
"Transkription läuft… lange Aufnahmen dauern." Der Klammerzusatz
"(A76-Takt gegen Überhitzung gedrosselt)" ist auf Wunsch des Nutzers
weg; das Takt-Cap selbst bleibt unverändert aktiv.

Doku: Changelog-Eintrag 2026-09-09 + Notiz im Zustandsmodell (Kap. 03),
inklusive Build-GOTCHA (dist/assets gehörte root -> vite EACCES).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:23:37 +02:00

262 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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->>REC: parecord beenden, WAV nach recordings/ verschieben
REC-)REC: encodeMp3() — Hintergrund, kein await
REC->>DB: WAV-Zeile (status=transcribing)
SRV->>DB: addMarkers() → Tabelle markers
Note over REC: kehrt in ~0,04 s zurück — GUI frei
GUI->>GUI: Kontextbox klappt auf (Freitext, seit 2026-08-17)
GUI->>SRV: PATCH /api/recordings/:id/context {context_note}
SRV->>DB: setContextNote() → recordings.context_note
Note over GUI,DB: Die Transkription läuft noch Minuten —<br/>der getippte Kontext ist rechtzeitig da (Kap. 05)
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,<br/>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: getContextNote() — Notiz erst JETZT gelesen (Kap. 05)
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.
- **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“).
- **GUI-Wartetext bei `transcribing`:** „📝 Transkription läuft… lange Aufnahmen dauern.“
(`web/src/main.js:1378`). Seit 2026-09-09 ohne den Klammerzusatz zum Takt-Cap — auf Wunsch des Nutzers;
das Cap selbst ist unverändert aktiv.
## Der Stop-Knopf: sofortige Antwort, MP3 im Hintergrund
Bis 2026-08-20 hing `POST /api/record/stop` so lange, wie der MP3-Encode dauerte — **gemessen ~1,4 s pro
aufgenommener Minute** (aus `wav_file`-Zeitstempel + `duration_ms` gegen `created_at`):
| Aufnahme | Dauer | Stop-Antwort |
|---|---|---|
| #114 | 35,5 min | 50,9 s |
| #118 | 30,8 min | 43,8 s |
| #111 | 20,8 min | 30,4 s |
| #117 | 1,1 min | 2,0 s |
Sichtbar war in dieser Zeit **nichts**: die GUI grante nur den Knopf aus (`opacity .55`) und stoppte die
Uhr. Der Nutzer hielt die Anwendung für hängengeblieben, wechselte den Reiter — was `render()` auslöst und
den Knopf frisch (also klickbar) aufbaut, obwohl `state.recording` noch `true` war — und drückte ein
zweites Mal Stop. Der zweite Stop lief zwangsläufig in `not recording`, weil `stopAndSave()` das
`current`-Objekt schon in seiner ersten Zeile auf `null` setzt.
```mermaid
sequenceDiagram
autonumber
participant U as Nutzer
participant GUI as Web-GUI
participant SRV as server.js
participant FF as ffmpeg (Hintergrund)
rect rgba(229,89,94,.12)
Note over U,SRV: VORHER — Encode blockierte die Antwort
U->>GUI: Klick Stop
GUI->>SRV: POST /api/record/stop
Note over GUI: Knopf grau, Uhr steht,<br/>kein Toast (bis 51 s)
U->>GUI: Reiterwechsel → render() → Knopf wieder aktiv
U->>GUI: Klick Stop (zum zweiten Mal)
GUI->>SRV: POST /api/record/stop
SRV--)GUI: 500 „not recording"
SRV--)GUI: (erste Antwort trifft jetzt ein)
end
rect rgba(126,182,124,.12)
Note over U,FF: NACHHER — Antwort in 0,04 s
U->>GUI: Klick Stop
GUI->>GUI: state.stopping = true → ⏳, gesperrt,<br/>Toast mit geschätzter Wartezeit
GUI->>SRV: POST /api/record/stop
SRV->>SRV: parecord beenden, WAV verschieben, DB-Zeile
SRV-)FF: encodeMp3() ohne await
SRV--)GUI: {id, …} nach ~0,04 s
FF-->>FF: rec_*.mp3.part → rename → rec_*.mp3
GUI->>GUI: Player lädt alle 5 s nach, solange /media 404 liefert
end
```
Drei Teile hat der Fix:
1. **`recorder.js stopAndSave()`** verschiebt erst das WAV nach `recordings/` (das ist die Arbeitskopie für
Whisper) und ruft `encodeMp3()` dann **ohne `await`** auf. Geschrieben wird nach `<name>.mp3.part`,
danach `fs.renameSync` — so bekommt der Browser nie eine halbe Datei zu hören, sondern bis zum
Umbenennen ein 404. **`-f mp3` ist dabei Pflicht:** ffmpeg leitet den Muxer aus der Dateiendung ab und
scheitert an `.part` mit `Error initializing the muxer … Invalid argument` (erst so aufgefallen —
beide Encode-Zweige fielen still aus, das MP3 fehlte ganz).
2. **`state.stopping` in der GUI** überlebt jeden `render()`. Der Knopf bleibt darum auch nach einem
Reiterwechsel gesperrt (zeigt ⏳), die Uhr friert ein, die Live-Karte in der Liste sagt „Aufnahme wird
gespeichert…", und ein Toast nennt die geschätzte Wartezeit. Ein zweiter Klick wird verworfen; kommt
trotzdem ein `not recording` zurück (zweites Browserfenster, F5 mitten im Speichern), meldet die GUI
sachlich „Aufnahme war bereits gestoppt" statt eines roten Fehlers.
3. **Der Player lädt nach:** `#mp3wait` zeigt „Wiedergabe-Datei wird noch erzeugt", das `error`-Ereignis
des `<audio>`-Tags ruft alle 5 s `audio.load()` (max. 5 min). `sweepOrphans` kennt jetzt auch
`rec_*.mp3.part`, damit eine beim Absturz liegengebliebene Halbdatei nach der Altersgrenze wegfällt.
Nachgemessen nach dem Umbau: Stop antwortet in **0,033–0,044 s** (12- und 15-s-Testclips), das MP3 stand
~2 s später mit 64 kbit CBR, 48 kHz, mono und ohne Xing-Tabelle da — die Anforderungen des nächsten
Abschnitts bleiben also erfüllt.
## 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 -f mp3
```
(`-f mp3` kam mit dem Hintergrund-Encode dazu, weil die Zieldatei `<name>.mp3.part` heißt — siehe
vorherigen Abschnitt.)
**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<br/>audio.currentTime = 1240"] --> B{Wie findet der<br/>Browser das Byte?}
B -->|"VBR: Xing-Tabelle<br/>100 Stützstellen ≈ 38 s Raster"| C["landet bei 20:11,5<br/>meldet aber 20:40"]
B -->|"CBR ohne Tabelle:<br/>Byte = Zeit × Bitrate"| D["landet bei 20:40,0<br/>Fehler 0,02 s"]
C --> E["Ton hinkt 28 s nach,<br/>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()<br/>GET /api/health"]
B --> C{"recording?"}
C -- nein --> D["Recorder zeigt ●, 00:00 — korrekt"]
C -- ja --> E["state.recording = true<br/>recStart = startedAt (Server-Epoche)<br/>markers = Server-Marker"]
E --> F["render(): ■ Stop, laufende Zeit,<br/>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/api/record/stop` (speichert regulär und startet die Transkription).
🕳️ **Bekannte Lücke in die andere Richtung (Stand 2026-08-26, nicht gefixt):** der Abgleich passiert
**nur beim Laden**. Während „Aufnahme läuft" pollt die GUI `/api/health` nicht mehr, und die Uhr rechnet
rein lokal (`Date.now() - state.recStart`). Wird der Stop von einem anderen Gerät oder Tab ausgelöst,
zählt der verwaiste Reiter für immer weiter — am 26.08. stand so über Nacht eine „laufende" Aufnahme mit
`1280:34` im Kopfband, obwohl der Server seit dem Vortag nichts aufnahm. Verschärfend: `fmt()` hat kein
Stundenfeld, die Minuten laufen also unbegrenzt hoch und lesen sich wie eine reale Dauer. Vorschlag:
`recording` am bestehenden 5-s-`sysmon`-Tick mitliefern und `fmt()` um Stunden erweitern.
## 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 |
| `PATCH /api/recordings/:id/context` `{context_note}` | Kontextnotiz speichern (leer = löschen); Antwort `applies` = `auto` / `running` / `reanalyze` sagt, ob die laufende Verarbeitung sie noch erreicht |
| `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 |