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>
This commit is contained in:
Dieter Schlüter 2026-09-14 00:48:02 +02:00
commit 6ebea4b745

View file

@ -10,8 +10,10 @@ Beispiele:
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)
@ -45,6 +47,88 @@ def emit(result: dict, as_json: bool, source: str | None = None) -> None:
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:
@ -164,13 +248,14 @@ def service_file(url: str, path: Path, args) -> dict:
return r.json()
def service_mic(url: str, args) -> int:
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
@ -180,7 +265,9 @@ def service_mic(url: str, args) -> int:
start["silence_ms"] = args.silence_ms
ws.send(json.dumps(start))
ws.recv() # ready (neu konfiguriert)
status("Bereit — sprich. (Strg+C beendet)")
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:
@ -194,11 +281,19 @@ def service_mic(url: str, args) -> int:
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 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
@ -211,9 +306,19 @@ def service_mic(url: str, args) -> int:
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():
if deadline and time.monotonic() > deadline:
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:
@ -233,6 +338,8 @@ def service_mic(url: str, args) -> int:
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
@ -253,7 +360,7 @@ def _local_engine(args):
return engine
def local_files(paths: list[Path], args) -> int:
def local_files(paths: list[Path], args, out: Output) -> int:
import stt_core as core
engine = _local_engine(args)
@ -261,12 +368,12 @@ def local_files(paths: list[Path], args) -> int:
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))
out.put(tr.to_dict(), str(path))
code = 0 if tr.text else code
return code
def local_mic(args) -> int:
def local_mic(args, out: Output) -> int:
from concurrent.futures import ThreadPoolExecutor
import stt_core as core
@ -279,11 +386,19 @@ def local_mic(args) -> int:
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 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)
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()
@ -292,13 +407,22 @@ def local_mic(args) -> int:
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)
status("Bereit — sprich. (Strg+C beendet)")
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
@ -317,6 +441,9 @@ def local_mic(args) -> int:
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
@ -336,33 +463,50 @@ def main() -> int:
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.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)
return local_files(args.files, args, out)
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))
out.put(result, str(path))
code = 0 if result["text"] else code
return code
return local_mic(args) if args.local else service_mic(args.url, args)
out.warmup()
return local_mic(args, out) if args.local else service_mic(args.url, args, out)
except KeyboardInterrupt:
return 130
except SystemExit:
@ -373,6 +517,8 @@ def main() -> int:
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__":