#!/usr/bin/env python3 """Sprech-Loop: sprechen -> Antwort hoeren -> erneut sprechen. Nimmt vom Mikrofon auf (Push-to-Talk), schickt das Audio ueber EINE /ws/voice-Verbindung an das Gateway und spielt die Antwort ab. Das Gespraechs- gedaechtnis bleibt ueber die `session_id` erhalten. Beispiele: python scripts/voice_loop.py python scripts/voice_loop.py --url ws://localhost:8003/ws/voice --session oma-anna python scripts/voice_loop.py --device hw:1,0 # bestimmtes Mikrofon (arecord -L) python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter python scripts/voice_loop.py --file frage.wav # ohne Mikrofon (Test) Voraussetzungen: laufendes Gateway, `arecord` (Aufnahme), ein Player (`ffplay`/`aplay`/`paplay`), Python-Paket `websockets`. """ from __future__ import annotations import argparse import asyncio import io import json import shutil import signal import subprocess import sys import tempfile import wave try: import websockets except ModuleNotFoundError: sys.exit("Fehlt: Python-Paket 'websockets' (kommt mit uvicorn[standard]).") TTS_SAMPLE_RATE = 24000 # Antwort-Audio des Gateways (s16le, mono) def _require(tool: str) -> str: path = shutil.which(tool) if not path: sys.exit(f"Fehlt: '{tool}' nicht gefunden. Bitte installieren.") return path def _first_player() -> list[str] | None: if shutil.which("ffplay"): return ["ffplay", "-loglevel", "quiet", "-nodisp", "-autoexit"] if shutil.which("aplay"): return ["aplay", "-q"] if shutil.which("paplay"): return ["paplay"] return None def record_utterance(device: str | None, rate: int) -> bytes: """Push-to-Talk: Enter startet, Enter stoppt die Aufnahme; gibt WAV-Bytes zurueck.""" _require("arecord") tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.close() cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(rate), "-c", "1"] if device: cmd += ["-D", device] cmd.append(tmp.name) input("\n[Enter] = Aufnahme START …") proc = subprocess.Popen(cmd) input("[Enter] = Aufnahme STOP …") proc.send_signal(signal.SIGINT) proc.wait(timeout=5) with open(tmp.name, "rb") as fh: return fh.read() def play_pcm(pcm: bytes) -> None: player = _first_player() if not player: print("(kein Player gefunden – Antwort-Audio wird nicht abgespielt)") return buf = io.BytesIO() with wave.open(buf, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(TTS_SAMPLE_RATE) w.writeframes(pcm) if player[0] == "ffplay": # ffplay liest WAV von stdin via pipe:0 subprocess.run([*player, "-i", "pipe:0"], input=buf.getvalue()) else: subprocess.run(player + ["/dev/stdin"], input=buf.getvalue()) def _start_frame(args) -> dict: frame = {"type": "start", "format": "wav"} for key in ("stt_provider", "llm_provider", "tts_provider", "language"): value = getattr(args, key, None) if value: frame[key] = value return frame async def one_turn(ws, wav: bytes, start_frame: dict) -> None: await ws.send(json.dumps(start_frame)) await ws.send(wav) await ws.send(json.dumps({"type": "end"})) audio = b"" while True: msg = await ws.recv() if isinstance(msg, (bytes, bytearray)): audio = bytes(msg) continue event = json.loads(msg) etype = event.get("type") if etype == "transcript": print(f" Du: {event.get('text','')!r}") elif etype == "semantic": print(f" Assistent: {event.get('text','')!r}") elif etype == "emergency": print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})") elif etype == "error": print(f" Fehler {event.get('status','')}: {event.get('detail')}") return elif etype == "done": break if audio: play_pcm(audio) async def run(args) -> None: url = f"{args.url}?session_id={args.session}" if args.token: url += f"&token={args.token}" start_frame = _start_frame(args) async with websockets.connect(url, max_size=None) as ws: print(f"Verbunden: {args.url} (Session '{args.session}')") if args.file: with open(args.file, "rb") as fh: wav = fh.read() print(f"Sende Datei: {args.file}") await one_turn(ws, wav, start_frame) return print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.") while True: try: wav = record_utterance(args.device, args.rate) except KeyboardInterrupt: print("\nEnde.") return if len(wav) < 1000: print(" (zu kurz/leer – nochmal)") continue await one_turn(ws, wav, start_frame) def main() -> None: p = argparse.ArgumentParser(description="Sprech-Loop fuer das Voice-Assistant-Gateway") p.add_argument("--url", default="ws://127.0.0.1:8003/ws/voice") p.add_argument("--session", default="voice-loop") p.add_argument("--token", default=None, help="Bearer-Token, falls AUTH_ENABLED=true") p.add_argument("--device", default=None, help="Aufnahmegeraet (siehe: arecord -L)") p.add_argument("--rate", type=int, default=16000) p.add_argument("--stt-provider", dest="stt_provider", default=None) p.add_argument("--llm-provider", dest="llm_provider", default=None) p.add_argument("--tts-provider", dest="tts_provider", default=None) p.add_argument("--language", default=None) p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)") args = p.parse_args() try: asyncio.run(run(args)) except KeyboardInterrupt: print("\nEnde.") if __name__ == "__main__": main()