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

@ -192,6 +192,8 @@ def _start_frame(args) -> dict:
frame = {"type": "start", "format": "wav"}
if getattr(args, "stream_audio", False):
frame["audio_stream"] = True
if getattr(args, "stream_text", False):
frame["stream"] = True
for key in ("stt_provider", "llm_provider", "tts_provider", "language"):
value = getattr(args, key, None)
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"}))
audio = b""
token_started = False
while True:
msg = await ws.recv()
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")
if etype == "transcript":
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":
print(f" Assistent: {event.get('text','')!r}")
if token_started:
sys.stdout.write("\n") # Live-Zeile abschliessen (Text steht schon)
sys.stdout.flush()
else:
print(f" Assistent: {event.get('text','')!r}")
elif etype == "emergency":
print(f" ⚠ NOTFALL erkannt (Kategorie: {event.get('category')})")
elif etype == "error":
if token_started:
sys.stdout.write("\n")
print(f" Fehler {event.get('status','')}: {event.get('detail')}")
return b""
elif etype == "done":
@ -311,6 +327,8 @@ def main() -> None:
p.add_argument("--stream-audio", dest="stream_audio", action="store_true",
help="Antwort satzweise vorlesen, sobald der erste Satz fertig ist "
"(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)")
args = p.parse_args()
try: