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>
This commit is contained in:
parent
7a4d0e2885
commit
340100210d
1 changed files with 1430 additions and 89 deletions
384
docs/API.md
Normal file
384
docs/API.md
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
# 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())
|
||||
```
|
||||
Loading…
Reference in a new issue