feat(voice_loop): --stream-text (Antworttext live am Monitor)

- neuer Flag --stream-text setzt stream:true im /ws/voice-Start-Frame;
  token-Events werden inline ausgegeben (Wort fuer Wort, waehrend die KI generiert)
- mit --stream-audio kombinierbar; bei semantic wird die Live-Zeile abgeschlossen
- Token-Events sind winzig -> Audio-Startzeit praktisch unveraendert (gemessen:
  Text 1. Token lokal ~1 s, Cloud ~3 s; erster Ton wie zuvor)
- Test: /ws/voice mit stream:true liefert token-Events; 66 Tests gruen
- Doku ergaenzt (Optionen + Feature-Hinweis)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 17:47:11 +02:00
commit 5296459b07
3 changed files with 36 additions and 3 deletions

View file

@ -110,6 +110,7 @@ Ablauf je Runde:
Nützliche Optionen: Nützliche Optionen:
```bash ```bash
python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher python scripts/voice_loop.py --stream-audio # satzweises Vorlesen: Antwort beginnt frueher
python scripts/voice_loop.py --stream-text # Antworttext live anzeigen (kombinierbar mit --stream-audio)
python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto') python scripts/voice_loop.py --recorder pw-record # PipeWire-Aufnahme (Standard bei 'auto')
python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon python scripts/voice_loop.py --recorder arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon
python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter python scripts/voice_loop.py --llm-provider openrouter --tts-provider openrouter
@ -177,8 +178,10 @@ curl -s -X POST "$URL/api/chat?debug=true" \
- **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert - **Echtzeit-Streaming:** WebSocket `/ws/chat` mit `{"text":"…","stream":true}` liefert
die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im
Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen Sprech-Loop aktivierst du das satzweise Vorlesen mit `--stream-audio` (das Vorlesen
beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort). beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort) und die
Provider-Overrides und `--stream-audio` wirken auch über `/ws/voice` (start-Frame). **Live-Anzeige des Antworttextes** mit `--stream-text` (erscheint Wort für Wort,
während die KI generiert — beides kombinierbar). Provider-Overrides, `--stream-audio`
und `--stream-text` wirken auch über `/ws/voice` (start-Frame).
- **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}` - **Unterbrechen (Barge-in):** während der Assistent spricht `{"type":"interrupt"}`
senden → laufende Antwort wird abgebrochen. senden → laufende Antwort wird abgebrochen.
- **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` - **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice`

View file

@ -192,6 +192,8 @@ def _start_frame(args) -> dict:
frame = {"type": "start", "format": "wav"} frame = {"type": "start", "format": "wav"}
if getattr(args, "stream_audio", False): if getattr(args, "stream_audio", False):
frame["audio_stream"] = True frame["audio_stream"] = True
if getattr(args, "stream_text", False):
frame["stream"] = True
for key in ("stt_provider", "llm_provider", "tts_provider", "language"): for key in ("stt_provider", "llm_provider", "tts_provider", "language"):
value = getattr(args, key, None) value = getattr(args, key, None)
if value: if value:
@ -211,6 +213,7 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes:
await ws.send(json.dumps({"type": "end"})) await ws.send(json.dumps({"type": "end"}))
audio = b"" audio = b""
token_started = False
while True: while True:
msg = await ws.recv() msg = await ws.recv()
if isinstance(msg, (bytes, bytearray)): if isinstance(msg, (bytes, bytearray)):
@ -224,11 +227,24 @@ async def one_turn(ws, wav: bytes, start_frame: dict, player=None) -> bytes:
etype = event.get("type") etype = event.get("type")
if etype == "transcript": if etype == "transcript":
print(f" Du: {event.get('text','')!r}") print(f" Du: {event.get('text','')!r}")
elif etype == "token":
# Live-Anzeige des Antworttextes, waehrend die KI ihn erzeugt.
if not token_started:
sys.stdout.write(" Assistent: ")
token_started = True
sys.stdout.write(event.get("text", ""))
sys.stdout.flush()
elif etype == "semantic": elif etype == "semantic":
if token_started:
sys.stdout.write("\n") # Live-Zeile abschliessen (Text steht schon)
sys.stdout.flush()
else:
print(f" Assistent: {event.get('text','')!r}") print(f" Assistent: {event.get('text','')!r}")
elif etype == "emergency": elif etype == "emergency":
print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})") print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})")
elif etype == "error": elif etype == "error":
if token_started:
sys.stdout.write("\n")
print(f" Fehler {event.get('status','')}: {event.get('detail')}") print(f" Fehler {event.get('status','')}: {event.get('detail')}")
return b"" return b""
elif etype == "done": elif etype == "done":
@ -311,6 +327,8 @@ def main() -> None:
p.add_argument("--stream-audio", dest="stream_audio", action="store_true", p.add_argument("--stream-audio", dest="stream_audio", action="store_true",
help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist " help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist "
"(geringere Zeit bis zum ersten Ton)") "(geringere Zeit bis zum ersten Ton)")
p.add_argument("--stream-text", dest="stream_text", action="store_true",
help="Antworttext live am Monitor anzeigen, waehrend die KI ihn erzeugt")
p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)") p.add_argument("--file", default=None, help="WAV statt Mikrofon senden (Test ohne Aufnahme)")
args = p.parse_args() args = p.parse_args()
try: try:

View file

@ -124,6 +124,18 @@ def test_ws_voice_start_frame_options_are_honored(monkeypatch):
assert ws.receive_json()["type"] == "done" assert ws.receive_json()["type"] == "done"
def test_ws_voice_stream_text_emits_token_events(monkeypatch):
# stream:true im START-Frame -> der Server schickt token-Events (Live-Text).
opts = _install_voice_stubs(monkeypatch)
with client.websocket_connect("/ws/voice") as ws:
ws.send_json({"type": "start", "format": "wav", "stream": True, **opts})
ws.send_bytes(b"PCMDATA")
ws.send_json({"type": "end"})
assert ws.receive_json()["type"] == "transcript"
assert ws.receive_json()["type"] == "ack"
assert ws.receive_json()["type"] == "token" # Antworttext kommt als token-Event(e)
def test_ws_voice_empty_buffer_errors(monkeypatch): def test_ws_voice_empty_buffer_errors(monkeypatch):
opts = _install_voice_stubs(monkeypatch) opts = _install_voice_stubs(monkeypatch)
with client.websocket_connect("/ws/voice") as ws: with client.websocket_connect("/ws/voice") as ws: