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:
parent
340100210d
commit
6ebea4b745
1 changed files with 672 additions and 24 deletions
178
stt_cli.py
178
stt_cli.py
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue