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:
parent
613b18055b
commit
8e48ab6d66
2 changed files with 76 additions and 25 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue