一、问题背景
上一轮优化解决了 TTS 阻塞 chat 接口的问题,但用户点击 🔊 按钮或语音识别后自动播放时,从点击到听到声音仍需等待 6~16 秒。深入分析发现,整个链路存在严重的串行瓶颈:旧架构数据流:
用户点🔊 → 前端 fetch → 后端等待所有音频块收集完 → 返回完整MP3 → 前端 blob → URL.createObjectURL → audio.load → audio.play
每一步都在等上一步完成,尤其是"后端等所有音频块收集完"这一步,即使自适应超时已经缩短到了 3~5s,前后端 encore 各环节叠加后总耗时仍达 6~16s。
二、流式架构设计
核心思路:打破"等全部收集完再返回"的串行模式,改为"收到一块发一块"的流式架构。
新架构数据流:
用户点🔊 → audio.src=url → 浏览器开始缓冲 → ~0.5s 首个音频块到达 → 立即开始播放
涉及三层改造:
- tts.py 新增流式生成器
_volcano_stream():火山引擎 WebSocket 每收到一个 AudioOnlyServer 块就立即yield,不等待所有块收集完毕。 - tts.py 新增
synthesize_stream()统一入口:命中缓存时分块 yield 模拟流式;否则走火山流式→讯飞→Edge-TTS 的降级链,合成完成后自动缓存到内存 LRU。 - stt_api.py /tts 端点改为 StreamingResponse:HTTP 流式推送音频块,首块 15s 内不到则返回 500 让前端降级。
三、关键代码实现
火山引擎流式生成器(tts.py):
async def _volcano_stream(self, text, voice, speed, volume, pitch):
"""每收到一个音频块就立即 yield,不等待"""
ws = await websockets.connect(VOLCANO_TTS_WS_URL, ...)
# 连接 → 会话 → 任务请求...
chunks_count = 0
while True:
msg = await asyncio.wait_for(receive_message(ws), timeout=recv_timeout)
if msg.type == MsgType.AudioOnlyServer and msg.payload:
chunks_count += 1
yield msg.payload # ← 实时发送,不等待收集
elif msg.event == EventType.TTSSentenceEnd and chunks_count:
breakStreamingResponse 端点(stt_api.py):
@router.get("/tts")
async def text_to_speech(text: str, ...):
stream = tts_service.synthesize_stream(text, ...)
first = await asyncio.wait_for(stream.__anext__(), timeout=15)
async def audio_generator():
yield first
async for chunk in stream:
yield chunk
return StreamingResponse(audio_generator(), media_type="audio/mpeg")前端简化(chat.html):
// 之前:fetch → blob → URL.createObjectURL → play
const resp = await fetch(ttsUrl);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
audio.src = url;
await audio.play();
// 之后:直接设 src,浏览器原生流式播放
audio.src = ttsUrl;
audio.load();
await audio.play(); // 首块 ~0.5s 到达即开始播放
四、代码重构亮点
此次优化不仅仅是"加功能",还顺手清理了之前的代码冗余:
- 消除重复逻辑:原来的
_synthesize_volcano()包含 ~80 行完整的 WebSocket 连接+收集逻辑,与新增的_volcano_stream()高度重复。重构后_synthesize_volcano()成为消费_volcano_stream()的薄封装(仅 5 行代码),一份逻辑两处复用。 - 补全缓存逻辑:
synthesize()的火山引擎路径原本遗漏了缓存写入,现在前端流式 API 和后台 TTS 预热可共享同一份缓存,减少重复合成。 - 浏览器原生能力利用:HTML5
<audio>元素天然支持流式 MP3 边收边播,前端代码从 ~30 行的 fetch+blob+URL 操作简化为 3 行。
五、优化效果对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首音延迟(点🔊到听见声音) | 6~16s | ~0.5s | 10~30x |
| 前端代码复杂度 | fetch+blob+URL (~30行) | src+play (3行) | 10x |
| WebSocket 空等时间 | 15s | 0s(收到即发) | ∞ |
| chat 接口响应 | 被 TTS 拖慢 | 秒级返回 | - |
此次流式改造彻底解决了 TTS 播报延迟问题,用户体验从"点完等十几秒"变为"点了就响",与主流 AI 语音助手的体验差距大幅缩小。
六、技术沉淀与可复用经验
- WebSocket 流式数据处理范式:不要先收集再返回,用
async generator + yield逐块推送,配合 FastAPI StreamingResponse 即可实现端到端流式传输。此模式可复用于任何实时数据推送场景(视频流、传感器数据等)。 - 浏览器原生能力优先:
<audio>元素的 src 直接指向流式 URL 即可边收边播,无需自定义 fetch+blob+URL 的前端播放栈。 - 缓存与流式共存:缓存命中时分块 yield 模拟流式行为,保证缓存和非缓存场景的前端行为一致。
- 降级链设计:火山流式→讯飞→Edge-TTS 的三级降级保障,即使主力引擎异常也能保证基础功能可用。


💬 过往技术交流