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
+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())