- CHANGELOG.md neu (0.1.0, 0.2.0) - Bedienungsanleitung: LLM-Korrektur in Überblick, Voraussetzungen, Installation, Deinstallation - Entwicklerdoku: Output-Klasse, STT_LLM_*-Konfiguration, Nebenläufigkeitsregel für Ausgaben, Erweitern (LLM/Prompt), Grenzen und nächste Schritte - README, CLAUDE.md: Verweise und Befehle ergänzt - stt_core.__version__ = 0.2.0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32 KiB
Entwicklerdokumentation
Für Programmierer, die den Code verstehen, ändern oder erweitern wollen. Anwender lesen die Bedienungsanleitung, Client-Entwickler die API-Referenz.
Inhalt
- Designziele
- Projektstruktur
- Architektur im Überblick
- Modul
stt_core.py - Modul
stt_service.py - Modul
stt_wyoming.py - Modul
stt_cli.py - Nebenläufigkeit — die wichtigsten Regeln
- Konfiguration
- Entwicklungsumgebung
- Tests
- Erweitern: typische Aufgaben
- Designentscheidungen
- Bekannte Grenzen und mögliche nächste Schritte
- 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
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):
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
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
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
- der normalisierte Text (klein, Satzzeichen weg) vollständig einem Muster aus
_HALLUCINATION_PATTERNSentspricht (z. B. „untertitel im auftrag des zdf 2021“), oder leer ist, oder - 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):
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 msAudio → sofort leeresTranscript, ohne das Modell anzufassen.- Sprache über
resolve_language:None→ Config,"auto"/""→ Erkennung,"de-DE"→"de". - Aufruf von
WhisperModel.transcribeunter_run_lockmit:
| 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:
segmentsist ein Generator — die eigentliche Rechenarbeit passiert erst beilist(...), 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:
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 beip < 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_fnist injizierbar (Tests nutzenEnergyProb),feed()gibt eine Liste vonVadEventzurück.- Unvollständige Fenster bleiben in
_pendingbis zum nächstenfeed().
5. Modul stt_service.py
5.1 App-Fabrik
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:
STT_PRELOAD:engine.warmupals erster Job im Worker starten, ohne zu warten. Der Dienst antwortet sofort (/health→loading); frühe Anfragen reihen sich hinter dem Laden ein.- Wyoming-Server starten, falls Port gesetzt (Fehler → nur Log, der HTTP-Dienst läuft trotzdem).
- Beim Beenden: Wyoming stoppen, Preload abbrechen, Executor herunterfahren.
5.2 TranscriptionWorker
ThreadPoolExecutor(max_workers=1)→ genau eine Inferenz gleichzeitig (GPU-schonend, vorhersagbarer Speicher).pendingzählt laufende + wartende Aufträge. Er wird nur im Event-Loop verändert, daher ohne Lock.transcribe()wirftOverloaded, wennpending ≥ 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:
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 nachend) ·None(Ende). - Ohne VAD sammelt
bufferbisendbzw. bismax_utterance_serreicht ist. startbaut Resampler und Segmenter neu (setzt die Sitzung zurück).- Beim Verbindungsende:
Noneeinreihen,_consumebis 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).
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
- Nie
engine.transcribe(oder anderes Blockierendes) direkt in einerasync-Funktion aufrufen — immer überworker.transcribe/worker.runbzw.asyncio.to_thread. worker.pendingnur im Event-Loop verändern.- In einer WebSocket-Sitzung nur über
_StreamSession.sendsenden (Lock). SttEngineist thread-sicher (_load_lock,_run_lock), aber der Dienst verlässt sich zusätzlich auf den Ein-Thread-Worker für die Warteschlangenlogik.UtteranceSegmenterundStreamResamplersind nicht thread-sicher — eine Instanz pro Stream.- In der CLI nur über
Output.putausgeben — nieprintaus Empfänger- oder Callback-Threads, sonst geraten LLM-korrigierte und direkte Ausgaben durcheinander. - 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). 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
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:
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:
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
.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 sleeps |
| 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 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 (
/metricsim 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). - 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_coregibt nichts aus und beendet nichts — Rückgabewerte und Exceptions. - Logging über
logging(stt,stt.service,stt.wyoming), nichtprint(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.