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 不兼容)
This commit is contained in:
ylhan
2026-09-15 17:54:34 +08:00
parent 35e107c290
commit 74a30b11d7
4 changed files with 819 additions and 2 deletions
+39 -1
View File
@@ -31,7 +31,11 @@ app/ 助手本体(部署到服务器)
└── tests/ 4 个测试,共 88 项断言,全部无需硬件
services/
└── tts_server.py Qwen3-TTS 服务(跑在 ~/tts-qwen,独立 venv)
├── tts_server.py Qwen3-TTS 服务(跑在 ~/tts-qwen,独立 venv)
└── bot_server.py Pipecat SmallWebRTC 服务端(实验,见下)
client/
└── test_client.py 自写 aiortc 客户端(实验用,也是 N5095 客户端的雏形)
p0/ P0 阶段的验证与基准脚本(保留用于复现)
├── verify_deepseek.py DeepSeek 四条路径
@@ -76,6 +80,40 @@ cd ~/ai-companion
**引擎与传输解耦。** 不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口。
好处:可以用文件音频端到端测试;接 WebRTC 时只需写薄适配层。
## 当前卡点:Pipecat 传了但没接上
方案文档写"Pipecat 作为唯一会话管线框架",但**代码里它只在 requirements.txt 里**。
2026-09-15 做了传输层实验(`services/bot_server.py` + `client/test_client.py`):
- ✅ **传输层跑通**:信令、ICE、data channel、双向媒体轨
- ❌ **帧路由未通**:自定义处理器的 `process_frame` 调用次数为 0,
连自己注入的 StartFrame 都没进去;引擎从未运行
现象是"连接全对、音轨协商成功、但助手永远不说话,且日志里一条错误都没有"。
**这直接影响 A/B 架构方案**:A 是"Pipecat 只做传输、逻辑归自有引擎",
B 是"按 Pipecat 的 service 模型重构"。帧送不进自定义处理器,
说明 A 的核心假设不是开箱即用的。
根因未定位,两种可能必须区分:接线错误(接缝浅,A 可行)
vs 模型不兼容(接缝深,应转 B)。
完整记录见代码仓库外的 `P0-Pipecat传输层实验报告.md`(在 Obsidian 笔记库里)。
### 实验挖出的 8 个坑
每个都表现为"什么都没发生",极难排查:
1. Pipecat 客户端 SDK 不覆盖"Linux x86 盒子接 USB 阵列"这种形态 → 客户端得自己写
2. 客户端必须建 data channel 并定期发 `"ping"`,否则服务端一帧音频都不推
3. offer 里没有媒体时 aiortc **完全不收集 ICE 候选**(连连通性测试都要加静音轨)
4. ICE 收集完成前发 offer → SDP 里 0 个候选地址
5. `PipelineTask.run()` 要 `WorkerParams(task_manager=...)`,不是 `PipelineParams`
6. `TaskManager()` 必须在事件循环内创建
7. **后台任务里的异常被静默吞掉**,日志里查不到
8. 信令回调里必须 `create_task`,不能 `await task.run()`
## 踩过的坑
全部写在对应文件的注释里,这里只列索引,详见 `部署说明.md` §已知的坑:
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""最小 aiortc 客户端(实验用)。
**为什么需要它**:Pipecat 的官方客户端 SDK 覆盖浏览器 / 移动端 / C++ / ESP32,
唯独没有适配"跑 Linux 的 x86 盒子接 USB 麦克风阵列"——也就是 N5095 的形态。
所以客户端必须自己写,这个文件是雏形。
它同时是验证工具:不依赖浏览器就能测服务端。用法:
# 发一段 wav,收回应答音频
python client/test_client.py --server http://<服务器>:7860 \
--wav p0/stt_tts_bench/wavs/stt_1.wav --out /tmp/reply.wav
# 只做连接测试,不发音频
python client/test_client.py --server http://<服务器>:7860 --connect-only
**它还不是什么**:不做 AEC、不做播客参考、不做打断(那些要真实声卡和硬件),
只用文件音频验证信令与媒体通路。
"""
from __future__ import annotations
import argparse
import asyncio
import fractions
import json
import logging
import sys
import time
import urllib.request
import wave
from pathlib import Path
import numpy as np
from aiortc import (
RTCConfiguration,
RTCPeerConnection,
RTCSessionDescription,
)
from aiortc.mediastreams import AudioStreamTrack, MediaStreamError
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("client")
class SilentTrack(AudioStreamTrack):
"""静音音轨。只为触发 ICE 候选收集,不发真实音频。"""
kind = "audio"
def __init__(self, sample_rate: int = 16000):
super().__init__()
self.sample_rate = sample_rate
self._n = 0
async def recv(self):
import fractions as _fr
from av import AudioFrame as _AF
await asyncio.sleep(0.02)
pcm = np.zeros(self.sample_rate // 50, dtype=np.int16)
f = _AF(format="s16", layout="mono", samples=len(pcm))
f.planes[0].update(pcm.tobytes())
f.sample_rate = self.sample_rate
f.pts = self._n
f.time_base = _fr.Fraction(1, self.sample_rate)
self._n += len(pcm)
return f
class FileAudioTrack(AudioStreamTrack):
"""把 wav 文件当作麦克风音源,按实时速率吐出来。
实时速率很重要——如果一次性灌完,服务端的 VAD 会把它当成
一段超长的连续语音,测出来的延迟也不真实。
"""
kind = "audio"
def __init__(self, wav_path: Path, *, sample_rate: int = 16000):
super().__init__()
with wave.open(str(wav_path)) as w:
assert w.getnchannels() == 1, "需要单声道"
self.src_rate = w.getframerate()
pcm = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
self.samples = pcm.astype(np.float32) / 32768.0
self.sample_rate = sample_rate
if self.src_rate != sample_rate:
n = int(len(self.samples) * sample_rate / self.src_rate)
self.samples = np.interp(np.linspace(0, len(self.samples) - 1, n),
np.arange(len(self.samples)), self.samples)
self._pos = 0
self._t0: float | None = None
self._frame_samples = sample_rate // 100 # 10ms
async def recv(self):
"""按 10ms 一块、以真实时间节奏产出。"""
if self._t0 is None:
self._t0 = time.perf_counter()
self._pos = 0
# 音频放完后持续吐静音,让服务端的 VAD 能判到"说完"
if self._pos >= len(self.samples):
audio = np.zeros(self._frame_samples, dtype=np.float32)
else:
audio = self.samples[self._pos:self._pos + self._frame_samples]
if len(audio) < self._frame_samples:
audio = np.pad(audio, (0, self._frame_samples - len(audio)))
self._pos += self._frame_samples
# 实时节奏
target = self._t0 + self._pos / self.sample_rate
delay = target - time.perf_counter()
if delay > 0:
await asyncio.sleep(delay)
pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16)
from av import AudioFrame
f = AudioFrame(format="s16", layout="mono", samples=len(pcm))
f.planes[0].update(pcm.tobytes())
f.sample_rate = self.sample_rate
f.pts = int(self._pos * 90000 / self.sample_rate)
f.time_base = fractions.Fraction(1, 90000)
return f
@property
def finished(self) -> bool:
return self._pos >= len(self.samples)
class ReplyRecorder:
"""收服务端音频,写 wav,并记录首音延迟。"""
def __init__(self, out_path: Path, sample_rate: int = 24000):
self.out_path = out_path
self.sample_rate = sample_rate
self.chunks: list[np.ndarray] = []
self.first_at: float | None = None
self.t0 = time.perf_counter()
def feed(self, frame) -> None:
if self.first_at is None:
self.first_at = time.perf_counter()
arr = frame.to_ndarray()
# 多声道取第一路
if arr.shape[0] > 1:
arr = arr[0:1]
self.chunks.append(arr.astype(np.float32).reshape(-1) / 32768.0)
def save(self) -> float:
if not self.chunks:
return 0.0
audio = np.concatenate(self.chunks)
pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16)
self.out_path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(self.out_path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(self.sample_rate)
w.writeframes(pcm.tobytes())
return len(audio) / self.sample_rate
@property
def first_audio_ms(self) -> float | None:
return None if self.first_at is None else (self.first_at - self.t0) * 1000
def post_offer(server: str, sdp: str, type_: str) -> dict:
"""把 offer 发给服务端,取回 answer。"""
payload = json.dumps({"sdp": sdp, "type": type_, "pc_id": None,
"restart_pc": False, "request_data": None}).encode()
req = urllib.request.Request(f"{server.rstrip('/')}/api/offer", data=payload,
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
timeout_s: float) -> int:
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
recorder = ReplyRecorder(out)
track = None
got_audio = asyncio.Event()
@pc.on("connectionstatechange")
async def _state():
log.info("连接状态: %s", pc.connectionState)
if pc.connectionState in ("failed", "closed"):
got_audio.set()
@pc.on("track")
def _track(t):
log.info("收到服务端音轨: %s", t.kind)
async def _pump():
try:
while True:
frame = await t.recv()
recorder.feed(frame)
if not got_audio.is_set() and recorder.chunks:
pass
except (MediaStreamError, asyncio.CancelledError):
pass
finally:
got_audio.set()
asyncio.ensure_future(_pump())
if wav and not connect_only:
track = FileAudioTrack(wav)
pc.addTrack(track)
log.info("音源: %s(%d 采样点)", wav.name, len(track.samples))
else:
# --connect-only 也要加一条(静音)音轨。
# **不能省**:aiortc 的 offer 里没有媒体时完全不收集 ICE 候选,
# 现象是"信令成功、SDP 交换正常、但 ICE 永远停在 new"。
pc.addTrack(SilentTrack())
log.info("连接测试模式:已加静音音轨(仅为触发 ICE 收集)")
# **必须建 data channel 并定期发 "ping"。**
# Pipecat 的 SmallWebRTC 用 data channel 上的消息来判断"客户端还活着":
# if isinstance(message, str) and message.startswith("ping"):
# self._last_received_time = time.time()
# 而 connection 的 connected 事件、进而 on_client_connected 回调、
# 进而 StartFrame(音频推流的开关)都依赖它。
# 不发 ping 的现象是:ICE 连通、音轨协商成功、但**服务端一帧音频都不推**、
# 助手永远不说话,而且日志里只有一句 "Data channel not established within 10s"。
channel = pc.createDataChannel("pipecat")
dc_state = {"ok": False}
@channel.on("open")
def _dc_open():
dc_state["ok"] = True
log.info("data channel 已打开,开始发心跳")
async def _keepalive():
while pc.connectionState not in ("closed", "failed"):
if dc_state["ok"]:
try:
channel.send("ping")
except Exception: # noqa: BLE001
pass
await asyncio.sleep(1.0)
asyncio.ensure_future(_keepalive())
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
# 等 ICE 收集完成再发 offer——**这步不能省**。
# 不等的话 SDP 里没有任何候选地址,服务端拿到的是一份"空地址"的 offer,
# 信令看着成功,但 ICE 永远连不上(状态停在 new)。
# 这叫非 trickle ICE:把候选都塞进 SDP 一次发完。
# 真实产品会用 trickle(边收集边发候选),但那样需要额外的信令端点。
for _ in range(100):
if pc.iceGatheringState == "complete":
break
await asyncio.sleep(0.1)
n_cand = pc.localDescription.sdp.count("candidate:")
log.info("ICE 收集: %s,SDP 含 %d 个候选", pc.iceGatheringState, n_cand)
if n_cand == 0:
log.warning("SDP 里没有候选地址,ICE 很可能连不上")
log.info("本地 SDP 已生成,发给服务端…")
t_send = time.perf_counter()
answer = post_offer(server, pc.localDescription.sdp, pc.localDescription.type)
if answer.get("type") not in ("answer", None):
log.error("服务端返回异常: %s", answer)
return 1
if answer.get("sdp"):
await pc.setRemoteDescription(RTCSessionDescription(
sdp=answer["sdp"], type=answer["type"]))
log.info("远端 SDP 已设置(往返 %.0f ms)", (time.perf_counter() - t_send) * 1000)
else:
log.info("服务端未返回 SDP(可能是 pc_id 复用): %s", answer)
# 等 ICE 连通
for _ in range(100):
if pc.connectionState in ("connected", "completed"):
break
if pc.connectionState in ("failed", "closed"):
log.error("连接失败")
await pc.close()
return 1
await asyncio.sleep(0.1)
log.info("✅ ICE 已连通(%s)", pc.connectionState)
print(f" ✅ 连接建立,状态 {pc.connectionState}")
if track:
# 等音频放完 + 留出响应时间
t0 = time.perf_counter()
while not track.finished and time.perf_counter() - t0 < timeout_s:
await asyncio.sleep(0.2)
log.info("音频已发完,等待回复…")
# 等首个回复音频
t_wait = time.perf_counter()
while time.perf_counter() - t_wait < timeout_s:
if recorder.chunks:
break
await asyncio.sleep(0.1)
# 再收一会儿,让回复说完
await asyncio.sleep(6)
dur = recorder.save()
await pc.close()
got_audio.set()
print()
print("=== 结果 ===")
print(f" 连接: {'✅ 成功' if dur > 0 else '⚠️ 无音频返回'}")
if dur:
print(f" 收到音频: {dur:.2f} 秒 → {out}")
fa = recorder.first_audio_ms
if fa:
print(f" 首音延迟(发完到收到首段): {fa:.0f} ms")
return 0 if dur > 0 else 1
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--server", default="http://cadadmin-server.taild0f91d.ts.net:7860",
help="服务端地址。默认用 tailscale 域名——LAN 地址会被重新编址"
"(2026-09-15 实测从 192.168.0.x 变成 172.16.1.x)")
p.add_argument("--wav", type=Path)
p.add_argument("--out", type=Path, default=Path("/tmp/client_reply.wav"))
p.add_argument("--connect-only", action="store_true")
p.add_argument("--timeout", type=float, default=30.0)
a = p.parse_args()
if not a.connect_only and not a.wav:
print("需要 --wav 或 --connect-only", file=sys.stderr)
return 2
return asyncio.run(run(a.server, a.wav, a.out,
connect_only=a.connect_only, timeout_s=a.timeout))
if __name__ == "__main__":
sys.exit(main())
+422
View File
@@ -0,0 +1,422 @@
#!/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())
+20 -1
View File
@@ -51,6 +51,8 @@ DoA 读取和网络桥接,不跑任何推理。
```
~/ai-companion/ 助手主体
├── client/test_client.py 自写 aiortc 客户端(实验用)
├── services/bot_server.py Pipecat SmallWebRTC 服务端(实验,端口 7860)
├── app/ 24 个 py 文件
│ ├── config.py 配置,支持环境变量覆盖
│ ├── prompts.py 单条 system 每轮重建
@@ -98,7 +100,11 @@ TTS 的线程扩展方向又和 STT 相反(越多越快)。
cd ~/tts-qwen && setsid nohup .venv/bin/python tts_server.py --port 18040 \
> logs/tts_service.log 2>&1 < /dev/null &
# 2. 确认就绪
# 2. (可选)Pipecat 传输层服务端 —— 实验状态,帧路由未通
cd ~/ai-companion && setsid nohup .venv/bin/python services/bot_server.py \
--host 0.0.0.0 --port 7860 > logs/bot_server.log 2>&1 < /dev/null &
# 3. 确认就绪
curl -s http://127.0.0.1:18040/health
# 3. 跑测试
@@ -154,6 +160,19 @@ rsync -az --delete --exclude '__pycache__' --exclude '*.pyc' \
(vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下
GitHub release 会断连。
## 当前卡点:Pipecat 帧路由未通
2026-09-15 的传输层实验:**传输层跑通(信令/ICE/data channel/双向媒体轨),
但帧送不进自定义处理器**——`process_frame` 调用次数为 0,引擎从未运行。
现象:连接全对、音轨协商成功、助手永远不说话,**日志里一条错误都没有**。
这直接影响 A/B 架构方案(A:Pipecat 只做传输;B:按框架 service 模型重构)。
根因未定位。完整记录见 Obsidian 笔记库里的 `P0-Pipecat传输层实验报告.md`。
**注意** `services/bot_server.py` 里目前还带着 `[诊断]` 前缀的日志,
是留给下一轮排查用的,定位后应清理。
## 未做的事
- **没有常驻服务**:TTS 服务和助手都没有 systemd 开机自启(方案 §10 要求 P2 后做)