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:
@@ -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())
|
||||
Reference in New Issue
Block a user