152 lines
9.2 KiB
Markdown
152 lines
9.2 KiB
Markdown
# P0 实验报告 · Pipecat 传输层
|
||
|
||
日期:2026-09-15。状态:**实时语音链路已闭环;A 方案验证通过。**
|
||
|
||
代码:`~/workspace/ai-companion/services/bot_server.py`(服务端)、
|
||
`client/test_client.py`(自写 aiortc 客户端)
|
||
|
||
## 一、为什么做这个实验
|
||
|
||
方案文档写"Pipecat 作为唯一会话管线框架",但核实后发现**它一行都没被用上**
|
||
(只在 `requirements.txt` 里声明,代码里零引用)。助手引擎是独立 asyncio,
|
||
由测试脚本用文件音频驱动。
|
||
|
||
于是有个待定的架构问题:**Pipecat 该用多深?**
|
||
|
||
- **A 方案**:Pipecat 只做传输与帧模型,会话语义归自有引擎(一个 FrameProcessor 吞掉整条管线)
|
||
- **B 方案**:按 Pipecat 的 service 模型重构,STT/LLM/TTS/记忆都做成框架原生 service
|
||
|
||
实验目的:验证 A 方案的核心假设能否成立,并拿到真实传输下的延迟数字。
|
||
|
||
## 二、结论摘要
|
||
|
||
| 部分 | 结果 |
|
||
|---|---|
|
||
| WebRTC 信令(offer/answer) | ✅ 往返 47–280ms |
|
||
| ICE 连通 | ✅ 走 172.16.1.x 直连 |
|
||
| data channel + 心跳 | ✅ |
|
||
| 双向媒体轨 | ✅ 客户端音轨送达服务端,服务端音轨客户端收到 |
|
||
| 管线启动、处理器 setup() | ✅,只产生一个自动 StartFrame |
|
||
| **帧路由到自定义处理器** | ✅ StartFrame 与 250+ 个音频帧实测到达 |
|
||
| 端到端出声 | ✅ 文件音频 → STT → DeepSeek → Qwen3-TTS → 非静音回复 |
|
||
| 浏览器真人对话 | ✅ SSH 隧道打开 Playground,麦克风输入与语音回复正常 |
|
||
| 单次真实链路延迟 | 源音频结束→客户端首个非静音约 5.8s;仍需优化 |
|
||
|
||
**即:Pipecat 负责传输/帧/RTVI、自有引擎负责会话语义的 A 方案成立。**
|
||
|
||
## 三、原问题根因与修复
|
||
|
||
原先看到 `EngineProcessor.setup()` 被调用、`process_frame` 却为 0,于是误判为
|
||
Pipeline 接线或模型不兼容。服务器完整日志揭示了真实顺序:Pipecat 在 setup 阶段
|
||
预热 NLTK 分句器,首次运行尝试下载 `punkt_tab`,20 秒后 setup timeout。worker
|
||
尚未走到自动发送 `StartFrame` 的阶段,因此所有业务帧都还在等待。
|
||
|
||
修复后把 `punkt_tab` 预装到权限 700 的
|
||
`~/.local/share/ai-companion/nltk_data`,启动时 fail-fast 检查;同时改用
|
||
`PipelineWorker(params=PIPELINE_PARAMS)`,删除客户端连接事件中手工发送的第二个
|
||
`StartFrame`。复测显示自动 StartFrame 正常抵达管线末端,音频持续进入处理器。
|
||
|
||
之后仍不出声是第二个独立问题:文件客户端播放完 WAV 后没有继续推进 PTS,服务端
|
||
收不到 VAD 所需的 700ms 尾静音。客户端现在固定发送 1.2s 尾静音且持续推进时钟。
|
||
接收端也改为只有检测到非静音音频才算成功,不再把 WebRTC 空闲静音当作回复。
|
||
|
||
## 四、挖出的 13 个集成坑
|
||
|
||
每个都会表现为"什么都没发生",极难排查。无论最终选 A 还是 B 都会踩。
|
||
|
||
| # | 坑 | 现象 | 怎么办 |
|
||
|---|---|---|---|
|
||
| 1 | Pipecat 客户端 SDK 不覆盖 N5095 形态 | 浏览器/移动端/C++/ESP32 都有,没有"Linux x86 盒子接 USB 阵列" | 自己写 aiortc 客户端(已有雏形) |
|
||
| 2 | 客户端必须建 data channel 并定期发 `"ping"` | 不建则服务端**一帧音频都不推**,日志只有一句 "Data channel not established within 10s" | 建 channel + 1s 心跳 |
|
||
| 3 | offer 里没有媒体时 aiortc **完全不收集 ICE 候选** | 信令成功、SDP 交换正常,但 ICE 永远停在 `new` | 即使只测连通也要加一条(静音)音轨 |
|
||
| 4 | ICE 收集完成前就发 offer | SDP 里 0 个候选地址 | 等 `iceGatheringState == "complete"` |
|
||
| 5 | `PipelineWorker.run()` 要 `WorkerParams(task_manager=...)`,**不是** `PipelineParams` | 不传报 "missing 1 required positional argument";传错报 "no attribute 'task_manager'" | 两个参数分别给构造函数和 run() |
|
||
| 6 | `TaskManager()` 内部调 `asyncio.get_running_loop()` | 模块级创建报 "no running event loop" | 必须在异步上下文里建 |
|
||
| 7 | **后台任务里的异常被静默吞掉** | 日志里查不到任何线索,表现为"连接正常但没反应" | 自己 try/except + log.exception |
|
||
| 8 | 信令回调里 `await task.run()` | 阻塞整个会话生命周期,管线初始化时序走歪 | `asyncio.create_task(task.run(...))` |
|
||
| 9 | NLTK `punkt_tab` 在首个 worker 内临时下载 | setup 20 秒超时,看起来像帧路由坏了 | 部署时预装到私有目录,启动前检查 |
|
||
| 10 | `PipelineWorker` 本身会自动发送 StartFrame | 手工再发会看到两个启动帧 | 删除连接回调里的手工 StartFrame |
|
||
| 11 | 文件结束后静音帧 PTS 不增长 | VAD 永远等不到说完 | 源位置与媒体时钟分离,额外发送 1.2s 静音 |
|
||
| 12 | WebRTC 输出轨空闲时也有静音 | “收到 34 秒音频/首音 206ms”的假成功 | 按非静音 RMS 判断业务回复 |
|
||
| 13 | prebuilt Playground 连接前先请求 `/start` | 页面正常但 Connect 在 authenticating 阶段报 Not Found | 增加 `/start`,返回 `/api/offer` 的 `webrtcRequestParams` |
|
||
|
||
第 5、6、7 条各花了好几轮才发现——因为异常在后台任务里,日志什么都不显示。
|
||
|
||
## 五、对 A/B 决策的影响
|
||
|
||
实验前的判断是"Pipecat 的独特价值收敛到传输层"(因为方案 §5 的音频边界决定
|
||
把 AEC 和播放放在客户端,绕开了 Pipecat 的打断机制)。
|
||
|
||
根因已经证明既不是 Pipeline 接线,也不是自定义处理器模型不兼容。因此确定采用
|
||
**A 方案**:保留已验证的 `ConversationEngine`,由薄 `EngineProcessor` 适配
|
||
Pipecat 的输入输出帧。网页客户端需要的 RTVI 继续开启;N5095 精简客户端可以忽略
|
||
暂不认识的 RTVI data-channel 消息。
|
||
|
||
不做 B 方案的全量重构。后续只在确有收益时渐进引入 Pipecat 的 VAD、turn processor、
|
||
metrics 与 interruption,不重写已有的记忆、身份、LLM/TTS 适配。
|
||
|
||
## 六、下一步
|
||
|
||
1. 优化真实首音延迟:分别记录 VAD、STT、LLM 首 token、TTS 首段和客户端播放时间
|
||
2. 继续用 prebuilt 网页客户端做真人语音、浏览器 AEC 与主观试听(基础对话已通过)
|
||
3. Tailscale tailnet 开启 Serve 后,用 HTTPS 暴露 `/client/`
|
||
4. 音频硬件到货后实现 N5095 声卡、AEC、播放参考与打断
|
||
5. 录本人普通话语料,对 Zipformer / SenseVoice / faster-whisper 做 CER 对比
|
||
|
||
## 七、当前代码状态
|
||
|
||
**服务端** `services/bot_server.py`:
|
||
|
||
- 已实现:SmallWebRTC 信令端点 `/api/offer`、prebuilt 网页客户端挂载 `/client/`、
|
||
Playground 启动端点 `/start`、`EngineProcessor`(自写能量 VAD + 引擎调用)
|
||
- 已改用 `PipelineWorker`,保留 RTVI;启动/超时/错误有显式日志
|
||
- 已适配 RTVI `send-text` 产生的 `LLMMessagesAppendFrame`,网页文字输入与
|
||
Conversation 区回复走自有引擎,不依赖框架原生 LLM service;Playground 1.7
|
||
需补发完整 `bot-transcription` / `bot-output`,token 级事件不足以稳定渲染
|
||
- 音频缓冲加入 1 秒预卷、60 秒上限和短噪声丢弃,连接结束释放会话资源
|
||
- VAD 是**极简能量判定**,不是最终方案(方案 §5 的说完判定还没选型)
|
||
- 当前没有唤醒门控:网页连接后每段有效语音都会触发回复。正式唤醒仍在 P2
|
||
的 N5095 本地实现,待机时不上传音频;本次按用户决定暂不处理
|
||
- 已接入联网搜索:DeepSeek 通过 Function Calling 请求 `web_search`,执行层调用
|
||
仅绑定 `127.0.0.1:18080` 的私有 SearXNG,结果作为 `tool` 消息回灌后继续生成;
|
||
网页保留来源 URL,TTS 不朗读来源段
|
||
- 2026-09-16 真实闭环验收通过:模型主动搜索,SearXNG 返回 5 条结果,最终回答带
|
||
两个来源;服务器网络下 360 搜索、搜狗可用,DuckDuckGo/Brave/Wikipedia 超时
|
||
- SearXNG 的完整选型、部署、API、运维和排错记录见
|
||
[[SearXNG私有搜索服务部署与使用]]
|
||
|
||
**客户端** `client/test_client.py`:
|
||
|
||
- 已实现:非 trickle ICE、data channel + 心跳、文件音频实时发送、1.2s 尾静音、回复录音
|
||
- 只有检测到非静音回复才成功;按 WebRTC 实际 48kHz 保存 WAV
|
||
- **不做 AEC、不做播放参考、不做打断**——那些要真实声卡和硬件
|
||
- 它是 N5095 客户端的雏形,不是产品代码
|
||
|
||
## 八、复现
|
||
|
||
```bash
|
||
# 服务端(服务器上)
|
||
cd ~/ai-companion
|
||
.venv/bin/python services/bot_server.py --host 0.0.0.0 --port 7860
|
||
|
||
# 客户端(N5095 上)
|
||
cd ~/workspace/ai-companion
|
||
python client/test_client.py \
|
||
--server http://cadadmin-server.taild0f91d.ts.net:7860 \
|
||
--wav p0/stt_tts_bench/wavs/stt_1.wav --out /tmp/reply.wav
|
||
```
|
||
|
||
当前实测:连接成功,收到非静音语音回复;源音频结束到首个非静音约 5.8 秒。
|
||
浏览器通过 SSH 隧道访问 `http://localhost:7860/client/` 也已完成真人对话;
|
||
2026-09-16 一轮服务端指标约 3.0 秒。
|
||
|
||
## 九、顺带发现的环境问题
|
||
|
||
**局域网被重新编址了**:两台机器从 `192.168.0.x` 变成 `172.16.1.x`
|
||
(N5095 现为 `172.16.1.99`,服务器 `172.16.1.101`)。
|
||
|
||
已处理:客户端默认地址改用 tailscale 域名
|
||
(`cadadmin-server.taild0f91d.ts.net`),抗重新编址。
|
||
|
||
**未处理**:方案文档与 N5095 配置里仍有 `192.168.0.20` 的残留引用。
|