fix(ws/voice): start-Frame-Optionen (Provider, audio_stream, language) ehren

Bug: /ws/voice nutzte bei explizitem {"type":"end"} die Optionen aus dem END-Frame.
voice_loop schickt Provider und audio_stream aber im START-Frame -> diese wurden
komplett ignoriert. Folge: --stt/--llm/--tts-provider und --stream-audio hatten
ueber /ws/voice KEINE Wirkung (Hybrid lief faktisch ueber die Cloud-Defaults,
audio_stream wurde nie aktiv).

Fix: start-Frame als start_options merken und beim Turn mit dem end-Frame mergen
({**start_options, **end}); VAD-Auto-Ende nutzt ebenfalls start_options.

Verifiziert: Provider-Override aus dem START-Frame greift (neuer Test); mit
audio_stream kommen jetzt mehrere Audio-Chunks ~6-7 s VOR dem fertigen Text
(satzweises Vorlesen startet frueher). 65 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 17:32:12 +02:00
commit 2d288be630
2 changed files with 21 additions and 4 deletions

View file

@ -235,7 +235,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s
fmt = "wav" fmt = "wav"
active = None active = None
vad = None vad = None
vad_options: dict = {} start_options: dict = {} # Konfig aus dem start-Frame (Provider, audio_stream, language)
async def _start_voice(audio: bytes, options: dict): async def _start_voice(audio: bytes, options: dict):
nonlocal active nonlocal active
@ -259,7 +259,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s
audio = bytes(audio_buffer) audio = bytes(audio_buffer)
audio_buffer.clear() audio_buffer.clear()
vad.reset() vad.reset()
await _start_voice(audio, vad_options) await _start_voice(audio, start_options)
continue continue
raw = message.get("text") raw = message.get("text")
@ -278,6 +278,7 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s
continue continue
if ctype == "start": if ctype == "start":
audio_buffer.clear() audio_buffer.clear()
start_options = control # Provider/audio_stream/language fuer den Turn merken
fmt = control.get("format", "wav") fmt = control.get("format", "wav")
if control.get("vad"): if control.get("vad"):
vad = EnergyVAD( vad = EnergyVAD(
@ -285,7 +286,6 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s
threshold=control.get("vad_threshold", 500.0), threshold=control.get("vad_threshold", 500.0),
silence_ms=control.get("vad_silence_ms", 700.0), silence_ms=control.get("vad_silence_ms", 700.0),
) )
vad_options = control
else: else:
vad = None vad = None
continue continue
@ -300,7 +300,8 @@ async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: s
audio_buffer.clear() audio_buffer.clear()
if vad is not None: if vad is not None:
vad.reset() vad.reset()
await _start_voice(audio, control) # start-Frame-Konfig + end-Frame zusammenfuehren (end kann ueberschreiben)
await _start_voice(audio, {**start_options, **control})
except WebSocketDisconnect: except WebSocketDisconnect:
if active and not active.done(): if active and not active.done():
active.cancel() active.cancel()

View file

@ -108,6 +108,22 @@ def test_ws_voice_transcribes_and_answers(monkeypatch):
assert ws.receive_json()["type"] == "done" assert ws.receive_json()["type"] == "done"
def test_ws_voice_start_frame_options_are_honored(monkeypatch):
# Provider stehen im START-Frame, der end-Frame ist leer -> muessen trotzdem gelten.
opts = _install_voice_stubs(monkeypatch)
with client.websocket_connect("/ws/voice") as ws:
ws.send_json({"type": "start", "format": "wav", **opts})
ws.send_bytes(b"PCMDATA") # 7 Bytes
ws.send_json({"type": "end"})
transcript = ws.receive_json()
# Stub-STT (aus dem START-Frame) liefert "erkannt(<bytes>)" -> beweist: Override greift
assert transcript["type"] == "transcript" and transcript["text"] == "erkannt(7)"
assert ws.receive_json()["type"] == "ack"
assert ws.receive_json()["type"] == "semantic"
ws.receive_bytes()
assert ws.receive_json()["type"] == "done"
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: