whisper-stt-cli/stt_cli.py
dschlueter 6ebea4b745 CLI: --until-enter und optionale LLM-Korrektur (--llm-correct)
- --until-enter: Aufnahme mit beliebig langen Pausen, Enter beendet, Ausgabe als ein Text
- stt_llm.py: Nachkorrektur per lokalem LLM (Standard gemma4:12b über Ollama nativ mit
  kleinem Kontext; OpenAI-kompatible Server möglich), strenger Prompt mit Beispielen,
  Ähnlichkeitsprüfung und Fehler-Fallback auf den Rohtext
- Output-Klasse in der CLI: Korrektur in eigenem Thread, Reihenfolge bleibt erhalten
- Tests für die LLM-Korrektur (13 Unit, 1 Integration)
- Doku: Bedienungsanleitung, README, Entwicklerdoku (inkl. Modellvergleich), CLAUDE.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:48:02 +02:00

525 lines
21 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 --until-enter # Pausen erlaubt, Enter beendet → ein Text
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 --until-enter --llm-correct # zusätzlich per lokalem LLM korrigieren
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)
class Output:
"""Gibt Ergebnisse in Reihenfolge aus — optional nach LLM-Korrektur (--llm-correct).
Die Korrektur läuft in einem eigenen Thread, damit Aufnahme und Empfang nie warten.
"""
def __init__(self, args):
self.as_json = args.json
self.verbose = args.verbose
self.corrector = None
self._executor = None
if getattr(args, "llm_correct", False):
from concurrent.futures import ThreadPoolExecutor
import stt_llm
cfg = stt_llm.LlmConfig.from_env()
if args.llm_model:
cfg.model = args.llm_model
if args.llm_url:
cfg.url = args.llm_url
self.corrector = stt_llm.LlmCorrector(cfg)
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="llm")
def warmup(self) -> None:
"""LLM laden, während der Nutzer schon spricht."""
if self.corrector is not None:
status(f"LLM-Korrektur aktiv: {self.corrector.config.model} (wird im Hintergrund geladen)")
self._executor.submit(self.corrector.warmup)
def put(self, result: dict, source: str | None = None) -> None:
if self.corrector is None:
self._emit(result, source)
else:
self._executor.submit(self._correct_and_emit, result, source)
def _correct_and_emit(self, result: dict, source: str | None) -> None:
raw = result.get("text", "")
if raw:
c = self.corrector.correct(raw)
result = {**result, "text": c.text, "raw_text": raw, "llm": c.info()}
if self.verbose or c.status in ("rejected", "error"):
labels = {"corrected": "korrigiert", "unchanged": "unverändert",
"rejected": "verworfen, Rohtext behalten", "error": "Fehler, Rohtext behalten"}
note = f" — {c.detail}" if c.detail else ""
status(f" LLM: {labels.get(c.status, c.status)} ({c.seconds:.2f}s){note}")
if self.verbose and c.changed:
status(f" roh: {raw}")
self._emit(result, source)
def _emit(self, result: dict, source: str | None) -> None:
emit(result if self.as_json else {"text": result.get("text", "")}, self.as_json, source)
def close(self) -> None:
if self._executor is not None:
self._executor.shutdown(wait=True)
def emit_joined(parts: list[dict], out: Output) -> str:
"""--until-enter: alle Abschnitte als EIN Ergebnis ausgeben."""
text = " ".join(p["text"] for p in parts if p.get("text")).strip()
out.put({"text": text, "parts": len(parts),
"duration": round(sum(p.get("duration", 0.0) for p in parts), 2),
"processing_s": round(sum(p.get("processing_s", 0.0) for p in parts), 3)})
return text
def start_enter_watcher() -> threading.Event:
"""Setzt das Event, sobald auf dem Terminal Enter gedrückt wird."""
pressed = threading.Event()
def wait():
try:
sys.stdin.readline()
except Exception:
pass
pressed.set()
threading.Thread(target=wait, daemon=True).start()
return pressed
# === 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, out: Output) -> 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}
parts: list[dict] = [] # --until-enter: Abschnitte sammeln statt sofort ausgeben
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)
enter = start_enter_watcher() if args.until_enter else None
status("Aufnahme läuft — Pausen sind erlaubt. [Enter] beendet." if enter
else "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)")
got_text.set()
if enter is not None:
parts.append(event)
if args.verbose:
status(f" + {event['text']}")
continue
out.put(event)
if args.once:
stop.set()
return
elif etype == "done" and enter is not None:
stop.set() # nach Enter: alle Abschnitte sind da
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
end_sent = False
try:
while not stop.is_set():
timed_out = bool(deadline and time.monotonic() > deadline)
if enter is not None and (enter.is_set() or timed_out):
if not end_sent:
status("Zeitlimit erreicht." if timed_out and not enter.is_set()
else "Aufnahme beendet — transkribiere den Rest …")
ws.send(json.dumps({"type": "end"})) # laufenden Abschnitt abschließen
end_sent = True
stop.wait(0.2) # auf "done" warten, kein Audio mehr senden
continue
if timed_out:
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"]
if enter is not None:
return 0 if emit_joined(parts, out) else 2
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, out: Output) -> 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)
out.put(tr.to_dict(), str(path))
code = 0 if tr.text else code
return code
def local_mic(args, out: Output) -> 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()
futures = [] # --until-enter: Reihenfolge der Abschnitte erhalten
def on_result(future):
tr = future.result()
if not tr.text:
return
if args.until_enter:
got_text.set()
if args.verbose:
status(f" + {tr.text}")
return
if not (args.once and got_text.is_set()):
out.put(tr.to_dict(with_segments=False))
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)
futures.append(fut)
enter = None
with Microphone(args.device, args.channel) as mic:
resampler = core.StreamResampler(mic.rate)
if args.until_enter:
enter = start_enter_watcher()
status("Aufnahme läuft — Pausen sind erlaubt. [Enter] beendet.")
else:
status("Bereit — sprich. (Strg+C beendet)")
deadline = time.monotonic() + args.timeout if args.timeout else None
try:
while not done.is_set():
if enter is not None and enter.is_set():
status("Aufnahme beendet — transkribiere den Rest …")
break
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)
if args.until_enter:
parts = [f.result().to_dict(with_segments=False) for f in futures]
return 0 if emit_joined(parts, out) else 2
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("--until-enter", action="store_true",
help="Aufnahme mit beliebig langen Pausen, bis Enter gedrückt wird; Ausgabe als EIN Text")
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("--llm-correct", action="store_true",
help="Ergebnis zusätzlich per lokalem LLM korrigieren (langsamer; Rohtext bleibt bei Zweifel)")
p.add_argument("--llm-model", default=None, help="LLM-Modell (Standard: gemma4:12b, env STT_LLM_MODEL)")
p.add_argument("--llm-url", default=None,
help="LLM-Server (Standard: http://127.0.0.1:11434 = Ollama, env STT_LLM_URL)")
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()
if args.until_enter:
if args.files or args.once:
status("Fehler: --until-enter ist nur für das Mikrofon und nicht zusammen mit --once nutzbar")
return 1
if not sys.stdin.isatty():
status("Fehler: --until-enter braucht ein Terminal für die Enter-Taste (stdin ist umgeleitet)")
return 1
if args.list_devices:
list_devices()
return 0
out = Output(args)
try:
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, out)
code = 2
for path in args.files:
result = service_file(args.url, path, args)
out.put(result, str(path))
code = 0 if result["text"] else code
return code
out.warmup()
return local_mic(args, out) if args.local else service_mic(args.url, args, out)
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
finally:
out.close() # ausstehende LLM-Korrekturen noch ausgeben
if __name__ == "__main__":
sys.exit(main())