whisper-stt-cli/stt_cli.py
dschlueter a986cbe42a 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>
2026-09-13 21:34:03 +02:00

379 lines
15 KiB
Python
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.

#!/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())