Files
ai-companion/services/bot_server.py
T
ylhan 74a30b11d7 Pipecat 传输层实验:传输层通,帧路由未通
新增实验代码
- services/bot_server.py:Pipecat SmallWebRTC 服务端,
  把 ConversationEngine 包成一个 FrameProcessor(检验 A 方案的核心假设)
- client/test_client.py:自写 aiortc 客户端——Pipecat 的客户端 SDK
  覆盖浏览器/移动端/C++/ESP32,没有适配"Linux x86 盒子接 USB 阵列"这种形态

实验结果
- ✅ 传输层跑通:信令(47–280ms 往返)、ICE 连通、data channel、
  双向媒体轨(客户端音轨送达服务端,服务端音轨客户端收到)
- ❌ 帧路由未通:自定义处理器的 process_frame 调用次数为 0,
  连自己注入的 StartFrame 都没进去,引擎从未运行
- 现象:连接全对、音轨协商成功、助手永远不说话,日志里一条错误都没有

对架构决策的影响
A 方案(Pipecat 只做传输、逻辑归自有引擎)的核心假设不是开箱即用的。
B 方案按框架原生 service 重构,绕开了这个接缝。
根因未定位:接线错误(接缝浅,A 可行)vs 模型不兼容(接缝深,应转 B)。

顺带挖出 8 个 Pipecat 集成坑,每个都表现为"什么都没发生",已记入 README:
客户端需发 data channel ping、offer 无媒体则不收集 ICE 候选、
run() 要 WorkerParams、TaskManager 需在事件循环内建、
后台任务异常被静默吞掉等。

其他
- bot_server.py 保留 [诊断] 前缀日志,供下一轮排查,定位后应清理
- 客户端默认地址改用 tailscale 域名(局域网已于当日重新编址,
  从 192.168.0.x 变为 172.16.1.x)
- 新增依赖 aiortc(含 av 17.x,注意 18.x 与 aiortc 1.15 不兼容)
2026-09-15 17:54:34 +08:00

