Files
Docs/DevLogs/Day13.md
2025-12-31 16:18:28 +08:00

220 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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并启动强制退出线程
```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`