Files
note_sync/有趣的项目/AI桌面陪伴助手/P0-Pipecat传输层实验报告.md
T

152 lines
9.2 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.
# 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` 的残留引用。