423 lines
17 KiB
Python
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.
#!/usr/bin/env python3
"""Pipecat SmallWebRTC 服务端(实验)。
**目的**:验证 Pipecat 传输层与自有引擎能否协同工作,并拿到真实传输下的延迟数字。
不是产品代码——见文件末尾"这还不是什么"。
结构:
┌──────────────── Pipecat ────────────────┐
│ transport.input() → EngineProcessor │
│ ↓ │
│ transport.output() │
└─────────────────────────────────────────┘
↑
我们的 ConversationEngine
(STT→记忆→LLM→TTS 全在里面)
EngineProcessor 把整条管线"吞"进一个处理器——这是 A 方案的核心假设:
**Pipecat 提供传输与帧模型,会话语义归我们的引擎。** 这个实验就是在检验它成不成立。
用法:
# 服务器上
.venv/bin/python services/bot_server.py --host 0.0.0.0 --port 7860
# 浏览器打开 http://<服务器IP>:7860/ 点连接后说话
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
import time
import uuid
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
InputAudioRawFrame,
InterruptionFrame,
OutputAudioRawFrame,
StartFrame,
TextFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.smallwebrtc.request_handler import (
SmallWebRTCRequest,
SmallWebRTCRequestHandler,
)
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
from pipecat.utils.asyncio.task_manager import TaskManager
from pipecat.workers.base_worker import WorkerParams
from app.config import AppConfig
from app.engine import ConversationEngine, EventKind
from app.identity import ManualIdentityProvider
from app.llm import FallbackProvider, create_provider
from app.memory import SQLiteMemoryStore
from app.voice import StreamingSTT, create_tts
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("bot")
#: 输入音频格式(WebRTC 侧协商后的格式)
IN_RATE = 16000
IN_CHANNELS = 1
#: 输出音频格式(Qwen3-TTS 输出 24kHz)
OUT_RATE = 24000
#: 管线参数。给 PipelineTask 构造用。
PIPELINE_PARAMS = PipelineParams(
audio_in_sample_rate=IN_RATE,
audio_out_sample_rate=OUT_RATE,
enable_metrics=True,
)
#: 注意:PipelineTask.run() 要的是 WorkerParams(内含 task_manager),
#: **不是** PipelineParams。传错会依次报两个错,且都发生在后台任务里,
#: 主日志里什么都看不到:
#: 1. 不传 → "PipelineWorker.run() missing 1 required positional argument: 'params'"
#: 2. 传错 → "'PipelineParams' object has no attribute 'task_manager'"
#: 而且 TaskManager() 内部调用 asyncio.get_running_loop(),
#: **不能在模块级创建**(会报 "no running event loop"),必须在异步上下文里建。
#: 参考实现:pipecat/workers/runner.py
# ---------------------------------------------------------------- 能量 VAD
class SimpleVAD:
"""极简能量判定。
**这是实验用的占位实现**,不是最终方案。方案 §5 把"说完判定"列为需要
实测的部分,正式的 VAD 还没选(候选:Pipecat 的 Silero、
sherpa-onnx VAD、或客户端 VAD + 服务端二次确认)。
用它的唯一原因是:先把传输层跑通,VAD 的好坏不影响"Pipecat 能不能
当传输层用"这个结论。
"""
def __init__(self, *, threshold: float = 350.0,
silence_ms: int = 700, min_speech_ms: int = 300):
self.threshold = threshold # PCM16 的 RMS 阈值
self.silence_ms = silence_ms
self.min_speech_ms = min_speech_ms
self._silence = 0.0
self._speech = 0.0
def reset(self) -> None:
self._silence = 0.0
self._speech = 0.0
def feed(self, pcm16: np.ndarray, chunk_ms: float) -> str | None:
"""喂一块音频,返回 'start' / 'end' / None。"""
if pcm16.size == 0:
return None
rms = float(np.sqrt(np.mean(pcm16.astype(np.float32) ** 2)))
event = None
if rms >= self.threshold:
if self._speech == 0.0:
event = "start"
self._speech += chunk_ms
self._silence = 0.0
else:
if self._speech > 0:
self._silence += chunk_ms
if self._silence >= self.silence_ms:
if self._speech >= self.min_speech_ms:
event = "end"
self.reset()
return event
# ---------------------------------------------------------------- 引擎处理器
class EngineProcessor(FrameProcessor):
"""把 ConversationEngine 包成一个 Pipecat 处理器。
A 方案的核心假设在这里被检验:**一个处理器吞掉整条会话逻辑,
Pipecat 只负责音频进出和帧路由。**
"""
def __init__(self, cfg: AppConfig, transport: SmallWebRTCTransport):
super().__init__()
log.info("[诊断] EngineProcessor 已创建")
self.cfg = cfg
self.transport = transport
self._vad = SimpleVAD()
self._buf = bytearray() # 当前话语的 PCM16
self._turn: asyncio.Task | None = None
self._engine: ConversationEngine | None = None
self._session = ""
self._bot_speaking = False
# 计时:用于拿真实延迟数字
self._t_speech_end = 0.0
self._t_first_audio = 0.0
# ------------------------------------------------------------ 生命周期
async def _ensure_engine(self) -> ConversationEngine:
if self._engine is None:
mem = SQLiteMemoryStore(Path.home() / ".local/share/ai_assistant/memory.db")
await mem.open()
llm = FallbackProvider(create_provider(self.cfg.llm),
create_provider(self.cfg.llm_fallback))
self._engine = ConversationEngine(
self.cfg, llm, mem, ManualIdentityProvider(),
create_tts(self.cfg.voice), StreamingSTT(self.cfg.voice))
self._session = await self._engine.start_session(user_id="u_ylhan")
return self._engine
async def setup(self, setup):
log.info("[诊断] EngineProcessor.setup() 被调用")
await super().setup(setup)
log.info("[诊断] EngineProcessor 初始化完成")
async def process_frame(self, frame, direction: FrameDirection):
log.info("[诊断·顶] 进入 process_frame: %s", type(frame).__name__)
await super().process_frame(frame, direction)
# [诊断] 记录到达的帧类型分布
self._seen = getattr(self, "_seen", {})
k = type(frame).__name__
self._seen[k] = self._seen.get(k, 0) + 1
if sum(self._seen.values()) <= 40 or self._seen[k] == 1:
log.info("[诊断] 帧: %s (累计 %d)", k, sum(self._seen.values()))
if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
elif isinstance(frame, InputAudioRawFrame):
await self._on_audio(frame)
elif isinstance(frame, InterruptionFrame):
# 客户端判定打断后发来的信号(或 Pipecat 内部产生)
log.info("收到打断信号,取消当前生成")
await self._cancel_turn()
await self.push_frame(frame, direction)
elif isinstance(frame, (EndFrame, CancelFrame)):
await self._cancel_turn()
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
# ------------------------------------------------------------ 音频处理
async def _on_audio(self, frame: InputAudioRawFrame) -> None:
pcm16 = np.frombuffer(frame.audio, dtype=np.int16)
chunk_ms = len(pcm16) / IN_RATE * 1000.0
# 头几帧打日志,用于确认音频真的到了处理器
self._n_audio = getattr(self, "_n_audio", 0) + 1
if self._n_audio <= 3:
rms = float(np.sqrt(np.mean(pcm16.astype(np.float32) ** 2))) if pcm16.size else 0.0
log.info("收到音频帧 #%d: %d 采样, %.0fms, RMS=%.0f",
self._n_audio, pcm16.size, chunk_ms, rms)
# 助手正在说话时收到的音频 = 用户插话的候选。
# 这里只记日志,真正的打断判定在客户端(见方案 §5 的边界决定)。
if self._bot_speaking and float(np.sqrt(np.mean(pcm16.astype(np.float32) ** 2))) > self._vad.threshold:
log.info("检测到用户插话(助手正在播放)")
self._buf.extend(frame.audio)
event = self._vad.feed(pcm16, chunk_ms)
if event == "end":
audio = np.frombuffer(bytes(self._buf), dtype=np.int16).astype(np.float32) / 32768.0
self._buf.clear()
if len(audio) > IN_RATE * 0.3: # 太短的不处理
self._t_speech_end = time.perf_counter()
self._t_first_audio = 0.0
await self._cancel_turn()
self._turn = asyncio.create_task(self._run_turn(audio))
async def _cancel_turn(self) -> None:
if self._turn and not self._turn.done():
self._turn.cancel()
try:
await self._turn
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
self._turn = None
# ------------------------------------------------------------ 跑一轮
async def _run_turn(self, audio: np.ndarray) -> None:
try:
engine = await self._ensure_engine()
async for ev in engine.respond_to_audio(audio, session_id=self._session):
if ev.kind == EventKind.RESPONSE_DELTA and ev.text:
await self.push_frame(TextFrame(text=ev.text))
elif ev.kind == EventKind.SPEAK and ev.audio is not None and len(ev.audio):
if self._t_first_audio == 0.0:
self._t_first_audio = time.perf_counter()
log.info("首音延迟(说完→首段音频): %.0f ms",
(self._t_first_audio - self._t_speech_end) * 1000)
await self._push_audio(ev.audio, ev.sample_rate)
elif ev.kind == EventKind.METRICS and ev.metrics:
log.info("本轮指标: %s", ev.metrics.summary())
except asyncio.CancelledError:
log.info("本轮被取消")
raise
except Exception as e: # noqa: BLE001
log.exception("跑轮失败: %s", e)
async def _push_audio(self, samples: np.ndarray, sample_rate: int) -> None:
"""把 float32 音频推给传输层。必要时重采样到 OUT_RATE。"""
if sample_rate != OUT_RATE:
# 简单线性重采样。实验够用;正式实现应该用 soxr/sox(已装)。
n = int(len(samples) * OUT_RATE / sample_rate)
samples = np.interp(np.linspace(0, len(samples) - 1, n),
np.arange(len(samples)), samples).astype(np.float32)
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
self._bot_speaking = True
# 按 20ms 一块推,模拟渐进播放
step = OUT_RATE // 50
for i in range(0, len(pcm), step):
chunk = pcm[i:i + step]
await self.push_frame(OutputAudioRawFrame(
audio=chunk.tobytes(), sample_rate=OUT_RATE, num_channels=1))
self._bot_speaking = False
async def cleanup(self):
await self._cancel_turn()
await super().cleanup()
# ---------------------------------------------------------------- HTTP 服务
app = FastAPI(title="AI Companion · Pipecat experimental server")
handler = SmallWebRTCRequestHandler()
_cfg: AppConfig | None = None
_tasks: list[PipelineTask] = []
_bg_tasks: set[asyncio.Task] = set() # 持有后台 task 引用,避免被 GC
@app.post("/api/offer")
async def offer(request: SmallWebRTCRequest):
"""WebRTC 信令:客户端发 offer,我们回 answer。"""
async def on_connection(connection):
transport = SmallWebRTCTransport(
webrtc_connection=connection,
params=TransportParams(
audio_in_enabled=True,
audio_in_sample_rate=IN_RATE,
audio_in_channels=IN_CHANNELS,
audio_out_enabled=True,
audio_out_sample_rate=OUT_RATE,
audio_out_channels=1,
),
)
engine_proc = EngineProcessor(_cfg, transport)
pipeline = Pipeline([transport.input(), engine_proc, transport.output()])
# 注意:这个版本里 PipelineParams 没有 allow_interruptions——
# 打断在 1.10 已移到 pipecat/turns/ 框架。实验阶段我们不用它的
# 轮次框架(方案 §5 的边界决定是"打断由客户端判定"),
# 所以这里只开指标。
# 注意:这个版本的 PipelineTask.run(params) **要求单独传 params**,
# 只传给构造函数不够。漏传的报错是
# "PipelineWorker.run() missing 1 required positional argument: 'params'"
# 而这个异常发生在后台任务里,会被静默吞掉——
# 现象是"连接正常但什么反应都没有",而且日志里一条错误都看不到。
# 所以下面的 _run_task 里必须 log.exception,不能让异常无声消失。
task = PipelineTask(pipeline)
_tasks.append(task)
log.info("新连接已建立,管线启动")
@transport.event_handler("on_client_connected")
async def _on_conn(t, client):
# **这一步不能省。** 传输层的音频推流由 StartFrame 驱动
# (base_input.start()),而 PipelineTask.run() 自己不会补发。
# 不喂 StartFrame 的话:ICE 连通、信令全对、但一帧音频都收不到,
# 表现为"连上了但助手永远不说话"。
log.info("客户端已连接,发送 StartFrame 启动音频流")
await task.queue_frames([StartFrame()])
@transport.event_handler("on_client_disconnected")
async def _on_disc(t, client):
log.info("客户端断开")
await task.cancel()
# **必须作为后台任务跑,不能 await。**
# 这个回调是被 handle_web_request 调用的,而后者又在 HTTP 请求里被 await。
# 如果这里 `await task.run()`,就会在信令请求里阻塞整个会话的生命周期,
# 管线初始化的时序会歪掉——实测表现为 setup()/connect() 不被调用,
# 于是 on_client_connected 永远不触发、StartFrame 不发、一帧音频都收不到。
# 官方 runner 用的是 background_tasks.add_task(bot, args),同样的道理。
async def _run_task():
log.info("[诊断] PipelineTask 开始运行")
try:
# TaskManager 必须在这里建——模块级没有运行中的事件循环
await task.run(WorkerParams(task_manager=TaskManager()))
except Exception as e: # noqa: BLE001
log.exception("[诊断] PipelineTask 异常退出: %s", e)
log.info("[诊断] PipelineTask 已结束")
bg = asyncio.create_task(_run_task())
_bg_tasks.add(bg)
bg.add_done_callback(_bg_tasks.discard)
answer = await handler.handle_web_request(request, on_connection)
if answer is None:
return {"status": "ok"}
return answer
@app.get("/health")
async def health():
return {"status": "ok", "connections": len([t for t in _tasks if not t.has_finished()])}
@app.get("/")
async def root():
return RedirectResponse("/client/")
def mount_client() -> bool:
"""挂载 prebuilt 网页客户端(用于测试)。"""
try:
from pipecat_ai_small_webrtc_prebuilt.frontend import SmallWebRTCPrebuiltUI
app.mount("/client", SmallWebRTCPrebuiltUI, name="client")
return True
except Exception as e: # noqa: BLE001
log.warning("prebuilt 客户端不可用(不影响服务端): %s", e)
return False
def main() -> int:
global _cfg
p = argparse.ArgumentParser()
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int, default=7860)
a = p.parse_args()
_cfg = AppConfig.load()
log.info("LLM=%s TTS=%s/%s STT=%d线程",
_cfg.llm.model, _cfg.voice.tts_backend, _cfg.voice.tts_speaker,
_cfg.voice.stt_threads)
mounted = mount_client()
log.info("网页客户端: %s", "已挂载 /client/" if mounted else "未挂载")
import uvicorn
uvicorn.run(app, host=a.host, port=a.port, log_level="warning")
return 0
if __name__ == "__main__":
sys.exit(main())