Init: 导入开发日志和项目文档
This commit is contained in:
219
DevLogs/Day13.md
Normal file
219
DevLogs/Day13.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user