- BEDIENUNGSANLEITUNG.md: Installation, Betrieb, CLI, Mikrofon, Home Assistant, Einstellungen, Modellwahl, Fehlerbehebung, Datenschutz - docs/API.md: HTTP, WebSocket-Protokoll, Wyoming, Fehlercodes, geprüfte Beispiele - docs/ENTWICKLER.md: Architektur, Module, Nebenläufigkeit, Tests, Erweitern, Designentscheidungen - CLAUDE.md: Verweis auf die Dokumentation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
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 |
http://<host>:9998/health |
Überwachung, Bereitschaft prüfen |
POST /v1/audio/transcriptions |
multipart | Tools mit OpenAI-Client, Web-Upload |
POST /transcribe |
Rohdaten im Body | Mikrocontroller, curl, einfache Skripte |
WS /ws/stream |
WebSocket | Live-Mikrofon, fortlaufendes Diktat, Push-to-Talk |
| Wyoming | tcp://<host>:10300 |
Home Assistant, ESPHome-Satelliten |
Interaktive Doku (Swagger) mit „Try it out“: http://<host>: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,/transcribeohneformat=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:
{
"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
curl http://127.0.0.1:9998/health
{
"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.
Bestehende OpenAI-Clients funktionieren, wenn man die Basis-URL auf http://<host>:9998/v1 setzt.
Form-Felder (multipart/form-data):
| Feld | Pflicht | Beschreibung |
|---|---|---|
file |
ja | Audiodatei |
language |
nein | siehe Sprache |
prompt |
nein | siehe Prompt |
response_format |
nein | json (Standard) · text · verbose_json |
model, temperature |
nein | werden akzeptiert, aber ignoriert (ein Modell pro Dienst) |
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:
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 |
prompt |
– | siehe Prompt |
Antwort: vollständiges Transkript-Objekt.
# 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):
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.
#include <WiFi.h>
#include <HTTPClient.h>
// 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 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
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):
{"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)
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 nachsilence_mserkannt; 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 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):
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())