whisper-stt-cli/docs/ENTWICKLER.md
2026-09-14 02:44:24 +02:00

32 KiB
Raw Permalink Blame History

Entwicklerdokumentation

Für Programmierer, die den Code verstehen, ändern oder erweitern wollen. Anwender lesen die Bedienungsanleitung, Client-Entwickler die API-Referenz.

Inhalt

  1. Designziele
  2. Projektstruktur
  3. Architektur im Überblick
  4. Modul stt_core.py
  5. Modul stt_service.py
  6. Modul stt_wyoming.py
  7. Modul stt_cli.py
  8. Nebenläufigkeit — die wichtigsten Regeln
  9. Konfiguration
  10. Entwicklungsumgebung
  11. Tests
  12. Erweitern: typische Aufgaben
  13. Designentscheidungen
  14. Bekannte Grenzen und mögliche nächste Schritte
  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):

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

  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):

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:

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

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:

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).

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). 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 (/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.