Die Unit startet Node als User=callrec (uid 1000) und konnte darum keinen privilegierten Port binden - daher bisher :3000. Mit AmbientCapabilities=CAP_NET_BIND_SERVICE bindet der unprivilegierte Prozess jetzt Port 80 direkt, ohne Reverse-Proxy (nginx haette 20-40 MB RAM gekostet, die neben dem residenten rkllm nicht frei sind). Kein Code-Eingriff noetig: server.js liest process.env.PORT, das Frontend nutzt relative API-Pfade. :3000 ist bewusst tot, alte Lesezeichen muessen angepasst werden. Dazu: Doku-IPs auf aime.local/192.168.1.48 korrigiert (192.168.1.200 war veraltet) und die Geisteranzeige-Diagnose vom 26.08. in Kapitel 03 + Changelog festgehalten (verwaister Browser-Reiter zaehlt ewig weiter, fmt() ohne Stunden). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
14 KiB
Markdown
259 lines
14 KiB
Markdown
# 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“).
|
||
|
||
## 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 |
|