diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 06a55d4..98b5c5f 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -110,6 +110,7 @@ Ablauf je Runde: Nützliche Optionen: ```bash 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 arecord --device hw:1,0 # ALSA, bestimmtes Mikrofon 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 die Antwort wortweise; `"audio_stream":true` zusätzlich das Audio satzweise. Im 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). - Provider-Overrides und `--stream-audio` wirken auch über `/ws/voice` (start-Frame). + beginnt dann schon nach dem ersten Satz, statt erst nach der ganzen Antwort) und die + **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"}` senden → laufende Antwort wird abgebrochen. - **Automatische Sprechpausen-Erkennung (VAD):** im Start-Frame von `/ws/voice` diff --git a/scripts/voice_loop.py b/scripts/voice_loop.py index 924b6b7..5db35dd 100644 --- a/scripts/voice_loop.py +++ b/scripts/voice_loop.py @@ -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: diff --git a/tests/test_ws.py b/tests/test_ws.py index 735bdfa..e738581 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -124,6 +124,18 @@ def test_ws_voice_start_frame_options_are_honored(monkeypatch): 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): opts = _install_voice_stubs(monkeypatch) with client.websocket_connect("/ws/voice") as ws: