diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 9048b9b..b15614f 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -531,7 +531,7 @@ python scripts/voice_loop.py --session mein-gespraech **Ablauf je Runde:** 1. **[Enter]** → sprechen 2. **[Enter]** → Aufnahme stoppt, Assistent antwortet hörbar -3. **[Enter]** während der Wiedergabe → **Barge-in:** Antwort sofort unterbrechen +3. **[Enter]** während der KI antwortet (Text streamt *oder* Audio spielt) → **Barge-in:** Antwort sofort unterbrechen 4. **[Enter]** → nächste Runde beginnen 5. **Strg + C** → beenden @@ -916,7 +916,7 @@ python scripts/voice_loop.py --no-stream-audio **Barge-in** (laufende Antwort unterbrechen): - **Web-Interface:** Mic-Button ⏹ (amber) während der KI-Antwort tippen → Wiedergabe stoppt sofort, Server bricht Generierung ab. -- **Terminal:** `[Enter]` während der Wiedergabe drücken → dasselbe Ergebnis. +- **Terminal:** `[Enter]` drücken, sobald die KI antwortet — egal ob Text noch streamt oder Audio bereits läuft → dasselbe Ergebnis. - **API/eigene Clients:** WebSocket-Event `{"type":"interrupt"}` senden → Server stoppt Streaming und meldet `{"type":"interrupted"}`. **VAD** (automatische Sprechpausen-Erkennung): Im Start-Frame von `/ws/voice` @@ -1457,7 +1457,7 @@ Meldet pro Modul `[OK]` / `[FAIL]`, inkl. TTS→STT-Round-Trip. | Symptom | Ursache | Lösung | |---------|---------|--------| -| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…` in neuem Terminal oder `source ~/.bashrc` | +| `OPENROUTER_API_KEY is empty` | Key fehlt im Service-Environment | `OPENROUTER_API_KEY=sk-or-…` in `.env` eintragen — systemd sourct kein `.bashrc` | | HTTP **401** „Bearer/Invalid token" | Auth an, Token fehlt/falsch | Token im Header; oder `AUTH_ENABLED=false` für Dev | | HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` = `ADMIN_API_KEY` | | HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` wählen | diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 3e30359..23e8915 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -33,6 +33,7 @@ import subprocess import sys import tempfile import select +import termios import time import wave @@ -342,9 +343,11 @@ async def one_turn(ws, wav: bytes, start_frame: dict, queue: "asyncio.Queue | No async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: """Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id). - Barge-in: Waehrend der Server antwortet, wartet _stdin_watcher() auf Enter. - Wird Enter gedrueckt, sendet der Client {"type":"interrupt"}, der Server bricht - den Turn ab, der Player wird sofort beendet. + Barge-in in zwei Phasen: + Phase 1 (LLM-Streaming): _stdin_watcher() laeuft parallel zu one_turn(); Enter + schickt {"type":"interrupt"} an den Server und bricht den Turn ab. + Phase 2 (Audio-Wiedergabe): Nach dem LLM-Ende laeuft _stdin_watcher() weiter; + Enter beendet den Player-Prozess sofort. Es wird immer ein durchgehender Player benutzt: der spielt sowohl viele Haeppchen (Stream-Audio) als auch ein einzelnes Komplett-Audio luekenlos ab. Wird kein Player @@ -355,21 +358,20 @@ async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: feeder = asyncio.create_task(_player_feeder(player, queue)) if queue is not None else None audio = b"" interrupted = False + # Watcher laeuft durch beide Phasen — wird erst im finally-Block beendet. + watcher_task = asyncio.create_task(_stdin_watcher()) + try: # ping_timeout/max_queue defensiv: lange Antworten + Echtzeit-Wiedergabe ueberleben. async with websockets.connect(url, max_size=None, max_queue=None, ping_timeout=60) as ws: turn_task = asyncio.create_task(one_turn(ws, wav, start_frame, queue)) - watcher_task = asyncio.create_task(_stdin_watcher()) + # Phase 1: LLM-Turn vs Enter done, _ = await asyncio.wait( {turn_task, watcher_task}, return_when=asyncio.FIRST_COMPLETED, ) - if turn_task in done: - # Normales Ende — Watcher canceln (kein Zeichen konsumiert) - watcher_task.cancel() - audio = turn_task.result() - else: - # Barge-in: Enter wurde gedrueckt + if watcher_task in done and turn_task not in done: + # Barge-in waehrend LLM-Streaming interrupted = True try: await ws.send(json.dumps({"type": "interrupt"})) @@ -380,27 +382,76 @@ async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None: await turn_task except asyncio.CancelledError: pass + else: + # Normales LLM-Ende (oder beide gleichzeitig) + audio = turn_task.result() + # watcher_task laeuft weiter -> Phase 2 im finally-Block finally: if feeder is not None: - queue.put_nowait(None) # Feeder beenden - if interrupted and player is not None: - player.terminate() # sofort stoppen statt Restpuffer abwarten - try: - await feeder - except Exception: - pass - if player is not None: + queue.put_nowait(None) # Feeder-Ende signalisieren if interrupted: + # Phase 1 unterbrochen: Player sofort beenden + if player is not None: + player.terminate() + if not watcher_task.done(): + watcher_task.cancel() try: - player.wait(timeout=2) - except subprocess.TimeoutExpired: - player.kill() + await feeder + except Exception: + pass + if player is not None: + try: + player.wait(timeout=2) + except subprocess.TimeoutExpired: + player.kill() else: - await asyncio.to_thread(_close_player, player) + # Phase 2: Feeder abwarten, dann Player vs Enter racen + try: + await feeder + except Exception: + pass + if player is not None: + if not watcher_task.done(): + # Race: Player laeuft aus vs Enter (Barge-in waehrend Audio) + close_task = asyncio.create_task( + asyncio.to_thread(_close_player, player) + ) + done2, _ = await asyncio.wait( + {close_task, watcher_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if watcher_task in done2 and close_task not in done2: + # Barge-in waehrend Wiedergabe: Player sofort killen + interrupted = True + try: + player.kill() + except (ProcessLookupError, OSError): + pass + else: + watcher_task.cancel() + if not close_task.done(): + try: + await close_task + except Exception: + pass + else: + # Watcher lief gleichzeitig mit Turn ab — nicht als Barge-in werten + await asyncio.to_thread(_close_player, player) + else: + # Kein Player/Feeder + if not watcher_task.done(): + watcher_task.cancel() + if interrupted: print(" ↩ Unterbrochen — drücke [Enter] für neue Aufnahme …") elif audio: # nur wenn kein Streaming-Player verfuegbar war play_pcm(audio) + # Gepufferten stdin leeren: ein Enter, der waehrend des Turns nicht als Barge-in + # verarbeitet wurde, wuerde sonst den START-Prompt sofort ueberspringen. + try: + termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) + except Exception: + pass async def run(args) -> None: