my_voice_assistant_v3_jamulix/scripts/voice_loop.py
Dieter Schlüter 7896f608fb docs: Bedienungsanleitung (Sprech-Loop, Einstellungen, Praxis-Tests) + voice_loop.py
- NEU scripts/voice_loop.py: Mikrofon -> /ws/voice -> Wiedergabe im Loop, mit
  Gedaechtnis (--session), Geraete-/Provider-Optionen, --file fuer Test ohne Mikrofon
- BEDIENUNGSANLEITUNG.md neu strukturiert:
  Teil A (sprechen->hoeren->sprechen: voice_loop + manueller Loop + chat_client),
  Teil B (Einstellungen: KI/Provider auf allen Ebenen real; Sound-Quelle/-Ausgabe
  ehrlich auf OS-Ebene, Gateway-Endpunkte als vorbereitete Routing-Ebene),
  Teil C (Praxis-Tests + gemessene Reaktionszeiten: STT ~1,2s / LLM ~0,7s / TTS ~1,9s
  / Round-Trip ~4s; Konstellations-Empfehlung)
- Alle JSON-Befehle mit '| jq'; Audio-Befehle in Datei + Player
- README: kurzer Verweis auf den Sprech-Loop
- live verifiziert (make smoke, Timings, voice_loop --file); 64 Tests gruen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:31:35 +02:00

178 lines
5.9 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
"""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()