什么情况下你非得自己架一台 TURN
先说结论方向:绝大多数人一开始纠结的 ICE、STUN、TURN,在 speech-to-speech 这套流水线里其实只跟一条传输路径有关。走错路的人比配错参数的人多得多,所以第一步不是查 NAT,是先确认自己到底在哪条路上。
第一步:确认你真的在 WebRTC 这条路上
speech-to-speech 的实时服务是一个 FastAPI / uvicorn 服务,同时暴露 WebSocket 与 WebRTC 两种传输,两种传输共用同一套事件协议,也从同一个池里认领会话所用的 PipelineUnit。
WebSocket 那条路是一条普通的长连接:客户端把音频以 base64 PCM 通过 input_audio_buffer.append 事件发上去,服务端解码、重采样到 16 kHz、切成 512 采样点的块喂给 VAD。这条路上不存在打洞问题,也就谈不上 ICE。
WebRTC 那条路才需要 ICE。判定动作有三个,任意一个成立就说明你确实在 WebRTC 模式:
- 安装时用的是
pip install "speech-to-speech[webrtc]"——WebRTC 传输需要这个 extra,默认安装并不带。 - 客户端建连方式是
POST /v1/realtime/calls,Content-Type: application/sdp,发的是一个 SDP offer。 - 你在会话里发了
input_audio_buffer.append,服务端拒绝了它并返回invalid_event_for_transport。这个错误码本身就是判定依据:WebRTC 模式下音频走媒体轨,不走事件通道,客户端的写法还停在 WebSocket 的习惯上。
还有一处时机差异值得记住:WebRTC 模式下 session.created 是在数据通道打开时发送的,而不是连接建立时。写客户端状态机的人如果按 WebSocket 的时序去等它,会觉得服务端”没反应”。
第二步:搞清楚不配 ICE 时的默认行为
如果不设置环境变量 SPEECH_TO_SPEECH_ICE_SERVERS,服务端用的是 aiortc 的默认配置:host candidates 加 Google STUN。
这两样东西解决的都是”我在哪”的问题,而不是”包能不能过去”的问题。host candidates 是服务端自己网卡上的地址,STUN 帮它发现自己在出口 NAT 上的映射地址。两者都只是把候选地址报给对端,媒体流最终仍然要求双方能把 UDP 包直接送到对面。
所以下面这几种情况,默认值大概率就够用,别急着架东西:
- 服务端和客户端在同一台机器上,或同一个局域网段里;
- 服务端有公网 IP,且防火墙/安全组把 UDP 放通了;
- 客户端是普通家庭宽带,没有被强约束的出口策略。
第三步:官方点名的两种”必须上 TURN”的形态
事实卡对应的官方文档在这里说得很直白:客户端无法直连服务端的部署——对称 NAT、没有暴露 UDP 的容器——需要 TURN 服务器。
这两种形态怎么对号入座:
没暴露 UDP 的容器是最常见也最容易被忽略的一种。很多人把服务塞进容器时只映射了 HTTP/WebSocket 用的那个 TCP 端口(默认 8765),信令一切正常,SDP 也换成功了,就是听不到声音——因为媒体那条 UDP 通路压根没有从宿主机进到容器里。这种情况下先别怀疑模型和音频参数,去看端口映射。
对称 NAT 则通常出在服务端或客户端所处的网络出口上。它的特征是:对不同目标地址会分配不同的映射端口,于是 STUN 探出来的那个地址对第三方并不成立,候选地址报了也白报。
TURN 与 STUN 的区别落到工程上就一句话:STUN 只在建连阶段被问一次,TURN 要在整个通话期间替你中继媒体流。这意味着带宽和费用是持续消耗的,这也是”能直连就别架”的现实理由。(这一段属于 WebRTC 的通用网络常识,不是该项目文档的内容。)
第四步:配置怎么写
ICE 服务器通过环境变量传入,值是一个 JSON 列表:
export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'
逐项解释这三个键为什么在这:
urls:服务器地址,STUN 条目写stun:前缀,TURN 条目写turn:前缀。上面这条 STUN 示例带了端口3478,TURN 示例没写端口。username/credential:TURN 的访问凭据。STUN 那条目里没有这两个键——这也是判断”我到底配没配上 TURN”的最直观标志:列表里只有stun:条目,就是没配 TURN。- 列表可以同时放 STUN 与 TURN 条目,官方给的示例就是两者并列的写法。
一条把 extra、ICE 配置和启动串起来的完整示例:
pip install "speech-to-speech[webrtc]"
export SPEECH_TO_SPEECH_ICE_SERVERS='[{"urls": "stun:stun.example.com:3478"}, {"urls": "turn:turn.example.com", "username": "u", "credential": "c"}]'
export OPENAI_API_KEY=<YOUR_API_KEY>
speech-to-speech serve --host 0.0.0.0 --port 8765
其中 --host 默认是 127.0.0.1,只有显式传 0.0.0.0 才会把服务暴露到网络上;--port 默认就是 8765,写出来只是为了让这条命令自解释。以上为按官方参数语义组合的示例,未逐项实测,以官方文档与 speech-to-speech serve -h 的实际输出为准。
关于 --host 0.0.0.0 必须说清楚的一件事:官方文档写得很明确——服务端自己不做任何认证,也不做任何限流;只应该在受信网络上启用,或者把服务部署在一个自己掌管访问控制的网关后面。TURN 解决的是”包能不能到”,它一点也不解决”谁能连”。这两件事千万别混在一起想。
Windows 侧的写法差异:上面的 export 是 Linux/macOS 与 Git Bash 的语法。PowerShell 里对应写成 $env:SPEECH_TO_SPEECH_ICE_SERVERS = '...',外层用单引号包住整段 JSON,让里面的双引号原样保留。这属于 shell 层面的通用差异,不是该项目文档的内容,但它是本站读者最容易在这一步卡住的地方——JSON 的双引号被 shell 吃掉之后,报错信息通常离真正原因很远。
第五步:产出物与验收点
配完之后,有依据可查的产物只有这么几处,其余的日志文案、界面提示我们没有依据,就不替你想象:
- 客户端 POST 一个 SDP offer 到
/v1/realtime/calls,会收到 SDP answer,状态码 201,并带Location: /v1/realtime/calls/{call_id}响应头。 - 之后音频走 RTP 媒体轨,编码是 Opus、48 kHz,与流水线内部的 16 kHz 之间由有状态重采样器互转;发送侧按 20 ms 一帧的节奏推,空闲时发静音。
- 所有 JSON 事件走
oai-events数据通道,协议与 WebSocket 模式相同。
按这个顺序验收,能把故障切成三段,不至于一上来就怀疑 NAT:
- 信令段:SDP 交换有没有拿到 201 和
Location头。拿不到说明问题在 HTTP 层或 extra 没装,跟 ICE 完全无关,配再多 TURN 也没用。 - 数据通道段:
session.created有没有在数据通道打开后到达。到了就说明事件通路是通的。 - 媒体段:对着麦克风说话之后,看有没有
input_audio_buffer.speech_started这类事件从数据通道回来。这个事件是 VAD 检测到语音才发的——它出现,就证明入站媒体真的送达了服务端;它一直不来而前两段都正常,才轮到怀疑 UDP 通路和 TURN。
另外有一个特别容易被误判成网络问题的坑:--num_pipelines 默认是 1。它的 help 原文说的是最大并发 WebSocket 会话数等于该值,更多的连接会被拒绝;而 WebRTC 会话是从同一个池里认领 pipeline unit 的。所以两个人一起测的时候,第二个人连不上,很可能只是池子被占满了,跟 NAT、TURN 一点关系都没有。改这个值之前先想清楚:help 原文说每个 unit 都有自己独立的 VAD/STT/LM/TTS handler 与各自的会话状态,池子开大就是成倍的资源占用。
什么情况下不该折腾这件事
- 能直连就别架。服务端有公网 IP、UDP 放通、客户端网络正常,默认的 host candidates 加 STUN 就够了,架 TURN 只是给自己加一份持续的带宽账单。
- 你走的是 WebSocket 传输。那条路上没有 ICE 这一环,把精力放在这里是白花。
- 你在用
local命令。它永远绑定环回,并把自带客户端连到本机的实时服务,整件事跟打洞无关。 - 你真正要解决的是访问控制。TURN 不做鉴权、不做限流、不替你挡住任何人。这类需求得靠你自己在前面放网关,而且必须结合自身环境评估,不能指望一条环境变量。
- 需要人工兜底的地方:TURN 凭据是有有效期和配额的,凭据本身写错通常不会在启动阶段报错,只会在建连阶段表现成”连不上”。这一点属于 TURN 的通用行为,不是该项目文档写的,排查时把它当成一个独立的怀疑对象,别和项目参数搅在一起。
延伸阅读
- 默认绑 127.0.0.1 是有道理的:改成 0.0.0.0 之前想清楚
- —enable_llm_proxy:不认证、不限流,官方自己说的
--num_pipelines默认是 1:多人用之前必须先动它
本文依据 speech-to-speech 官方仓库(github.com/huggingface/speech-to-speech)的 README、
src/speech_to_speech/arguments_classes/ 下的参数定义与 Realtime Engine 架构文档整理,核对日 2026-08-09。
本文内容为仓库源码与文档口径,我们没有安装、部署或调用过该服务,文中毫秒值均为参数默认值而非实测延迟。
参数与默认值随版本变动,请以 speech-to-speech serve -h 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。