Neuer Helfer recStamp(r) formatiert created_at als "Di., 29.09.2026, 14:23 Uhr" und zeigt die Zeile in der .status-line vor der Analysezeit. Wer aus der Inhaltssuche, einem Projekt-Pin oder per Direktlink in die Detailansicht springt, hat in der linken Liste keine markierte Zeile und sah den Aufnahmezeitpunkt bisher nirgends. Keine Server-/DB-Änderung: /api/recordings/:id liefert created_at bereits mit. Doku: Changelog 2026-09-29 + Notiz in Kapitel 03. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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
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
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) /=cpuals Fallback-Schalter in recorder.js.- Whisper-Kalibrierung: medium beam1 hatte 11,3 % WER vs. large-v3 — beam5 schlechter,
--initial-promptmit Begriffsliste zerstört den Output (Echo-Loop). Eigennamen-Korrektur passiert deshalb im Text-Layer (Glossar, Kap. 05).
Zustandsmodell einer Aufnahme
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,rediarizeUND seit 2026-07-16 auchanalyzeRecording(LLM lief vorher ungedeckelt auf 95–97 °C, Kap. 05). - GUI-Anzeige „⏱ Analysezeit“:
analysis_started_at/analysis_msin der DB; beim Polling aufanalysis_statusachten, NICHT aufstatus(sonst wirkt die Summary „leer“). - Kopf der Detailansicht:
🗓 <Wochentag, Datum, Uhrzeit>(HelferrecStamp(r)auscreated_at) steht seit 2026-09-29 vor der Analysezeit — aus Suche, Projekt-Pin oder Direktlink heraus ist links keine Listenzeile markiert, der Aufnahmezeitpunkt wäre sonst nirgends sichtbar. - 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.
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:
recorder.js stopAndSave()verschiebt erst das WAV nachrecordings/(das ist die Arbeitskopie für Whisper) und ruftencodeMp3()dann ohneawaitauf. Geschrieben wird nach<name>.mp3.part, danachfs.renameSync— so bekommt der Browser nie eine halbe Datei zu hören, sondern bis zum Umbenennen ein 404.-f mp3ist dabei Pflicht: ffmpeg leitet den Muxer aus der Dateiendung ab und scheitert an.partmitError initializing the muxer … Invalid argument(erst so aufgefallen — beide Encode-Zweige fielen still aus, das MP3 fehlte ganz).state.stoppingin der GUI überlebt jedenrender(). 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 einnot recordingzurück (zweites Browserfenster, F5 mitten im Speichern), meldet die GUI sachlich „Aufnahme war bereits gestoppt" statt eines roten Fehlers.- Der Player lädt nach:
#mp3waitzeigt „Wiedergabe-Datei wird noch erzeugt", daserror-Ereignis des<audio>-Tags ruft alle 5 saudio.load()(max. 5 min).sweepOrphanskennt jetzt auchrec_*.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:
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.
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:
{ "ok": true, "recording": true, "startedAt": 1786359542932, "source": "default",
"markers": [ { "t_ms": 8401, "name": "Markus Ufer" } ] }
recStartkommt als Server-Epoche; der Timer rechnetDate.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 |