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

6.0 KiB
Raw Permalink Blame History

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 → 16kHzaudio_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)

🔜 下一步开发

  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.inoHTTP /stream.wav 播放实现)
  • 服务器端点:audio_stream.py/stream.wav
  • 客户端代码:avaota_app_demo/src/main.cpp