whisper-stt-cli/docs/ENTWICKLER.md
dschlueter 6ebea4b745 CLI: --until-enter und optionale LLM-Korrektur (--llm-correct)
- --until-enter: Aufnahme mit beliebig langen Pausen, Enter beendet, Ausgabe als ein Text
- stt_llm.py: Nachkorrektur per lokalem LLM (Standard gemma4:12b über Ollama nativ mit
  kleinem Kontext; OpenAI-kompatible Server möglich), strenger Prompt mit Beispielen,
  Ähnlichkeitsprüfung und Fehler-Fallback auf den Rohtext
- Output-Klasse in der CLI: Korrektur in eigenem Thread, Reihenfolge bleibt erhalten
- Tests für die LLM-Korrektur (13 Unit, 1 Integration)
- Doku: Bedienungsanleitung, README, Entwicklerdoku (inkl. Modellvergleich), CLAUDE.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:48:02 +02:00

609 lines
30 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.

# Entwicklerdokumentation
Für Programmierer, die den Code verstehen, ändern oder erweitern wollen. Anwender lesen die
[Bedienungsanleitung](../BEDIENUNGSANLEITUNG.md), Client-Entwickler die [API-Referenz](API.md).
## Inhalt
1. [Designziele](#1-designziele)
2. [Projektstruktur](#2-projektstruktur)
3. [Architektur im Überblick](#3-architektur-im-überblick)
4. [Modul `stt_core.py`](#4-modul-stt_corepy)
5. [Modul `stt_service.py`](#5-modul-stt_servicepy)
6. [Modul `stt_wyoming.py`](#6-modul-stt_wyomingpy)
7. [Modul `stt_cli.py`](#7-modul-stt_clipy)
8. [Nebenläufigkeit — die wichtigsten Regeln](#8-nebenläufigkeit--die-wichtigsten-regeln)
9. [Konfiguration](#9-konfiguration)
10. [Entwicklungsumgebung](#10-entwicklungsumgebung)
11. [Tests](#11-tests)
12. [Erweitern: typische Aufgaben](#12-erweitern-typische-aufgaben)
13. [Designentscheidungen](#13-designentscheidungen)
14. [Bekannte Grenzen und mögliche nächste Schritte](#14-bekannte-grenzen-und-mögliche-nächste-schritte)
15. [Konventionen](#15-konventionen)
---
## 1. Designziele
| Ziel | Umsetzung |
|---|---|
| **Schnell** | Modell einmal laden und halten; `large-v3-turbo` auf GPU; genau eine Transkription pro Äußerung |
| **Nicht blockierend** | Inferenz in einem Worker-Thread; Event-Loop bleibt frei; begrenzte Warteschlange mit sofortigem 503 |
| **Zuverlässig** | GPU→CPU-Fallback beim Laden; Halluzinationsfilter; robustes Dekodieren beliebiger Formate; Fehler einzelner Anfragen beenden keine Verbindung |
| **Vielseitig anbindbar** | eine Engine, mehrere Schnittstellen: OpenAI-HTTP, Roh-HTTP, WebSocket, Wyoming, CLI |
| **Testbar** | reine Logik ohne I/O im Kern; App-Fabrik mit injizierbarer Engine und VAD |
| **Gegenstück zu chatterbox-tts-cli** | gleiche Bauart: Kernmodul + FastAPI-Dienst + CLI + systemd-User-Unit |
Das Projekt ersetzt das alte Einzelskript `legacy/stt.py`. Dessen Schwächen — Stoppwort-Erkennung in
starren 2-s-Blöcken, doppelte Transkription, Modell-Laden pro Aufruf, falsches Lesen von Stereo-WAVs,
alles in einer Datei mit `print`/`sys.exit` — haben das Design direkt geprägt. Als Vorbild für
funktionierendes STT diente `my_voice_assistant_v3` (Modell-Cache, `asyncio.to_thread`, VAD-Segmentierung,
Warm-up).
---
## 2. Projektstruktur
```
SpeechToText/
├── stt_core.py Kern: Engine, Audio-Hilfen, Halluzinationsfilter, VAD-Segmentierung (kein I/O)
├── stt_service.py FastAPI-Dienst: HTTP-Endpunkte, WebSocket, Worker, Start des Wyoming-Servers
├── stt_wyoming.py Wyoming-Protokoll-Handler (Home Assistant)
├── stt_cli.py Client: Dateien/Mikrofon → Dienst, oder --local direkt mit dem Kern
├── stt_llm.py optionale LLM-Nachkorrektur (nur CLI --llm-correct)
├── tests/
│ ├── conftest.py Fixtures und Test-Helfer (FakeEngine, EnergyProb, make_wav, tone, espeak_wav)
│ ├── test_core.py Audio, Filter, Konfiguration, Segmentierung
│ ├── test_service.py HTTP, Überlast, Nicht-Blockieren, WebSocket
│ ├── test_wyoming.py Wyoming über echten TCP-Socket
│ ├── test_llm.py LLM-Korrektur mit geskripteten Antworten (+ 1 Integrationstest mit Ollama)
│ └── test_integration.py echtes Whisper + Silero (Marker "integration")
├── deploy/stt-service.service systemd-User-Unit
├── docs/API.md Schnittstellen-Referenz
├── docs/ENTWICKLER.md dieses Dokument
├── BEDIENUNGSANLEITUNG.md Anwender-Handbuch
├── README.md Überblick und Schnellstart
├── CLAUDE.md Kurzkontext für Claude Code
├── requirements.txt
├── pytest.ini Marker, Standard: Integrationstests ausgeschlossen
└── legacy/ historische Einzelskripte (nicht gepflegt)
```
**Abhängigkeitsrichtung** (keine Zyklen):
```mermaid
flowchart LR
cli[stt_cli.py] -. "nur mit Option local" .-> core[stt_core.py]
service[stt_service.py] --> core
service -. Import zur Laufzeit .-> wyoming[stt_wyoming.py]
wyoming --> core
cli -- HTTP/WS --> service
cli -. "nur mit Option llm-correct" .-> llm[stt_llm.py]
llm -- HTTP --> ollama[(Ollama / OpenAI-kompatibel)]
```
`stt_core` importiert nichts aus dem Projekt; `stt_llm` nutzt aus dem Kern nur `_env`. Dienst und Wyoming
wissen nichts von der LLM-Korrektur. `stt_wyoming` wird erst beim Start importiert, damit der
Dienst ohne das Paket `wyoming` lauffähig bleibt.
---
## 3. Architektur im Überblick
```mermaid
flowchart TB
subgraph Clients
HA[Home Assistant / ESPHome]
ESP[ESP32 / Skripte]
OAI[OpenAI-kompatible Tools]
CLI[stt_cli.py]
end
subgraph Prozess["stt_service.py (ein Prozess, ein Event-Loop)"]
WY[Wyoming-Handler<br/>stt_wyoming.py]
HTTP[HTTP-Endpunkte<br/>/transcribe, /v1/audio/transcriptions]
WS[_StreamSession<br/>/ws/stream]
SEG[UtteranceSegmenter<br/>+ SileroProb]
W[TranscriptionWorker<br/>ThreadPoolExecutor 1 Thread<br/>pending ≤ max_queue]
E[SttEngine<br/>WhisperModel]
end
HA -- TCP 10300 --> WY
ESP --> HTTP
OAI --> HTTP
CLI --> HTTP
CLI --> WS
ESP --> WS
WS --> SEG
WY --> W
HTTP --> W
SEG -- Äußerung --> W
W -- Thread --> E
E -- CTranslate2 --> GPU[(GPU / CPU)]
```
Alle Schnittstellen enden im **selben `TranscriptionWorker`** und damit in **einem** geladenen Modell.
Der Worker serialisiert die Inferenz (eine GPU-Aufgabe zur Zeit) und begrenzt die Anzahl wartender
Aufträge.
### Ablauf einer HTTP-Anfrage
```mermaid
sequenceDiagram
participant C as Client
participant L as Event-Loop (FastAPI)
participant D as to_thread (Dekodieren)
participant W as Worker-Thread
participant E as SttEngine
C->>L: POST /transcribe (Body)
L->>L: Größe prüfen (413)
L->>D: decode_audio_bytes (PyAV)
D-->>L: float32 16 kHz
L->>L: pending >= max_queue? → 503
L->>W: run_in_executor(engine.transcribe)
Note over L: Event-Loop frei für /health, WS, Wyoming
W->>E: transcribe (mit _run_lock)
E-->>W: Transcript
W-->>L: Transcript
L-->>C: JSON
```
---
## 4. Modul `stt_core.py`
Reine Logik: **kein `print`, kein `sys.exit`, keine Netzwerkzugriffe**. Fehler als Exceptions,
Ergebnisse als Dataclasses. Schwere Importe (`faster_whisper`, `scipy`, `soxr`) erfolgen erst in den
Funktionen, die sie brauchen — `import stt_core` ist dadurch schnell.
### 4.1 Audio-Hilfen
| Funktion/Klasse | Zweck |
|---|---|
| `pcm16_to_float(data, channels, channel)` | s16le → float32 mono; Kanal auswählen oder mitteln; unvollständige Frames verwerfen |
| `float_to_pcm16(audio)` | Gegenrichtung (Tests, Clients) |
| `resample(audio, src, dst)` | ganze Signale (`scipy.signal.resample_poly`) |
| `StreamResampler(src, dst)` | fortlaufende Stücke ohne Randartefakte (`soxr.ResampleStream`); `process(chunk, last=True)` leert den Filter |
| `decode_audio_bytes(data)` | beliebige Container über `faster_whisper.decode_audio` (PyAV) → 16 kHz mono; wirft `AudioDecodeError` |
Konstante `SAMPLE_RATE = 16000` ist das interne Format überall.
### 4.2 Ergebnisobjekte
`Segment(start, end, text, avg_logprob, no_speech_prob)` und
`Transcript(text, language, language_probability, duration, processing_s, segments, dropped_segments)`
mit `to_dict(with_segments=True)`. Die JSON-Antworten der Schnittstellen sind direkt daraus abgeleitet —
neue Felder hier erscheinen automatisch in `/transcribe` und im WebSocket.
### 4.3 Halluzinationsfilter
Whisper erzeugt bei Stille oder Rauschen typische Floskeln aus seinen Trainingsdaten (Untertitel).
`is_hallucination(seg)` verwirft ein Segment, wenn
1. der normalisierte Text (klein, Satzzeichen weg) **vollständig** einem Muster aus
`_HALLUCINATION_PATTERNS` entspricht (z. B. „untertitel im auftrag des zdf 2021“), oder leer ist, oder
2. Whisper selbst unsicher ist **und** Stille vermutet: `no_speech_prob > 0.6 and avg_logprob < -0.8`.
„Vollständig“ ist Absicht: „Die Untertitel im Fernsehen sind zu klein.“ bleibt erhalten.
Verworfene Segmente zählt `Transcript.dropped_segments`; mit `STT_LOG_LEVEL=DEBUG` werden sie geloggt.
### 4.4 `EngineConfig` und `SttEngine`
`EngineConfig` bündelt alle Modellparameter; `EngineConfig.from_env()` liest `STT_*`.
**Laden** (`SttEngine.load`, idempotent, durch `_load_lock` geschützt):
```mermaid
flowchart TD
A[load] --> B{Modell schon geladen?}
B -- ja --> Z[fertig]
B -- nein --> C[_load_attempts]
C --> D{device auto/cuda<br/>und CUDA-GPU vorhanden?}
D -- ja --> E[Versuch: cuda, model, float16]
D -- nein --> F
E -- Fehler --> F[Versuch: cpu, cpu_model*, int8]
E -- ok --> R[status = ready]
F -- ok --> R
F -- Fehler --> X[status = error, RuntimeError]
```
\* bei `device=cpu` wird `model` statt `cpu_model` verwendet — wer CPU explizit wählt, bekommt sein Modell.
Status-Felder für `/health`: `status` (`idle|loading|ready|error`), `error`, `active_model`,
`active_device`, `active_compute_type`, `load_seconds`.
**Transkribieren** (`SttEngine.transcribe`, blockierend):
- `< 100 ms` Audio → sofort leeres `Transcript`, ohne das Modell anzufassen.
- Sprache über `resolve_language`: `None` → Config, `"auto"`/`""` → Erkennung, `"de-DE"` → `"de"`.
- Aufruf von `WhisperModel.transcribe` unter `_run_lock` mit:
| Parameter | Wert | Grund |
|---|---|---|
| `vad_filter` | `True` (Standard) | Stille innerhalb des Audios überspringen → schneller, weniger Halluzinationen |
| `vad_parameters` | `min_silence_duration_ms=500, speech_pad_ms=200` | Wörter an Pausen nicht abschneiden |
| `condition_on_previous_text` | `False` | verhindert Wiederholungsschleifen über 30-s-Fenster hinweg |
| `beam_size` | Config (5) | Genauigkeit; `warmup` nutzt 1 |
| `initial_prompt` | Anfrage oder Config | Eigennamen |
| `hotwords` | nur Wyoming | Namen/Begriffe aus Home Assistant |
- **Wichtig:** `segments` ist ein Generator — die eigentliche Rechenarbeit passiert erst bei `list(...)`,
deshalb liegt das innerhalb des Locks.
`warmup()` lädt das Modell und transkribiert 1 s leises Rauschen (ohne VAD), damit CUDA-Kernel und
Decoder initialisiert sind und die erste echte Anfrage schnell ist.
### 4.5 Streaming-Segmentierung
**`SileroProb`** liefert die Sprachwahrscheinlichkeit je 512-Sample-Fenster (32 ms) mit dem in
faster-whisper mitgelieferten Silero-VAD (ONNX, CPU, kein Download). Besonderheit: Das Modell leitet
den 64-Sample-Kontext eines Fensters aus dem **vorherigen Fenster im selben Aufruf** ab. Damit das über
Aufrufgrenzen stimmt, wird das letzte Fenster des vorigen Aufrufs vorangestellt und dessen Ergebnis
verworfen.
**`UtteranceSegmenter`** ist eine Zustandsmaschine über diesen Fenstern:
```mermaid
stateDiagram-v2
[*] --> Warten
Warten --> Warten: Fenster in Pre-Roll-Puffer, p < threshold → voiced = 0
Warten --> Sprache: voiced ≥ min_speech_ms, → Ereignis speech_start, Pre-Roll wird Äußerungsanfang
Sprache --> Sprache: Fenster anhängen, p < threshold − 0,15 → silent += 1, sonst silent = 0
Sprache --> Warten: silent ≥ silence_ms, → utterance (reason silence)
Sprache --> Warten: Länge ≥ max_utterance_s, → utterance (reason max_length)
Sprache --> Warten: flush(), → utterance (reason flush)
```
- **Hysterese:** Start bei `p ≥ threshold`, Stille erst bei `p < threshold − 0,15` → kein Flattern.
- **Pre-Roll:** Ringpuffer der letzten `pre_roll_ms + min_speech_ms` → Anlaute bleiben erhalten.
- **Stream-Zeit** (`time_s`) zählt verarbeitete Samples, nicht die Uhr → Dateien dürfen schneller als
Echtzeit gestreamt werden.
- `prob_fn` ist injizierbar (Tests nutzen `EnergyProb`), `feed()` gibt eine Liste von `VadEvent` zurück.
- Unvollständige Fenster bleiben in `_pending` bis zum nächsten `feed()`.
---
## 5. Modul `stt_service.py`
### 5.1 App-Fabrik
```python
app = create_app(engine=None, settings=None, vad_config=None, prob_fn_factory=None)
```
Ohne Argumente wird alles aus der Umgebung gebaut (`EngineConfig.from_env()`, `ServiceSettings.from_env()`,
`VadConfig.from_env()`, `SileroProb`). Tests übergeben `FakeEngine` und `EnergyProb`. Auf Modulebene
steht `app = create_app()` für `uvicorn stt_service:app` — das ist billig, weil erst die **Lifespan**
das Modell lädt.
**Lifespan:**
1. `STT_PRELOAD`: `engine.warmup` als **erster Job im Worker** starten, **ohne** zu warten. Der Dienst
antwortet sofort (`/health` → `loading`); frühe Anfragen reihen sich hinter dem Laden ein.
2. Wyoming-Server starten, falls Port gesetzt (Fehler → nur Log, der HTTP-Dienst läuft trotzdem).
3. Beim Beenden: Wyoming stoppen, Preload abbrechen, Executor herunterfahren.
### 5.2 `TranscriptionWorker`
- `ThreadPoolExecutor(max_workers=1)` → genau eine Inferenz gleichzeitig (GPU-schonend,
vorhersagbarer Speicher).
- `pending` zählt laufende + wartende Aufträge. Er wird **nur im Event-Loop** verändert, daher ohne Lock.
- `transcribe()` wirft `Overloaded`, wenn `pending ≥ max_queue` → HTTP 503 bzw. WS-`error`.
- `run(fn, …)` für beliebige blockierende Aufgaben im selben Thread (z. B. `warmup`).
### 5.3 HTTP-Endpunkte
`_run_transcription` übersetzt Ausnahmen in HTTP-Status (`Overloaded` → 503, `RuntimeError` → 500).
`_decode` dekodiert in `asyncio.to_thread` (PyAV kostet bei langen Dateien CPU) und macht aus
`AudioDecodeError` einen 400. `_check_size` erzwingt `max_upload_mb`.
### 5.4 WebSocket: `_StreamSession`
Eine Instanz pro Verbindung. Zwei nebenläufige Tasks:
```mermaid
flowchart LR
RX["run() — Empfangsschleife"] -- Binär --> A[_on_audio:<br/>pcm16_to_float → StreamResampler<br/>→ UtteranceSegmenter.feed]
RX -- Text --> B[_on_control:<br/>start / end / close]
A -- speech_start --> SEND
A -- utterance --> Q[(jobs: asyncio.Queue)]
B -- flush / done-Marker --> Q
Q --> C["_consume() — Task"]
C -- worker.transcribe --> W[TranscriptionWorker]
C -- transcript / error / done --> SEND[send mit send_lock]
```
- Die Empfangsschleife **wartet nie auf Whisper** → Audio wird auch während der Transkription
angenommen; Äußerungen werden in Reihenfolge abgearbeitet.
- `send_lock`, weil beide Tasks senden.
- Queue-Einträge: `(audio, reason, t)` · `"done"` (Marker nach `end`) · `None` (Ende).
- Ohne VAD sammelt `buffer` bis `end` bzw. bis `max_utterance_s` erreicht ist.
- `start` baut Resampler und Segmenter neu (setzt die Sitzung zurück).
- Beim Verbindungsende: `None` einreihen, `_consume` bis 120 s ausstehende Transkripte abarbeiten lassen.
---
## 6. Modul `stt_wyoming.py`
`SttEventHandler(AsyncEventHandler)` — eine Instanz pro TCP-Verbindung.
| Ereignis | Aktion |
|---|---|
| `describe` | `build_info(engine)` → `Info` mit `AsrProgram("speech-to-text")`, Modellname, Whisper-Sprachen |
| `transcribe` | `language` merken; `transcript_names + transcript_terms` → `hotwords` |
| `audio-start` | Puffer und `AudioChunkConverter(16000 Hz, 16 Bit, mono)` neu |
| `audio-chunk` | konvertieren und anhängen |
| `audio-stop` | `worker.transcribe` → `Transcript`-Ereignis → `return False` (Verbindung schließen, HA-Konvention) |
Fehler bei der Transkription führen zu einem **leeren** Transkript statt zu einem Abbruch — Home Assistant
würde sonst auf eine Antwort warten. `start_server(worker, host, port)` gibt den `AsyncTcpServer` zurück;
Port `0` ist in Tests erlaubt (freien Port über `server._server.sockets[0]` ermitteln).
---
## 7. Modul `stt_cli.py`
| Funktion | Aufgabe |
|---|---|
| `main` | Argumente, Modus-Auswahl, Fehler → Exit-Code, Hinweis bei nicht erreichbarem Dienst |
| `service_file` | `POST /transcribe` mit Datei als Body (Streaming-Upload über `requests`) |
| `service_mic` | Mikrofon → `/ws/stream` (synchroner `websockets`-Client, Empfänger-Thread) |
| `local_files` / `local_mic` | wie oben, aber `SttEngine` + `UtteranceSegmenter` im Prozess; Inferenz im `ThreadPoolExecutor(1)` |
| `start_enter_watcher` / `emit_joined` | `--until-enter`: Thread wartet auf Enter; Abschnitte sammeln und am Ende als ein Ergebnis ausgeben (Dienst: `end` senden, auf `done` warten; lokal: `segmenter.flush()`, Futures in Reihenfolge) |
| `Microphone` | `sounddevice.InputStream` mit Callback → `queue.Queue` von s16le-Mono-Blöcken (100 ms) |
| `resolve_input` | Gerätewahl: kein `-d` → ALSA-Gerät `pulse` (folgt PipeWire-Default), Zahl → Index, Text → Namenssuche |
**Threads im Mikrofon-Modus mit Dienst:** Audio-Callback (PortAudio) → Queue → Hauptthread sendet;
ein Empfänger-Thread liest Ereignisse und schreibt Transkripte auf stdout. Keine Stelle blockiert die
Aufnahme; läuft die Queue (60 s) voll, werden Blöcke gezählt verworfen.
**Warum `pulse` statt PortAudio-Default?** PortAudio wählt auf PipeWire-Systemen die erste ALSA-Karte
(hier: Onboard-ALC1220), nicht das eingestellte Mikrofon. Über `pulse` wird mit 16 kHz aufgenommen
(PipeWire resampelt); der Name der Standardquelle kommt aus `pactl get-default-source` und dient der
ReSpeaker-Erkennung (→ Kanal 1).
**Ausgabevertrag:** stdout nur Ergebnisse (Text oder JSON-Zeile), stderr alles andere.
Exit-Codes `0/1/2/130` (siehe Bedienungsanleitung).
---
### LLM-Nachkorrektur (`stt_llm.py`)
Optionaler Zusatz, bewusst **außerhalb** von Kern und Dienst: Smarthome-, ESP32- und Wyoming-Pfade bleiben
schnell und deterministisch. Eingebunden nur über `Output` in `stt_cli.py` (`--llm-correct`).
```mermaid
flowchart LR
T[Transkript] --> O[Output.put]
O -- ohne LLM --> E[emit stdout]
O -- mit LLM --> X[ThreadPoolExecutor 1<br/>Reihenfolge bleibt]
X --> C[LlmCorrector.correct]
C --> S[split_chunks<br/>≤ 1200 Zeichen]
S --> CH[_chat<br/>Ollama /api/chat oder /v1/chat/completions]
CH --> CL[clean_output]
CL --> G{_accept<br/>Ähnlichkeit ≥ 0,75<br/>Länge ±30 %}
G -- ja --> K[korrigierter Text]
G -- nein --> R[Rohtext]
CH -- Exception --> R
K --> E
R --> E
```
| Baustein | Aufgabe |
|---|---|
| `LlmConfig` / `from_env` | URL, Modell, `api` (`auto`/`ollama`/`openai`), `keep_alive`, `num_ctx`, Timeout, Schwellen (`STT_LLM_*`) |
| `SYSTEM_PROMPT` + `FEW_SHOT` | nur Erkennungsfehler korrigieren; Diktat in `<transkript>`-Tags; Beispiel mit „Anweisung“ im Diktat |
| `LlmCorrector._detect_api` | `…/v1` → OpenAI-kompatibel; sonst `GET /api/version` → Ollama |
| `LlmCorrector._chat` | Ollama nativ mit `think: false`, `keep_alive`, `num_ctx`, `temperature 0`; sonst Chat Completions |
| `clean_output` | entfernt Tags, umschließende Anführungszeichen, „Korrigierter Text:“ |
| `_accept` | Ähnlichkeit (`difflib`) und Längenänderung → sonst Rohtext |
| `correct` | wirft nie; `Correction(text, raw_text, status, detail, llm_text, similarity, seconds, model)` |
**Warum Ollama nativ statt `/v1`?** Auf dem Entwicklungsrechner ist Ollama global mit
`OLLAMA_CONTEXT_LENGTH=131072` und `OLLAMA_NUM_PARALLEL=4` konfiguriert. Über `/v1` lässt sich der Kontext
nicht pro Anfrage setzen — die Modelle liefen dann zu 75–90 % auf der CPU (5–8 s pro Satz, `mistral-nemo`
95 GB). Mit `num_ctx=4096` pro Anfrage laufen sie zu 100 % auf der GPU (0,5–1,5 s).
**Modellvergleich** (2026-09-14, 11 Fälle: 5 Whisper-Fehler, 5 korrekte Sätze, 1 „Anweisung“; RTX 3090,
Prompt mit Tags und Beispielen):
| Modell | bestanden | Latenz warm | Beobachtung |
|---|---|---|---|
| `gemma4:12b` | 9/11 | ~1,2 s | widersteht der Anweisung schon per Prompt; scheitert nur an stark verstümmelten Sätzen |
| `mistral-nemo` | 9/11 | ~0,6 s | Anweisung und Tags nur durch `_accept` abgefangen; erfindet Namen („Meredith-Stehle“) |
| `qwen3.5:9b` | 4/11 | ~0,8 s | verändert Befehle („Schalte das Licht aus“ → „Das Licht ist ausgeschaltet“) |
Das erste Laden eines Modells dauert 35–75 s; `Output.warmup()` stößt es im Mikrofon-Modus sofort an.
## 8. Nebenläufigkeit — die wichtigsten Regeln
1. **Nie** `engine.transcribe` (oder anderes Blockierendes) direkt in einer `async`-Funktion aufrufen —
immer über `worker.transcribe` / `worker.run` bzw. `asyncio.to_thread`.
2. `worker.pending` nur im Event-Loop verändern.
3. In einer WebSocket-Sitzung nur über `_StreamSession.send` senden (Lock).
4. `SttEngine` ist thread-sicher (`_load_lock`, `_run_lock`), aber der Dienst verlässt sich zusätzlich
auf den Ein-Thread-Worker für die Warteschlangenlogik.
5. `UtteranceSegmenter` und `StreamResampler` sind **nicht** thread-sicher — eine Instanz pro Stream.
6. Silero-VAD läuft im Event-Loop (gemessen ≈ 0,4 ms Rechenzeit pro 100 ms Audio). Bei sehr vielen gleichzeitigen
Streams wäre das der erste Kandidat für einen eigenen Thread.
---
## 9. Konfiguration
Alle Einstellungen sind Umgebungsvariablen (vollständige Tabelle:
[Bedienungsanleitung §9](../BEDIENUNGSANLEITUNG.md#9-einstellungen)). Gelesen werden sie an genau drei
Stellen:
| Klasse | Variablen |
|---|---|
| `stt_core.EngineConfig.from_env` | `STT_MODEL`, `STT_DEVICE`, `STT_COMPUTE_TYPE`, `STT_DEVICE_INDEX`, `STT_CPU_MODEL`, `STT_CPU_THREADS`, `STT_LANGUAGE`, `STT_BEAM_SIZE`, `STT_INITIAL_PROMPT`, `STT_DOWNLOAD_ROOT` |
| `stt_core.VadConfig.from_env` | `STT_VAD_THRESHOLD`, `STT_VAD_MIN_SPEECH_MS`, `STT_VAD_SILENCE_MS`, `STT_VAD_PRE_ROLL_MS`, `STT_VAD_MAX_UTTERANCE_S` |
| `stt_service.ServiceSettings.from_env` | `STT_MAX_QUEUE`, `STT_MAX_UPLOAD_MB`, `STT_PRELOAD`, `STT_WYOMING_HOST`, `STT_WYOMING_PORT` |
Außerdem direkt: `STT_LOG_LEVEL`, `STT_HOST`, `STT_PORT` (`stt_service.py`), `STT_URL` (`stt_cli.py`).
Hilfsfunktion `stt_core._env(name, default, cast)`: leere Werte gelten als „nicht gesetzt“,
`bool` akzeptiert `1/true/yes/on`.
Neue Einstellung = Feld in der passenden Dataclass + Zeile in `from_env` + Tabelle in der
Bedienungsanleitung + Test (`test_engine_config_from_env` als Muster).
---
## 10. Entwicklungsumgebung
```bash
cd ~/Python_Programs/SpeechToText
python3 -m venv --system-site-packages .venv
.venv/bin/pip install -r requirements.txt
```
Die venv erbt auf dem Entwicklungsrechner faster-whisper, FastAPI, onnxruntime usw. aus
`~/miniforge3`; `wyoming` ist nur in der venv installiert. **Immer `.venv/bin/python` verwenden**, sonst
fehlt `wyoming` (Wyoming-Tests werden dann übersprungen, der Dienst startet ohne Wyoming).
**Dienst im Entwicklungsbetrieb:**
```bash
CUDA_VISIBLE_DEVICES=GPU-83ba6d1f-ebbe-f8c8-1433-153e0ebc3866 STT_LOG_LEVEL=DEBUG .venv/bin/python stt_service.py
# schneller Start ohne GPU/Download:
STT_MODEL=tiny STT_DEVICE=cpu STT_WYOMING_PORT= .venv/bin/uvicorn stt_service:app --port 9998 --reload
```
**GPU-Umgebung des Entwicklungsrechners:** T600 + 2× RTX 3090. `nvidia-smi` zählt in PCI-Reihenfolge,
CUDA „fastest first“ — Indizes stimmen nicht überein, daher GPU per UUID pinnen. GPU 1 ist durch
llama.cpp/Ollama nahezu voll; GPU 2 teilen sich chatterbox-tts, Open Notebook (STT/TTS) und dieser Dienst
(`large-v3-turbo` ≈ 2,4 GB). Hugging-Face-Modelle liegen unter `$HF_HOME/hub`.
**Schnelle Handtests:**
```bash
espeak-ng -v de -w /tmp/t.wav "Schalte das Licht im Wohnzimmer ein."
curl --data-binary @/tmp/t.wav localhost:9998/transcribe | python3 -m json.tool
.venv/bin/python stt_cli.py /tmp/t.wav
```
---
## 11. Tests
```bash
.venv/bin/python -m pytest # 49 Unit-Tests, ~9 s, ohne Modell
.venv/bin/python -m pytest -m integration # 5 Integrationstests: Whisper small auf CPU (~20 s), LLM über Ollama (bis ~75 s)
.venv/bin/python -m pytest tests/test_service.py -k ws -v
STT_TEST_MODEL=large-v3-turbo STT_TEST_DEVICE=cuda .venv/bin/python -m pytest -m integration
```
`pytest.ini` schließt `integration` standardmäßig aus.
### Helfer in `tests/conftest.py`
| Helfer | Zweck |
|---|---|
| `FakeEngine(delay=0)` | Engine ohne Modell; Text = `"audio <Dauer>s"`; zeichnet Aufrufe in `calls` auf; `delay` simuliert Rechenzeit |
| `EnergyProb` | deterministische Ersatz-VAD: Fenster-RMS > 0,05 = Sprache |
| `make_wav(audio, rate, channels, width)` | float32 → WAV-Bytes (16/32 Bit) |
| `tone(sec, freq, amp, rate)` / `silence(sec, rate)` | synthetische Signale |
| `espeak_wav(text, voice)` | echte (synthetische) Sprache für Integrationstests; `None` ohne espeak-ng |
`conftest.py` setzt `STT_WYOMING_PORT=""`, damit das beim Import erzeugte Modul-`app` keinen TCP-Port
belegt.
### Was wie getestet wird
| Bereich | Muster |
|---|---|
| Kernlogik | direkte Funktionsaufrufe (`test_core.py`) |
| HTTP | `TestClient(create_app(FakeEngine(), ServiceSettings(wyoming_port=None), …))` |
| Nebenläufigkeit (503, nicht blockieren) | `httpx.ASGITransport` + `asyncio.gather`, `FakeEngine(delay=…)`; auf `worker.pending` warten statt fester `sleep`s |
| WebSocket | `client.websocket_connect("/ws/stream")`, Hilfsfunktion `_receive_until(ws, "done")` |
| Wyoming | echter `start_server(…, port=0)` + `wyoming.client.AsyncTcpClient` |
| Ende-zu-Ende | `test_integration.py`: espeak-Sprache → Silero-Segmentierung → Whisper |
| LLM-Korrektur | `ScriptedCorrector` überschreibt `_chat` mit vorgegebenen Antworten (Annahme, Verwerfen, Fehler, Stückelung); `test_real_llm_correction` nur mit laufendem Ollama (`STT_TEST_LLM_MODEL`) |
Neue Funktionen bitte mit Unit-Test ohne Modell absichern; nur was wirklich Whisper braucht, als
`@pytest.mark.integration`.
---
## 12. Erweitern: typische Aufgaben
**Neuer HTTP-Endpunkt** — in `create_app` definieren, Audio über `_decode` bzw. `core.pcm16_to_float`
beschaffen, Transkription über `_run_transcription`. Test in `test_service.py` mit `_client()`. In
`docs/API.md` dokumentieren.
**Neuer Transkriptionsparameter** (z. B. Wort-Zeitstempel) — Parameter in `SttEngine.transcribe`
ergänzen und an `model.transcribe` durchreichen; bei Bedarf Felder in `Segment`/`Transcript`; in den
Endpunkten als Query/Form/`start`-Feld annehmen. `FakeEngine.transcribe` akzeptiert `**kwargs`, bestehende
Tests laufen weiter.
**Halluzinationsmuster ergänzen** — Regex (klein, ohne Satzzeichen am Ende) in `_HALLUCINATION_PATTERNS`,
Parametrisierung in `test_hallucinations_are_detected` **und** ein Gegenbeispiel echter Sprache in
`test_real_speech_is_kept`.
**Andere VAD** — Klasse mit `__call__(windows: np.ndarray) -> np.ndarray` (eine Wahrscheinlichkeit je
512er-Fenster) und optional `reset()`; im Dienst über `create_app(prob_fn_factory=…)` einsetzen.
**Andere Engine** (z. B. anderes STT-Backend) — Unterklasse von `SttEngine`, die `load` und `transcribe`
überschreibt und ein `Transcript` liefert (`FakeEngine` zeigt das minimale Muster).
**Neuer Client** — gegen [docs/API.md](API.md) programmieren; `stt_cli.py` ist die Referenzimplementierung
für Datei-Upload und WebSocket-Streaming.
**Version erhöhen** — `stt_core.__version__` (wird von `/health`, FastAPI und Wyoming übernommen).
---
## 13. Designentscheidungen
| Entscheidung | Begründung | Verworfene Alternative |
|---|---|---|
| Äußerungsende per Silero-VAD | natürliche Bedienung, geringe Latenz, für Smarthome üblich | Stoppwort (legacy): unzuverlässig an Blockgrenzen, doppelte Transkription |
| Ein Worker-Thread | GPU-Speicher vorhersagbar, keine Konkurrenz um den Kontext; ~0,2 s pro Befehl reicht für einen Haushalt | mehrere Worker (`num_workers`): mehr VRAM, kaum Gewinn auf einer geteilten GPU |
| Sofortiges 503 bei voller Queue | „nicht blockieren“: Clients bekommen schnell eine Antwort und können neu versuchen | unbegrenzte Queue: Latenz wächst unbemerkt |
| `large-v3-turbo` als Standard | nahezu `large-v3`-Qualität, deutlich schneller, ~2,4 GB statt ~4 GB | `large-v3`: langsamer; `medium`: schlechter bei Deutsch |
| CPU-Fallback mit `small` | Dienst bleibt nutzbar, wenn die GPU belegt ist; `small` ist auf CPU noch schnell | Abbruch: Smarthome ohne Spracherkennung |
| `condition_on_previous_text=False` | verhindert Wiederholungsschleifen | Standard `True` |
| Preload ohne Warten | Dienst sofort erreichbar, `/health` zeigt Fortschritt | blockierender Start: systemd-Timeouts, keine Diagnose |
| Wyoming im selben Prozess | ein Modell im Speicher, eine Konfiguration | separater Wyoming-Dienst (`wyoming-faster-whisper`): zweites Modell |
| OpenAI-kompatibler Endpunkt | vorhandene OpenAI-Clients (SDK, Voice-Assistant v3 …) nutzbar, nur Basis-URL `…/v1` setzen | nur eigene API |
| Roh-Endpunkt `/transcribe` | Mikrocontroller können kein bequemes multipart | nur multipart |
| CLI über `pulse`-Gerät | folgt der Systemeinstellung; PortAudio-Default ist falsch | PortAudio-Default (legacy-Fehler) |
| LLM-Korrektur nur optional in der CLI, mit Ähnlichkeitsprüfung | LLMs können Inhalte verfälschen und kosten ~1 s; Rohtext muss erhalten bleiben | LLM immer im Dienst (legacy `stt.py`): langsam, unkontrolliert, schnitt bei 500 Tokens ab |
| Keine Authentifizierung (vorerst) | Heimnetz-Betrieb, einfache Clients (ESP32) | Token: siehe nächste Schritte |
---
## 14. Bekannte Grenzen und mögliche nächste Schritte
**Grenzen**
- Wörtlich wiederholte Sätze innerhalb eines 30-s-Fensters fasst Whisper teilweise zusammen.
- Keine Zwischenergebnisse während des Sprechens; Transkripte kommen pro Äußerung.
- Keine Authentifizierung, kein TLS.
- Ein Modell pro Dienstinstanz; Modellwechsel = Neustart.
- Wyoming protokolliert Transkripttexte auf `INFO`.
- Die Mikrofonpfade der CLI sind mit synthetischer Sprache und Pegelmessung getestet, der WebSocket-Pfad
mit echter Sprache in Echtzeit-Simulation — nicht automatisiert mit echtem Mikrofon.
**Mögliche nächste Schritte**
- Optionales Bearer-Token (`STT_API_TOKEN`) für HTTP/WebSocket.
- Metriken (`/metrics` im Prometheus-Format: Latenzen, Queue, Fehler).
- Zwischenergebnisse (partielle Transkripte) während langer Äußerungen.
- MCP-Adapter analog zu chatterbox-tts-cli (`mcp_adapter.py`).
- Remote-Repository auf kitux.de (Forgejo).
- Anbindung als STT-Provider im Voice-Assistant v3 (OpenAI-kompatibel bereits möglich).
---
## 15. Konventionen
- **Sprache:** Kommentare, Docstrings, Doku, Log-/Fehlermeldungen und Commit-Nachrichten auf Deutsch;
Bezeichner englisch.
- **Kern ohne I/O:** `stt_core` gibt nichts aus und beendet nichts — Rückgabewerte und Exceptions.
- **Logging** über `logging` (`stt`, `stt.service`, `stt.wyoming`), nicht `print` (Ausnahme: CLI-Ausgabe).
- **Typen:** Type Hints und `from __future__ import annotations`; Datenobjekte als `@dataclass`.
- **Konfiguration** nur über Umgebungsvariablen in den `from_env`-Methoden.
- **Tests** zu jeder Änderung; Unit-Tests ohne Modell, Integration mit Marker.
- **Commits:** kleine, thematische Commits; Autor `dschlueter <dschlueter@kitux.de>`.
- **Leere Datei `3`:** entsteht durch einen System-Bug, gehört nie in einen Commit und darf gelöscht werden.