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

384 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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://<host>: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://<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:
```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://<host>: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 <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](#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())
```