fix(terminal): Barge-in funktioniert jetzt in beiden Phasen

Barge-in im Sprech-Loop (voice_loop.py) war nur während der LLM-Phase
zuverlässig. Während der Audio-Wiedergabe (nach done-Event) war der
_stdin_watcher bereits gecancelt, sodass Enter ignoriert wurde oder
als Spurious-Input in record_utterance() landete.

Lösung:
- _stdin_watcher läuft jetzt durch beide Phasen (LLM + Audio)
- Phase 2: _close_player wird als asyncio-Task gegen den Watcher geract;
  Enter während der Wiedergabe killt den Player sofort
- termios.tcflush nach jedem Turn verhindert Rest-Enters im stdin-Buffer

Außerdem: OPENROUTER_API_KEY in .env ergänzt (systemd sourct kein
.bashrc); Dokumentation entsprechend aktualisiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-19 00:39:00 +02:00
commit 8e48ab6d66
2 changed files with 76 additions and 25 deletions

View file

@ -531,7 +531,7 @@ python scripts/voice_loop.py --session mein-gespraech
**Ablauf je Runde:** **Ablauf je Runde:**
1. **[Enter]** → sprechen 1. **[Enter]** → sprechen
2. **[Enter]** → Aufnahme stoppt, Assistent antwortet hörbar 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 4. **[Enter]** → nächste Runde beginnen
5. **Strg + C** → beenden 5. **Strg + C** → beenden
@ -916,7 +916,7 @@ python scripts/voice_loop.py --no-stream-audio
**Barge-in** (laufende Antwort unterbrechen): **Barge-in** (laufende Antwort unterbrechen):
- **Web-Interface:** Mic-Button ⏹ (amber) während der KI-Antwort tippen → Wiedergabe stoppt sofort, Server bricht Generierung ab. - **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"}`. - **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` **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 | | 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** „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 **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 | | HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` wählen |

View file

@ -33,6 +33,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import select import select
import termios
import time import time
import wave 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: async def _send_and_play(url: str, wav: bytes, start_frame: dict) -> None:
"""Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id). """Eine kurze Verbindung pro Runde (Gedaechtnis bleibt serverseitig via session_id).
Barge-in: Waehrend der Server antwortet, wartet _stdin_watcher() auf Enter. Barge-in in zwei Phasen:
Wird Enter gedrueckt, sendet der Client {"type":"interrupt"}, der Server bricht Phase 1 (LLM-Streaming): _stdin_watcher() laeuft parallel zu one_turn(); Enter
den Turn ab, der Player wird sofort beendet. 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 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 (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 feeder = asyncio.create_task(_player_feeder(player, queue)) if queue is not None else None
audio = b"" audio = b""
interrupted = False interrupted = False
# Watcher laeuft durch beide Phasen — wird erst im finally-Block beendet.
watcher_task = asyncio.create_task(_stdin_watcher())
try: try:
# ping_timeout/max_queue defensiv: lange Antworten + Echtzeit-Wiedergabe ueberleben. # 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: 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)) 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( done, _ = await asyncio.wait(
{turn_task, watcher_task}, {turn_task, watcher_task},
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
) )
if turn_task in done: if watcher_task in done and turn_task not in done:
# Normales Ende — Watcher canceln (kein Zeichen konsumiert) # Barge-in waehrend LLM-Streaming
watcher_task.cancel()
audio = turn_task.result()
else:
# Barge-in: Enter wurde gedrueckt
interrupted = True interrupted = True
try: try:
await ws.send(json.dumps({"type": "interrupt"})) 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 await turn_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
else:
# Normales LLM-Ende (oder beide gleichzeitig)
audio = turn_task.result()
# watcher_task laeuft weiter -> Phase 2 im finally-Block
finally: finally:
if feeder is not None: if feeder is not None:
queue.put_nowait(None) # Feeder beenden queue.put_nowait(None) # Feeder-Ende signalisieren
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:
if interrupted: if interrupted:
# Phase 1 unterbrochen: Player sofort beenden
if player is not None:
player.terminate()
if not watcher_task.done():
watcher_task.cancel()
try: try:
player.wait(timeout=2) await feeder
except subprocess.TimeoutExpired: except Exception:
player.kill() pass
if player is not None:
try:
player.wait(timeout=2)
except subprocess.TimeoutExpired:
player.kill()
else: 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: if interrupted:
print(" ↩ Unterbrochen — drücke [Enter] für neue Aufnahme …") print(" ↩ Unterbrochen — drücke [Enter] für neue Aufnahme …")
elif audio: # nur wenn kein Streaming-Player verfuegbar war elif audio: # nur wenn kein Streaming-Player verfuegbar war
play_pcm(audio) 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: async def run(args) -> None: