fix(voice_loop): Keepalive-Timeout (1011) bei langer Aufnahme/Wiedergabe behoben

Eine Dauerverbindung wurde vom Server geschlossen, wenn Aufnahme/Wiedergabe den
Event-Loop blockierten (Keepalive-Ping unbeantwortet) - sichtbar bei langen
TTS-Antworten. Jetzt: kurze WebSocket-Verbindung PRO Sprech-Runde; das Audio wird
erst NACH dem Schliessen abgespielt. Gespraechsgedaechtnis bleibt serverseitig via
session_id erhalten. Verifiziert mit langer Antwort (~12 s Audio) ohne Abbruch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 11:00:13 +02:00
commit 4254475b73

View file

@ -131,7 +131,9 @@ def _start_frame(args) -> dict:
return frame
async def one_turn(ws, wav: bytes, start_frame: dict) -> None:
async def one_turn(ws, wav: bytes, start_frame: dict) -> bytes:
"""Sendet eine Aeusserung, gibt das Antwort-Audio (PCM) zurueck. Spielt NICHT ab,
damit die Wiedergabe ausserhalb der offenen Verbindung passieren kann."""
await ws.send(json.dumps(start_frame))
await ws.send(wav)
await ws.send(json.dumps({"type": "end"}))
@ -152,9 +154,19 @@ async def one_turn(ws, wav: bytes, start_frame: dict) -> None:
print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})")
elif etype == "error":
print(f" Fehler {event.get('status','')}: {event.get('detail')}")
return
return b""
elif etype == "done":
break
return audio
async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None:
"""Oeffnet eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via
session_id erhalten), empfaengt die Antwort und spielt sie NACH dem Schliessen ab.
So blockiert die (lange) Aufnahme/Wiedergabe nie eine offene WebSocket-Verbindung
(kein Keepalive-Timeout)."""
async with websockets.connect(url, max_size=None) as ws:
audio = await one_turn(ws, wav, start_frame)
if audio:
play_pcm(audio)
@ -165,32 +177,35 @@ async def run(args) -> None:
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)
if args.file:
with open(args.file, "rb") as fh:
wav = fh.read()
print(f"Sende Datei: {args.file}")
await _send_and_play(url, wav, start_frame)
return
recorder = resolve_recorder(args.recorder)
print(f"Ziel: {args.url} (Session '{args.session}')")
print(f"Aufnahme mit: {recorder}"
+ (f" (Gerät: {args.device})" if args.device else " (Standardgerät)"))
print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.")
while True:
try:
wav = record_utterance(recorder, args.device, args.rate)
except KeyboardInterrupt:
print("\nEnde.")
return
recorder = resolve_recorder(args.recorder)
print(f"Aufnahme mit: {recorder}"
+ (f" (Gerät: {args.device})" if args.device else " (Standardgerät)"))
print("Sprich nach 'START', stoppe mit Enter. Strg+C beendet den Loop.")
while True:
try:
wav = record_utterance(recorder, args.device, args.rate)
except KeyboardInterrupt:
print("\nEnde.")
return
if len(wav) < 1000:
print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug"
" oder Geraet nicht nutzbar.")
print(" Verfuegbare Mikrofone: arecord -l")
print(" Direktes ALSA-Geraet verwenden, z. B.:")
print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0")
continue
await one_turn(ws, wav, start_frame)
if len(wav) < 1000:
print(" ⚠ Keine/zu kurze Aufnahme. Moegliche Ursache: Aufnahmewerkzeug"
" oder Geraet nicht nutzbar.")
print(" Verfuegbare Mikrofone: arecord -l")
print(" Direktes ALSA-Geraet verwenden, z. B.:")
print(" python scripts/voice_loop.py --recorder arecord --device plughw:2,0")
continue
try:
await _send_and_play(url, wav, start_frame)
except Exception as exc: # noqa: BLE001 - Verbindung pro Runde; Fehler nicht fatal
print(f" Verbindungsfehler: {exc}")
def main() -> None: