whisper-stt-cli/docs/ENTWICKLER.md

625 lines
32 KiB
Markdown
Raw Normal View History

# 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 |
| **Zusätze getrennt halten** | LLM-Korrektur nur optional in der CLI; Kern, Dienst und Wyoming bleiben davon unberührt |
| **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
```
whisper-stt-cli/ (lokal: ~/Python_Programs/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
├── CHANGELOG.md Änderungen je Version
├── CLAUDE.md Kurzkontext für Claude Code
├── requirements.txt
├── pytest.ini Marker, Standard: Integrationstests ausgeschlossen
├── .gitignore u. a. .venv/, *.wav (Aufnahmen), .claude/
└── legacy/ historische Einzelskripte stt.py, stt_respeaker.py (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 |
| `Output` | einzige Ausgabestelle: `put()` gibt direkt aus oder reicht an die LLM-Korrektur (eigener Thread, Reihenfolge bleibt); `warmup()` lädt das LLM vorab; `close()` wartet auf ausstehende Korrekturen |
| `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. In der CLI nur über `Output.put` ausgeben — nie `print` aus Empfänger- oder Callback-Threads, sonst
geraten LLM-korrigierte und direkte Ausgaben durcheinander.
7. 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` |
| `stt_llm.LlmConfig.from_env` | `STT_LLM_URL`, `STT_LLM_MODEL`, `STT_LLM_API`, `STT_LLM_TIMEOUT`, `STT_LLM_KEEP_ALIVE`, `STT_LLM_NUM_CTX`, `STT_LLM_MIN_SIMILARITY` |
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).
**Anderes LLM oder Prompt anpassen** — Modell/Server nur per `STT_LLM_MODEL`/`STT_LLM_URL` wechseln. Vor
einem neuen Standardmodell den Vergleich wiederholen (Fälle wie in §7 „LLM-Nachkorrektur“: echte
Whisper-Fehler, korrekte Sätze inkl. Befehle mit Verneinung/Zahlen, eine „Anweisung“ im Diktat). Prompt-
Änderungen in `SYSTEM_PROMPT`/`FEW_SHOT`, danach `pytest -m integration tests/test_llm.py`. Neue
Ablehnungsregeln gehören in `_accept` und brauchen einen Test mit `ScriptedCorrector`.
**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),
Beispiel in `docs/API.md` (`/health`) anpassen und Eintrag in `CHANGELOG.md` ergänzen.
---
## 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`.
- LLM-Korrektur: ~1 s pro Text, erstes Laden 35–75 s, ~10 GB Grafikspeicher; stark verstümmelte Sätze
werden oft nicht sinnvoll repariert. Nur in der CLI verfügbar.
- 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.
- LLM-Korrektur optional auch im Dienst anbieten (z. B. `/transcribe?correct=true`), weiterhin standardmäßig aus.
- MCP-Adapter analog zu chatterbox-tts-cli (`mcp_adapter.py`).
- 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.