Files
Aime/docs/03-aufnahme-pipeline.md
T
Claude 0f530b575a feat(analyse): Kontextnotiz zum Gespraech nach dem Stop
Ein Freitextfeld ("Kontext zum Gespraech") in der Detailansicht, nach dem
Stop aufgeklappt. Der Text geht als Hintergrundwissen in die LLM-Analyse:
Personen, Firmen, Anlass und Eigennamen muessen nicht mehr aus dem
fehlerbehafteten ASR-Text erraten werden.

Bewusst EIN Feld statt eines Formulars mit Teilnehmer-/Anlass-/Begriffs-
feldern (Entscheidung des Nutzers): die Box benutzen auch technisch nicht
versierte Menschen. Gefuehrt wird ueber den Platzhaltertext, die Struktur
zieht die Analyse selbst aus der Prosa.

- recordings.context_note/-_at, db.setContextNote/getContextNote
- PATCH /api/recordings/:id/context; Antwort `applies` sagt ehrlich, ob die
  Notiz die laufende Verarbeitung noch erreicht (auto/running/reanalyze)
- analyzeRecording liest die Notiz erst kurz vor dem LLM-Aufruf, damit
  waehrend der minutenlangen Vorstufen Getipptes noch zaehlt
- llm.contextBlock: eigener """-Block mit Verbot, daraus Aussagen, Ergebnisse
  oder Aufgaben abzuleiten (sonst schreibt das 4B-Modell die Notiz als
  Gesagtes zurueck); eingespeist in Single-, Whole- und Sektions-Pfad
- zwei Deckel: 1200 Zeichen fuer Prompts, die die Notiz einmal sehen,
  600 fuer die Chunk-Prompts (dort landet sie in jedem Chunk von 3200)
- GUI-Karte zeichnet sich selbst neu (kein openRecording -> Audio laeuft
  weiter), ungespeicherter Text ueberlebt den Neuaufbau nach der Analyse

Verifiziert an #97: Kontext greift (korrekte Namen, richtiges Thema), die
absichtlich eingelegten Fremdfakten der Testnotiz erscheinen nicht in der
Zusammenfassung. Alte Analyse danach zurueckgeschrieben.

Doku: Kapitel 05 (neuer Abschnitt + Flussdiagramm + Deckel-Tabelle),
Kapitel 03 (Sequenzdiagramm + API), Changelog 2026-08-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:10:38 +02:00

8.9 KiB
Raw Blame History

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->>DB: WAV + MP3 + Zeile (status=transcribing)
    SRV->>DB: addMarkers() → Tabelle markers
    Note over REC: kehrt nach ~1,5 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) / =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

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:

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" } ] }
  • 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
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