# Day 13: TTS 播放优化与事件循环阻塞修复 **日期**:2025-12-11 **目标**:解决 TTS 音频播放断续问题、修复服务器退出问题 --- ## 📅 工作摘要 ### 1. 核心问题发现 ✅ **症状**:TTS 音频播放断断续续,每隔几秒才播放几个字 **根因**:`omni_client.py` 中使用**同步迭代器**处理 Omni API 响应,阻塞了整个 asyncio 事件循环 ```python # 问题代码(已修复) async def stream_chat(...): completion = client.chat.completions.create(stream=True, ...) for chunk in completion: # ← 同步迭代,阻塞事件循环! yield OmniStreamPiece(...) ``` ### 2. Omni 客户端异步化 ✅ **修复**:使用 `threading.Thread` + `asyncio.Queue` 解耦同步 API 调用 ```python # 修复后的代码 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 | ```cpp // 客户端预缓冲逻辑 static std::vector 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,并启动强制退出线程 ```python @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 的成功实现: ```cpp // 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),可尝试增大: ```cpp // 增大 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 调用前等待完整响应再发送,牺牲首次延迟换取流畅播放: ```python # 收集完整响应再发送 full_audio = b"" async for piece in stream_chat(...): full_audio += piece.audio_bytes # 一次性发送 await send_tts(full_audio) ``` --- ## 🔜 下一步开发 1. **实现 HTTP `/stream.wav` 客户端**(~150 行 C++ 代码) - 添加 HTTP 客户端线程 - 解析 WAV 头 - 读取 chunked 数据并播放 2. **测试验证** - 验证 HTTP 模式播放流畅度 - 对比 WebSocket 模式性能 3. **摄像头 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`