whisper-stt-cli/docs/API.md
dschlueter 340100210d Dokumentation: Bedienungsanleitung, API-Referenz, Entwicklerdoku, README überarbeitet
- 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>
2026-09-13 23:29:33 +02:00

15 KiB
Raw Blame History

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, /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:

{
  "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 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 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())