STT-Dienst: Kern, HTTP/WebSocket/Wyoming-Service, CLI, Tests, systemd-Unit

- stt_core.py: Whisper-Engine (einmal laden, CPU-Fallback), Audio-Dekodierung
  jedes Formats, Halluzinationsfilter, Äußerungserkennung per Silero-VAD
- stt_service.py: /health, /v1/audio/transcriptions (OpenAI), /transcribe (Rohdaten),
  WS /ws/stream; Inferenz im Worker-Thread, begrenzte Warteschlange (503)
- stt_wyoming.py: Home-Assistant-Anbindung (TCP 10300)
- stt_cli.py: Datei/Mikrofon über den Dienst oder lokal
- Tests (Unit + Integration), README, requirements.txt, deploy/stt-service.service

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-13 21:34:03 +02:00
commit a986cbe42a

379
stt_cli.py Normal file
View file

@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""STT-CLI – Client für stt_service.py (oder lokal ohne Dienst).
Nur Transkripte gehen auf stdout (eine Zeile pro Äußerung bzw. Datei); alle
Statusmeldungen auf stderr. Dadurch direkt in Skripten nutzbar:
text=$(python stt_cli.py --once)
Beispiele:
python stt_cli.py aufnahme.wav # Datei an den Dienst schicken
python stt_cli.py *.mp3 --json # mehrere Dateien, JSON-Zeilen
python stt_cli.py # Mikrofon → fortlaufend transkribieren
python stt_cli.py --once # nach der ersten Äußerung beenden
python stt_cli.py -d respeaker # ReSpeaker (rechter ASR-Kanal automatisch)
python stt_cli.py --local aufnahme.wav # ohne Dienst (lädt Modell selbst)
python stt_cli.py --list-devices
Exit-Codes: 0 ok, 1 Fehler, 2 keine Sprache erkannt (--once/--timeout, Dateien)
"""
from __future__ import annotations
import argparse
import json
import os
import queue
import sys
import threading
import time
from pathlib import Path
DEFAULT_URL = os.environ.get("STT_URL", "http://127.0.0.1:9998")
BLOCK_SEC = 0.1
def status(msg: str) -> None:
print(msg, file=sys.stderr, flush=True)
def emit(result: dict, as_json: bool, source: str | None = None) -> None:
if as_json:
if source:
result = {"source": source, **result}
print(json.dumps(result, ensure_ascii=False), flush=True)
else:
print(result.get("text", ""), flush=True)
# === Geräte ===================================================================
def list_devices() -> None:
import sounddevice as sd
default_in = sd.default.device[0]
for i, dev in enumerate(sd.query_devices()):
if dev["max_input_channels"] > 0:
mark = "*" if i == default_in else " "
print(f"{mark}[{i:>2}] {dev['name']} ({dev['max_input_channels']} Kanäle, "
f"{int(dev['default_samplerate'])} Hz)")
SYSTEM_DEVICES = ("pipewire", "pulse") # ALSA-Plugins, die dem System-Default folgen
def pulse_default_source() -> str:
"""Name der System-Standardquelle (PipeWire/PulseAudio), '' wenn unbekannt."""
import subprocess
try:
return subprocess.run(["pactl", "get-default-source"], capture_output=True,
text=True, timeout=3).stdout.strip()
except Exception:
return ""
def resolve_input(spec: str | None) -> tuple[int | None, dict]:
"""None → System-Default über PipeWire/Pulse; Zahl → Index; Text → Namenssuche.
PortAudios eigener Default ist auf PipeWire-Systemen oft die erste ALSA-Karte
(nicht das eingestellte Mikrofon) — daher bevorzugt das 'pulse'-Gerät.
"""
import sounddevice as sd
devices = sd.query_devices()
if spec is None:
for name in SYSTEM_DEVICES:
for i, dev in enumerate(devices):
if dev["name"] == name and dev["max_input_channels"] > 0:
return i, dev
idx = sd.default.device[0]
return (None, sd.query_devices(kind="input")) if idx is None or idx < 0 else (idx, devices[idx])
if spec.isdigit():
idx = int(spec)
if idx < len(devices) and devices[idx]["max_input_channels"] > 0:
return idx, devices[idx]
raise SystemExit(f"Gerät [{idx}] existiert nicht oder hat keine Eingänge (--list-devices)")
for i, dev in enumerate(devices):
if spec.lower() in dev["name"].lower() and dev["max_input_channels"] > 0:
return i, dev
raise SystemExit(f"Kein Eingabegerät mit '{spec}' gefunden (--list-devices)")
def is_respeaker(name: str) -> bool:
return any(k in name.lower() for k in ("xvf3800", "respeaker"))
class Microphone:
"""Nimmt im Callback auf und legt s16le-Mono-Blöcke in eine Queue — der
Audio-Thread wird nie durch Netzwerk oder Inferenz aufgehalten."""
def __init__(self, device: str | None, channel: int | None):
import sounddevice as sd
self.index, info = resolve_input(device)
self.name = info["name"]
self.channels = min(int(info["max_input_channels"]), 2)
if self.name in SYSTEM_DEVICES:
# PipeWire resampelt selbst → direkt in Whisper-Rate aufnehmen
self.rate = 16000
source = pulse_default_source()
if source:
self.name = f"{self.name} → {source}"
else:
self.rate = int(info["default_samplerate"])
if channel is None and is_respeaker(self.name) and self.channels >= 2:
channel = 1 # XVF3800: rechter Kanal = ASR-optimierter Ausgang
self.channel = channel
self.blocks: queue.Queue[bytes] = queue.Queue(maxsize=600) # ~60 s Puffer
self.dropped = 0
self._stream = sd.InputStream(
device=self.index, samplerate=self.rate, channels=self.channels, dtype="int16",
blocksize=int(self.rate * BLOCK_SEC), callback=self._callback,
)
def _callback(self, indata, frames, time_info, flags):
import numpy as np
data = indata[:, self.channel] if self.channel is not None else indata.mean(axis=1)
try:
self.blocks.put_nowait(np.ascontiguousarray(data, dtype="<i2").tobytes())
except queue.Full:
self.dropped += 1
def __enter__(self):
ch = f", Kanal {self.channel}" if self.channel is not None else ""
status(f"Mikrofon: {self.name} ({self.rate} Hz, {self.channels} Kanäle{ch})")
self._stream.start()
return self
def __exit__(self, *exc):
self._stream.stop()
self._stream.close()
# === Dienst-Modus =============================================================
def service_file(url: str, path: Path, args) -> dict:
import requests
params = {k: v for k, v in (("language", args.language), ("prompt", args.prompt)) if v}
with open(path, "rb") as fh:
r = requests.post(f"{url}/transcribe", params=params, data=fh, timeout=args.http_timeout)
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
return r.json()
def service_mic(url: str, args) -> int:
from websockets.sync.client import connect
ws_url = url.replace("http://", "ws://").replace("https://", "wss://").rstrip("/") + "/ws/stream"
got_text = threading.Event()
stop = threading.Event()
result = {"code": 0}
with Microphone(args.device, args.channel) as mic, connect(ws_url, max_size=None) as ws:
ws.recv() # ready
start = {"type": "start", "sample_rate": mic.rate, "language": args.language,
"prompt": args.prompt}
if args.silence_ms:
start["silence_ms"] = args.silence_ms
ws.send(json.dumps(start))
ws.recv() # ready (neu konfiguriert)
status("Bereit — sprich. (Strg+C beendet)")
def receiver():
try:
for raw in ws:
event = json.loads(raw)
etype = event.get("type")
if etype == "speech_start" and args.verbose:
status(f" … Sprache erkannt ({event['t']:.1f}s)")
elif etype == "transcript":
if not event["text"]:
continue
if args.verbose:
status(f" ({event['duration']:.1f}s Audio, {event['processing_s']:.2f}s Rechenzeit)")
emit(event if args.json else {"text": event["text"]}, args.json)
got_text.set()
if args.once:
stop.set()
return
elif etype == "error":
status(f" Fehler vom Dienst: {event.get('detail')}")
stop.set() # Dienst hat die Verbindung regulär geschlossen
except Exception as exc: # Verbindung weg
if not stop.is_set():
status(f"Verbindung beendet: {exc}")
result["code"] = 1
stop.set()
rx = threading.Thread(target=receiver, daemon=True)
rx.start()
deadline = time.monotonic() + args.timeout if args.timeout else None
try:
while not stop.is_set():
if deadline and time.monotonic() > deadline:
status("Zeitlimit erreicht.")
break
try:
ws.send(mic.blocks.get(timeout=0.2))
except queue.Empty:
continue
except KeyboardInterrupt:
status("\nBeendet.")
finally:
if mic.dropped:
status(f"Warnung: {mic.dropped} Audioblöcke verworfen (Netz zu langsam?)")
if not stop.is_set():
try:
ws.send(json.dumps({"type": "close"}))
rx.join(timeout=60) # ausstehende Transkripte noch abholen
except Exception:
pass
if result["code"]:
return result["code"]
return 0 if got_text.is_set() or not (args.once or args.timeout) else 2
# === Lokaler Modus (ohne Dienst) ==============================================
def _local_engine(args):
import stt_core as core
cfg = core.EngineConfig.from_env()
if args.model:
cfg.model = cfg.cpu_model = args.model
if args.compute_device:
cfg.device = args.compute_device
engine = core.SttEngine(cfg)
status("Lade Modell …")
engine.load()
status(f"Modell {engine.active_model} auf {engine.active_device} bereit ({engine.load_seconds:.1f}s)")
return engine
def local_files(paths: list[Path], args) -> int:
import stt_core as core
engine = _local_engine(args)
code = 2
for path in paths:
audio = core.decode_audio_bytes(path.read_bytes())
tr = engine.transcribe(audio, language=args.language, initial_prompt=args.prompt)
emit(tr.to_dict() if args.json else {"text": tr.text}, args.json, str(path))
code = 0 if tr.text else code
return code
def local_mic(args) -> int:
from concurrent.futures import ThreadPoolExecutor
import stt_core as core
engine = _local_engine(args)
vad = core.VadConfig.from_env()
if args.silence_ms:
vad.silence_ms = args.silence_ms
segmenter = core.UtteranceSegmenter(vad)
executor = ThreadPoolExecutor(max_workers=1) # Inferenz getrennt von der Aufnahme
done = threading.Event()
got_text = threading.Event()
def on_result(future):
tr = future.result()
if tr.text and not (args.once and got_text.is_set()):
emit(tr.to_dict(with_segments=False) if args.json else {"text": tr.text}, args.json)
got_text.set()
if args.once:
done.set()
def submit(event):
fut = executor.submit(engine.transcribe, event.audio, language=args.language,
initial_prompt=args.prompt)
fut.add_done_callback(on_result)
with Microphone(args.device, args.channel) as mic:
resampler = core.StreamResampler(mic.rate)
status("Bereit — sprich. (Strg+C beendet)")
deadline = time.monotonic() + args.timeout if args.timeout else None
try:
while not done.is_set():
if deadline and time.monotonic() > deadline:
status("Zeitlimit erreicht.")
break
try:
block = mic.blocks.get(timeout=0.2)
except queue.Empty:
continue
audio = resampler.process(core.pcm16_to_float(block))
for event in segmenter.feed(audio):
if event.kind == "speech_start" and args.verbose:
status(f" … Sprache erkannt ({event.time_s:.1f}s)")
elif event.kind == "utterance":
submit(event)
except KeyboardInterrupt:
status("\nBeendet.")
for event in segmenter.flush():
submit(event)
executor.shutdown(wait=True)
return 0 if got_text.is_set() or not (args.once or args.timeout) else 2
# === Main =====================================================================
def main() -> int:
p = argparse.ArgumentParser(
description="STT-CLI: Datei oder Mikrofon → Text (über stt_service oder lokal)",
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("Beispiele:")[1],
)
p.add_argument("files", nargs="*", type=Path, help="Audiodateien (WAV, MP3, OGG, …); ohne = Mikrofon")
p.add_argument("--url", default=DEFAULT_URL, help=f"Dienst-URL (Standard: {DEFAULT_URL}, env STT_URL)")
p.add_argument("--local", action="store_true", help="ohne Dienst: Modell im Prozess laden")
p.add_argument("-l", "--language", default=None, help="Sprachcode (de, en, …) oder 'auto'; Standard: Dienst-Default")
p.add_argument("--prompt", default=None, help="Kontext/Eigennamen für Whisper")
p.add_argument("--json", action="store_true", help="JSON-Zeilen statt reinem Text")
p.add_argument("-d", "--device", default=None, help="Eingabegerät: Index oder Namensteil (Standard: System-Default)")
p.add_argument("--channel", type=int, default=None, help="Kanal wählen (ReSpeaker: automatisch 1)")
p.add_argument("--once", action="store_true", help="nach der ersten erkannten Äußerung beenden")
p.add_argument("--timeout", type=float, default=None, help="Mikrofon-Modus nach N Sekunden beenden")
p.add_argument("--silence-ms", type=int, default=None, help="Stille bis Äußerungsende (Standard: 700)")
p.add_argument("--model", default=None, help="nur --local: Whisper-Modell")
p.add_argument("--compute-device", choices=["auto", "cuda", "cpu"], default=None, help="nur --local")
p.add_argument("--http-timeout", type=float, default=300.0)
p.add_argument("--list-devices", action="store_true", help="Eingabegeräte auflisten")
p.add_argument("-v", "--verbose", action="store_true", help="Statusdetails auf stderr")
args = p.parse_args()
try:
if args.list_devices:
list_devices()
return 0
if args.files:
missing = [f for f in args.files if not f.is_file()]
if missing:
status(f"Datei nicht gefunden: {', '.join(map(str, missing))}")
return 1
if args.local:
return local_files(args.files, args)
code = 2
for path in args.files:
result = service_file(args.url, path, args)
emit(result if args.json else {"text": result["text"]}, args.json, str(path))
code = 0 if result["text"] else code
return code
return local_mic(args) if args.local else service_mic(args.url, args)
except KeyboardInterrupt:
return 130
except SystemExit:
raise
except Exception as exc:
hint = ""
if "Connection refused" in str(exc) or "ConnectionRefused" in type(exc).__name__:
hint = f"\nLäuft der Dienst? (systemctl --user status stt-service, {args.url}/health) — oder --local nutzen."
status(f"Fehler: {exc}{hint}")
return 1
if __name__ == "__main__":
sys.exit(main())