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:
parent
2e6f2efef6
commit
5296459b07
3 changed files with 36 additions and 3 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
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":
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue