6.0 KiB
6.0 KiB
Day 13: TTS 播放优化与事件循环阻塞修复
日期:2025-12-11
目标:解决 TTS 音频播放断续问题、修复服务器退出问题
📅 工作摘要
1. 核心问题发现 ✅
症状:TTS 音频播放断断续续,每隔几秒才播放几个字
根因:omni_client.py 中使用同步迭代器处理 Omni API 响应,阻塞了整个 asyncio 事件循环
# 问题代码(已修复)
async def stream_chat(...):
completion = client.chat.completions.create(stream=True, ...)
for chunk in completion: # ← 同步迭代,阻塞事件循环!
yield OmniStreamPiece(...)
2. Omni 客户端异步化 ✅
修复:使用 threading.Thread + asyncio.Queue 解耦同步 API 调用
# 修复后的代码
async def stream_chat(...):
queue = asyncio.Queue()
def _sync_stream(): # 在独立线程中运行
for chunk in completion:
loop.call_soon_threadsafe(queue.put_nowait, OmniStreamPiece(...))
thread = threading.Thread(target=_sync_stream, daemon=True)
thread.start()
while True:
item = await queue.get() # 非阻塞等待
if item is None: break
yield item
3. 客户端 TTS 预缓冲机制 ✅
问题:即使服务器端修复后,TTS 仍断断续续(Underrun)
原因:Omni API 每 ~2 秒返回一个音频块,客户端播放速度快于接收速度
解决方案:在 main.cpp 中实现预缓冲机制
| 参数 | 值 | 说明 |
|---|---|---|
| PRE_BUFFER_FRAMES | 16000 | 预缓冲 1 秒音频再播放 |
| MIN_PLAY_FRAMES | 8000 | 最小播放阈值 0.5 秒 |
| 批次大小 | 1600 帧 | 每次播放 100ms |
// 客户端预缓冲逻辑
static std::vector<int16_t> tts_buffer;
static bool is_buffering = true;
// 接收时:追加到缓冲区
tts_buffer.insert(tts_buffer.end(), samples, samples + frames);
// 播放时:积累足够再开始
if (tts_buffer.size() >= PRE_BUFFER_FRAMES) {
is_buffering = false; // 开始播放
}
4. 服务器退出修复 ✅
问题:Ctrl+C 后进程挂起,不返回 shell
修复:在 lifespan 中捕获 CancelledError,并启动强制退出线程
@asynccontextmanager
async def lifespan(app):
# ... 启动逻辑 ...
try:
yield
except asyncio.CancelledError:
pass # Ctrl+C 正常行为
finally:
print("[LIFESPAN] 应用关闭完成")
# 强制退出线程
def _force_exit():
time.sleep(0.5)
os._exit(0)
threading.Thread(target=_force_exit, daemon=True).start()
5. 其他修复 ✅
- TTS 上采样:8kHz → 16kHz(在
audio_stream.py) - TTS 缓存机制:WebSocket 断开时缓存音频,重连后发送
- WebSocket 引用保护:防止模型加载期间 ws 被清空
📝 代码变更汇总
| 文件 | 变更内容 |
|---|---|
omni_client.py |
使用线程+队列解耦同步 API 调用 |
main.cpp |
添加 TTS 预缓冲机制 |
app_main.py |
lifespan 捕获 CancelledError + 强制退出线程 |
audio_stream.py |
TTS 上采样、缓存机制、WebSocket 引用保护 |
🔴 遗留问题
1. TTS 音频仍有轻微断续
现象:
- 预缓冲后首次播放流畅
- 后续仍偶发
Underrun occurred
根因:
- Omni API 流式响应速度慢(每 ~2 秒一个块)
- 客户端播放速度(实时)> 服务器推送速度
客户端日志示例:
[AUD-PLAY] Pre-buffer full, starting playback
[AUD-PLAY] Played 4000 frames, remaining: 14399
[AudioPlayer] Underrun occurred, recovering...
2. WebSocket 仍偶发断开
现象:长时间对话后出现 websocket.send after websocket.close
原因:模型加载或其他长时间操作期间,WebSocket 超时断开
🚀 改进方向
方案 A:切换到 HTTP /stream.wav 模式(推荐)
服务器已支持 /stream.wav 端点,参考 ESP32S3 的成功实现:
// ESP32 的 HTTP 流式播放(已验证可用)
WiFiClient cli;
cli.connect(SERVER_HOST, SERVER_PORT);
cli.print("GET /stream.wav HTTP/1.1\r\n...");
// 持续读取 WAV 数据并播放
优势:
- HTTP 流比 WebSocket 更稳定
- 服务器已实现,只需修改客户端
- ESP32 方案成熟,可直接参考
方案 B:增大 ALSA 缓冲区
当前客户端 ALSA 缓冲区较小(1280 帧 = 80ms),可尝试增大:
// 增大 ALSA 缓冲区
snd_pcm_hw_params_set_buffer_size(handle, params, 16000); // 1秒
snd_pcm_hw_params_set_period_size(handle, params, 4000); // 250ms
方案 C:服务器端预生成完整响应
在 Omni API 调用前等待完整响应再发送,牺牲首次延迟换取流畅播放:
# 收集完整响应再发送
full_audio = b""
async for piece in stream_chat(...):
full_audio += piece.audio_bytes
# 一次性发送
await send_tts(full_audio)
🔜 下一步开发
-
实现 HTTP
/stream.wav客户端(~150 行 C++ 代码)- 添加 HTTP 客户端线程
- 解析 WAV 头
- 读取 chunked 数据并播放
-
测试验证
- 验证 HTTP 模式播放流畅度
- 对比 WebSocket 模式性能
-
摄像头 VBV 缓冲区优化
- 继续调整编码参数
- 减少
VBV buffer full警告
📊 测试结果
| 测试项 | 修复前 | 修复后 |
|---|---|---|
| TTS 块间隔 | 5-15 秒 | ~2 秒 |
| 首次播放延迟 | 立即 | ~1 秒(预缓冲) |
| Underrun 频率 | 每块一次 | 偶发 |
| 服务器退出 | 挂起 | 正常返回 |
📂 参考文件
- ESP32S3 固件:
AvaotaF1/ESP32S3/compile.ino(HTTP/stream.wav播放实现) - 服务器端点:
audio_stream.py→/stream.wav - 客户端代码:
avaota_app_demo/src/main.cpp