Repository navigation
fix(voice): stream first audio and survive a worker without the stream route (#13215, #12886) - #13297
Merged
Merged
Conversation
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
This was referenced Aug 2, 2026
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Owner
Author
|
PR #13297 — awaiting CI + review. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
These two issues were filed as "slow" and "404", but reading the code they are one defect seen from two ends, and #13215's stated premise is partly wrong.
#13215 says the streaming route "sits unused". It does not.
api/voice_stream.py:98has consumedsynthesize_stream()since #12501 — the WebSocket voice path, which is what speaks chat replies. What never used it is the HTTP path (api/voice.py).That matters, because it explains the measurement. Chain, verified in code:
/tts/synthesize/stream(bug(tts): synthesis 404s — deployed worker lacks /tts/synthesize/stream because autobot-tts-worker has NO resolve path AND NO drift visibility (silent skew on every backend update); /tts/clone-voice is a dead call that exists nowhere #12886).synthesize_stream()raises,voice_stream.pycatches it and emits{"type": "error", "message": "TTS synthesis failed: TTS worker error 404: ..."}— the exact string reported in bug(tts): synthesis 404s — deployed worker lacks /tts/synthesize/stream because autobot-tts-worker has NO resolve path AND NO drift visibility (silent skew on every backend update); /tts/clone-voice is a dead call that exists nowhere #12886.useVoiceOutput.ts) only falls back to HTTP when the socket fails. A per-utteranceerrorframe islogger.warn-ed and nothing else. The streaming path produces silence, not slowness.So the naive fix (point
api/voice.pyat the streaming route) makes it strictly worse: it converts the one path that still worked into a second 404. The fix has to be capability-aware, which is why these ship together.I could not verify the deployed worker's route table — that needs a live host, and #12886's history is five rounds of "merged, still not on the host". This change deliberately does not depend on that: it is correct whether or not the route is served.
#13207 is not in this PR. It is
multimodal_processor— a different subsystem, zero file overlap — and it is a behaviour change (threshold 0.7→0.8, timeout 30→15) that needs an owner's decision on which value is intended. Per the closure gate, partial delivery must not close it, so it stays open. Detail in a comment on #13215.What Changed
services/tts_client.py— capability probe + fallback (the core).synthesize_stream()now raisesTTSStreamUnsupported(not bareRuntimeError) on 404/405, distinguishing "the worker's route table predates the backend" from "synthesis failed". Newstream_or_synthesize()prefers the streaming route and degrades to the whole-utterance route when it is absent. The 404 arrives before any chunk, so the fallback cannot duplicate or interleave audio; a guard re-raises rather than restarting if anything was already emitted. The negative result is cached forAUTOBOT_TTS_STREAM_PROBE_TTL(module constant, default 5 min) so it costs one failed round trip per window, not one per utterance, and re-probes afterwards — a worker updated in place resumes streaming without a backend restart. A 500 is not treated as skew and is never retried against a different endpoint.api/voice_stream.py— the WS path usesstream_or_synthesize. A stale worker now speaks instead of going silent. This is the #12886 user-facing symptom, fixed independently of deploy state.api/voice.py—/synthesizeand/speaktakestream: bool = Form(False). Default is unchanged (audio/wavwhole blob), so every existing caller keeps its contract. Withstream=truethe response is aStreamingResponseof the worker's own[4-byte length][WAV]framing, forwarded unchanged.Framing rather than concatenation is required, not stylistic: the chunks are separate, independently-decodable WAV files. Concatenated into one
audio/wavbody only the first RIFF header is valid, sodecodeAudioDatawould decode the first ~250ms and silently drop the rest of the sentence.useVoiceOutput.ts— requestsstream=trueand, when the framing header is present, splits frames across read boundaries (a frame can straddle two reads, including mid-header) and schedules each mini-WAV on the existing gapless timeline as it arrives. Falls back to the blob path when the header is absent.isSpeakingis deliberately not force-cleared on the framed path — chunks are still sounding when the response ends; the per-sourceonendedowns that transition.Also regenerated
api.tsfor the new field. Note: the generator bakes the generating machine's CWD/install path and unset-env placeholders into schema defaults, so a local regeneration produces unrelated churn; those hunks were reverted to the committed values and filed as #13298.Verification
Regression tests were written first and fail on the pre-fix tree:
That third failure message is #12886's reported symptom reproduced in-process from the repo alone.
After:
The latency claim is measured, not asserted. A fake worker paces a 0.5s utterance as 5 chunks; time-to-first-byte is read off the ASGI
http.response.bodymessages:which is the expected 1/5. Note starlette's
TestClientbuffers the whole response before returning, so it cannot observe TTFB at all — the test drives the ASGI app directly, and still exercises the real route, form parsing andStreamingResponse. An "it called synthesize_stream" assertion would pass even if the route buffered the whole utterance, i.e. it would not catch the actual defect.Frontend, same before/after discipline:
Not verified — stated plainly rather than claimed. I did not execute anything against a live deployment, so I cannot confirm the deployed worker now serves
/tts/synthesize/stream. That is exactly why this change does not depend on it: on a current worker the WS and HTTP paths stream, and on a stale one both degrade to working whole-blob audio instead of a 404 or silence. The live route probe from #12886's verification block remains the check to run before treating the deploy half as proven.Closes #13215, #12886
Discovered and filed, not fixed here: #13298 (generated-types gate is machine-dependent), #13299 (a per-utterance WS error frame is only logged, so the sentence is dropped and the HTTP fallback never fires — this PR removes the common cause of that frame, not the handling gap).
Model Used
claude-opus-5