# API-Referenz Schnittstellen des STT-Dienstes (`stt_service.py`) für Client-Entwickler — Skripte, Web-Apps, Mikrocontroller (ESP32), Home Assistant. | Schnittstelle | Adresse | Gedacht für | |---|---|---| | [`GET /health`](#get-health) | `http://:9998/health` | Überwachung, Bereitschaft prüfen | | [`POST /v1/audio/transcriptions`](#post-v1audiotranscriptions-openai-kompatibel) | multipart | Tools mit OpenAI-Client, Web-Upload | | [`POST /transcribe`](#post-transcribe-rohdaten) | Rohdaten im Body | Mikrocontroller, `curl`, einfache Skripte | | [`WS /ws/stream`](#ws-wsstream-live-streaming) | WebSocket | Live-Mikrofon, fortlaufendes Diktat, Push-to-Talk | | [Wyoming](#wyoming-home-assistant) | `tcp://:10300` | Home Assistant, ESPHome-Satelliten | Interaktive Doku (Swagger) mit „Try it out“: **`http://:9998/docs`** > **Sicherheit:** Der Dienst hat **keine Authentifizierung** und lauscht auf allen Netzwerkschnittstellen > (`0.0.0.0`). Nur im vertrauenswürdigen LAN betreiben bzw. per Firewall absichern > (z. B. `sudo ufw allow from 192.168.1.0/24 to any port 9998,10300 proto tcp`). --- ## Gemeinsame Konzepte ### Audio - Intern arbeitet Whisper mit **16 kHz, mono, float32**. Alles andere wird vom Dienst umgerechnet. - Container-Formate (`/v1/audio/transcriptions`, `/transcribe` ohne `format=pcm`): WAV (jede Bittiefe und Kanalzahl), MP3, OGG/Opus, FLAC, M4A/AAC, WebM … — alles, was FFmpeg/PyAV lesen kann. - Rohdaten (`/transcribe?format=pcm`, WebSocket, Wyoming): **PCM signed 16 bit little-endian** (`s16le`), bei mehreren Kanälen verschachtelt (L R L R …). - Empfehlung für Mikrocontroller: direkt **16 kHz mono s16le** senden — dann entfällt jede Umrechnung. ### Sprache (`language`) | Wert | Wirkung | |---|---| | weggelassen | Default des Dienstes (`STT_LANGUAGE`, Standard `de`) | | `de`, `en`, `fr`, … | feste Sprache (ISO 639-1); `de-DE` wird zu `de` | | `auto` | automatische Spracherkennung (etwas langsamer, bei kurzen Äußerungen unsicherer) | ### Prompt (`prompt`) Optionaler Kontext für Whisper, v. a. **Eigennamen und Fachbegriffe**, z. B. `"Bredelar, Marsberg, Hoppermann"`. Verbessert die Schreibweise solcher Wörter. ### Transkript-Objekt Von `/transcribe` (vollständig) und WebSocket (ohne `segments`) geliefert: ```json { "text": "Schalte das Licht im Wohnzimmer ein.", "language": "de", "language_probability": 1.0, "duration": 2.41, "processing_s": 0.198, "segments": [ {"start": 0.0, "end": 2.3, "text": "Schalte das Licht im Wohnzimmer ein.", "avg_logprob": -0.21, "no_speech_prob": 0.01} ], "dropped_segments": 0 } ``` | Feld | Bedeutung | |---|---| | `text` | Ergebnis; **leerer String**, wenn keine Sprache erkannt wurde | | `language` / `language_probability` | erkannte bzw. vorgegebene Sprache und Sicherheit | | `duration` | Audiodauer in Sekunden | | `processing_s` | Rechenzeit der Transkription in Sekunden | | `segments` | Satzabschnitte mit Zeitstempeln und Whisper-Konfidenzwerten | | `dropped_segments` | vom Halluzinationsfilter verworfene Abschnitte (z. B. „Untertitel im Auftrag des ZDF“) | ### Fehler HTTP-Fehler kommen als JSON `{"detail": "…"}`: | Status | Ursache | Client-Reaktion | |---|---|---| | `400` | Audio nicht dekodierbar, leerer Body, ungültiger Kanal, unbekanntes `response_format` | Eingabe prüfen | | `413` | Upload größer als `STT_MAX_UPLOAD_MB` (Standard 50 MB) | Datei kürzen/teilen | | `422` | Parameter außerhalb des gültigen Bereichs (z. B. `sample_rate=100`) | Parameter prüfen | | `500` | Modell nicht ladbar / interner Fehler | `/health` und Log prüfen | | `503` | Warteschlange voll (`STT_MAX_QUEUE`), Header `Retry-After: 2` | nach 2 s erneut versuchen | --- ## `GET /health` ```bash curl http://127.0.0.1:9998/health ``` ```json { "status": "ok", "model": "large-v3-turbo", "device": "cuda", "compute_type": "float16", "default_language": "de", "queue": 0, "max_queue": 8, "load_seconds": 2.34, "wyoming_port": 10300, "error": null, "version": "0.1.0" } ``` | `status` | Bedeutung | |---|---| | `ok` | Modell geladen, Dienst bereit | | `loading` | Modell wird gerade geladen (erste Anfragen warten automatisch) | | `idle` | Modell noch nicht geladen (nur bei `STT_PRELOAD=0`; lädt bei der ersten Anfrage) | | `error` | Modell nicht ladbar — Grund in `error` | `device` = `cpu` bei laufender GPU bedeutet: GPU-Laden ist fehlgeschlagen, der Dienst ist auf CPU ausgewichen (langsamer, Modell `STT_CPU_MODEL`). --- ## `POST /v1/audio/transcriptions` (OpenAI-kompatibel) Nachbildung der [OpenAI-Transcriptions-API](https://platform.openai.com/docs/api-reference/audio/createTranscription). Bestehende OpenAI-Clients funktionieren, wenn man die Basis-URL auf `http://:9998/v1` setzt. **Form-Felder (multipart/form-data):** | Feld | Pflicht | Beschreibung | |---|---|---| | `file` | ja | Audiodatei | | `language` | nein | siehe [Sprache](#sprache-language) | | `prompt` | nein | siehe [Prompt](#prompt-prompt) | | `response_format` | nein | `json` (Standard) · `text` · `verbose_json` | | `model`, `temperature` | nein | werden akzeptiert, aber ignoriert (ein Modell pro Dienst) | ```bash curl -F file=@aufnahme.mp3 -F language=de http://127.0.0.1:9998/v1/audio/transcriptions # {"text":"…"} curl -F file=@aufnahme.mp3 -F response_format=text http://127.0.0.1:9998/v1/audio/transcriptions # …reiner Text… ``` `verbose_json` liefert `task`, `language`, `duration`, `text` und `segments` (mit `id`, `start`, `end`, `text`, `avg_logprob`, `no_speech_prob`). **Python mit dem offiziellen OpenAI-Paket:** ```python from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:9998/v1", api_key="unbenutzt") with open("aufnahme.mp3", "rb") as fh: result = client.audio.transcriptions.create(model="whisper-1", file=fh, language="de") print(result.text) ``` --- ## `POST /transcribe` (Rohdaten) Audio steht **direkt im Request-Body** — kein multipart, ideal für Mikrocontroller und `curl`. **Query-Parameter:** | Parameter | Standard | Beschreibung | |---|---|---| | `format` | `auto` | `auto` = Container (WAV, MP3 …) erkennen · `pcm` = rohes s16le | | `sample_rate` | `16000` | nur `pcm`: Abtastrate (4000–192000) | | `channels` | `1` | nur `pcm`: Kanalzahl (1–16) | | `channel` | – | nur `pcm`: einen Kanal auswählen (0-basiert), sonst Mittelwert aller Kanäle | | `language` | – | siehe [Sprache](#sprache-language) | | `prompt` | – | siehe [Prompt](#prompt-prompt) | Antwort: vollständiges [Transkript-Objekt](#transkript-objekt). ```bash # WAV/MP3 direkt curl --data-binary @aufnahme.wav http://127.0.0.1:9998/transcribe # Rohes PCM erzeugen und senden (16 kHz mono) ffmpeg -i aufnahme.mp3 -f s16le -ar 16000 -ac 1 roh.pcm curl --data-binary @roh.pcm "http://127.0.0.1:9998/transcribe?format=pcm&sample_rate=16000" # ReSpeaker-Aufnahme: 48 kHz Stereo, nur rechter ASR-Kanal curl --data-binary @respeaker.pcm \ "http://127.0.0.1:9998/transcribe?format=pcm&sample_rate=48000&channels=2&channel=1" ``` **Python (requests):** ```python import requests with open("aufnahme.wav", "rb") as fh: r = requests.post("http://127.0.0.1:9998/transcribe", data=fh, params={"language": "de"}, timeout=120) r.raise_for_status() print(r.json()["text"]) ``` **ESP32 (Arduino, Skizze — nicht getestet):** Eine mit I²S aufgenommene Äußerung als PCM hochladen. ```cpp #include #include // pcm: int16_t-Puffer, 16 kHz mono, samples = Anzahl Samples String transcribe(const int16_t* pcm, size_t samples) { HTTPClient http; http.begin("http://192.168.1.10:9998/transcribe?format=pcm&sample_rate=16000&language=de"); http.addHeader("Content-Type", "application/octet-stream"); http.setTimeout(15000); int code = http.POST((uint8_t*)pcm, samples * sizeof(int16_t)); String body = code == 200 ? http.getString() : String(); http.end(); return body; // JSON; Feld "text" z. B. mit ArduinoJson auslesen } ``` Speicherbedarf: 1 s Audio = 32 KB. Für längere Aufnahmen den [WebSocket](#ws-wsstream-live-streaming) nutzen. --- ## `WS /ws/stream` (Live-Streaming) Der Client schickt fortlaufend Audio, der Dienst **erkennt Äußerungen selbst** (Silero-VAD: Sprache → Stille ≥ `silence_ms`) und schickt pro Äußerung ein Transkript. Die Verbindung bleibt für beliebig viele Äußerungen offen. Äußerungen werden der Reihe nach transkribiert; der Empfang läuft währenddessen weiter. ### Ablauf ```mermaid sequenceDiagram participant C as Client participant S as STT-Dienst C->>S: WebSocket verbinden S-->>C: {"type":"ready"} C->>S: {"type":"start", "sample_rate":48000, ...} (optional) S-->>C: {"type":"ready"} loop fortlaufend C->>S: Binär: PCM s16le end S-->>C: {"type":"speech_start","t":1.31} Note over S: Stille ≥ silence_ms → Äußerung fertig S-->>C: {"type":"transcript","text":"…","reason":"silence"} C->>S: {"type":"end"} S-->>C: {"type":"transcript", "reason":"flush"} (falls noch Sprache lief) S-->>C: {"type":"done"} C->>S: {"type":"close"} S-->>C: Verbindung geschlossen ``` ### Client → Dienst **Binär-Frames:** PCM s16le in beliebig großen Stücken (empfohlen 20–100 ms). Kanalzahl und Abtastrate gemäß `start`-Frame (Standard 16 kHz mono). **`start`** (optional, jederzeit; setzt die Sitzung zurück): ```json {"type": "start", "sample_rate": 16000, "channels": 1, "channel": null, "language": "de", "prompt": null, "vad": true, "silence_ms": 700, "threshold": 0.5, "min_speech_ms": 200, "pre_roll_ms": 300, "max_utterance_s": 30} ``` | Feld | Standard | Beschreibung | |---|---|---| | `sample_rate` | `16000` | Abtastrate der Binär-Frames | | `channels` | `1` | Kanalzahl der Binär-Frames | | `channel` | `null` | einen Kanal auswählen (ReSpeaker XVF3800: `1`), sonst Mittelwert | | `language`, `prompt` | – | wie bei HTTP | | `vad` | `true` | `true` = Äußerungen automatisch erkennen · `false` = sammeln bis `end` (Push-to-Talk) | | `silence_ms` | `700` | Stille, die eine Äußerung beendet (Diktat mit Denkpausen: 1200–2000) | | `threshold` | `0.5` | Silero-Sprachwahrscheinlichkeit (höher = unempfindlicher gegen Geräusche) | | `min_speech_ms` | `200` | Mindestdauer Sprache, bevor eine Äußerung beginnt (filtert Klicks) | | `pre_roll_ms` | `300` | Audio vor dem Sprachbeginn mitnehmen (verhindert abgeschnittene Anlaute) | | `max_utterance_s` | `30` | harte Obergrenze; längere Äußerungen werden geteilt (`reason: max_length`) | Nicht angegebene VAD-Werte kommen aus der Dienst-Konfiguration (`STT_VAD_*`). **`end`** — laufende Äußerung sofort abschließen (mit VAD) bzw. alles bisher Gesendete transkribieren (ohne VAD). Nach allen daraus entstehenden Transkripten folgt `done`. Die Verbindung bleibt offen. **`close`** — wie `end`, danach schließt der Dienst die Verbindung, sobald ausstehende Transkripte gesendet sind. ### Dienst → Client | `type` | Felder | Bedeutung | |---|---|---| | `ready` | `sample_rate`, `vad` | nach Verbindungsaufbau und nach jedem `start` | | `speech_start` | `t` | Sprachbeginn erkannt (Stream-Zeit in s) — z. B. für eine „hört zu“-LED | | `transcript` | Transkript-Objekt ohne `segments`, plus `reason`, `t` | Ergebnis einer Äußerung; `text` kann leer sein (nur Geräusch) | | `done` | – | alle durch `end` ausgelösten Transkripte sind gesendet | | `error` | `detail` | z. B. ungültiger Frame, Warteschlange voll; Verbindung bleibt bestehen | `reason`: `silence` (Stille erkannt) · `max_length` (Obergrenze erreicht) · `flush` (durch `end`/`close`). ### Beispiel: Python-Client (Datei in Echtzeit streamen) ```python import json, threading, time, wave from websockets.sync.client import connect with wave.open("aufnahme.wav", "rb") as wav, connect("ws://127.0.0.1:9998/ws/stream") as ws: rate, channels = wav.getframerate(), wav.getnchannels() # WAV muss 16 Bit sein ws.recv() # ready ws.send(json.dumps({"type": "start", "sample_rate": rate, "channels": channels})) ws.recv() # ready def receive(): for raw in ws: event = json.loads(raw) if event["type"] == "transcript" and event["text"]: print(event["text"]) if event["type"] == "done": return receiver = threading.Thread(target=receive) receiver.start() block = rate // 10 # 100 ms while chunk := wav.readframes(block): ws.send(chunk) time.sleep(0.1) # Echtzeit simulieren ws.send(json.dumps({"type": "end"})) receiver.join() ``` Ein vollständiger Mikrofon-Client ist `stt_cli.py` (Funktion `service_mic`). ### Hinweise - Der Sprachbeginn wird nach ca. `min_speech_ms`, das Ende nach `silence_ms` erkannt; das Transkript kommt typischerweise **~1 s nach Sprechende** (700 ms Stille + ~0,2 s Rechenzeit auf GPU). - Wer schneller senden kann als Echtzeit (z. B. Datei), darf das — die Äußerungserkennung arbeitet auf der Stream-Zeit, nicht auf der Uhrzeit. - Pro Verbindung eine eigene Sitzung; mehrere Verbindungen teilen sich den einen Worker (Reihenfolge über alle Verbindungen, Warteschlangenlimit gilt gesamt). --- ## Wyoming (Home Assistant) TCP-Server nach dem [Wyoming-Protokoll](https://github.com/OHF-Voice/wyoming) auf Port **10300** (`STT_WYOMING_PORT`, leer = deaktiviert). **Einrichten in Home Assistant:** Einstellungen → Geräte & Dienste → Integration hinzufügen → **Wyoming Protocol** → Host = IP dieses Rechners, Port `10300`. Anschließend unter Einstellungen → Sprachassistenten die Pipeline öffnen und als **Spracherkennung** „speech-to-text“ wählen. | Ereignis (HA → Dienst) | Verarbeitung | |---|---| | `describe` | Antwort `info`: Programm `speech-to-text`, Modellname, unterstützte Sprachen | | `transcribe` | merkt `language`; `transcript_names`/`transcript_terms` werden als Whisper-`hotwords` genutzt | | `audio-start` / `audio-chunk` | Audio sammeln; beliebige Rate/Bittiefe/Kanäle werden nach 16 kHz mono konvertiert | | `audio-stop` | transkribieren → `transcript` (`text`, `language`), danach Verbindung schließen | Das Äußerungsende bestimmt Home Assistant bzw. der Satellit (ESPHome) selbst. Bei einem Fehler wird ein leeres Transkript gesendet, damit HA nicht hängen bleibt. Test ohne Home Assistant (Python, Paket `wyoming`): ```python import asyncio, wave from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient async def main(): with wave.open("aufnahme.wav", "rb") as wav: rate, width, ch = wav.getframerate(), wav.getsampwidth(), wav.getnchannels() audio = wav.readframes(wav.getnframes()) async with AsyncTcpClient("127.0.0.1", 10300) as client: await client.write_event(Transcribe(language="de").event()) await client.write_event(AudioStart(rate=rate, width=width, channels=ch).event()) step = rate * width * ch // 10 for i in range(0, len(audio), step): await client.write_event(AudioChunk(rate=rate, width=width, channels=ch, audio=audio[i:i + step]).event()) await client.write_event(AudioStop().event()) print(Transcript.from_event(await client.read_event()).text) asyncio.run(main()) ```