1. 项目缘起与整体架构拆解
WebRTC 和 SIP 这两个协议栈,一个扎根在浏览器生态,一个统治着传统电话网络,各自都活得挺好,但偏偏在很多企业通信场景里,业务方就是想让浏览器里的用户直接拨通电话网络里的号码,或者反过来让电话网络里的呼叫能落到网页端接听。这个需求听起来简单,做起来却要跨越两套完全不同的信令体系、媒体传输机制和编解码协商逻辑。我接手这个项目的时候,团队已经尝试过几种方案,要么延迟高得离谱,要么在 NAT 环境下频繁掉线,最后决定走一条更可控的路线:用声网的音视频 SDK 处理 WebRTC 侧的媒体采集、编码和传输,用 FreeSWITCH 作为 SIP 侧的软交换核心,中间自己写一个 Gateway 来做协议转换和媒体桥接。
这个 Gateway 的核心任务可以拆成三块:信令转换、媒体协商、以及媒体流的转发或转码。信令转换负责把 WebRTC 的 SDP 和 SIP 的 SDP 互相翻译,同时把信令通道从 WebSocket 映射到 SIP over UDP/TCP;媒体协商要处理 ICE、DTLS、SRTP 这些 WebRTC 特有的安全传输机制与 SIP 侧 RTP/RTCP 的对接;媒体转发则是决定是走旁路还是走网关中转,这直接影响到延迟和服务器带宽成本。我选择声网 SDK 的原因在于它已经把 WebRTC 的采集、前处理、编码、以及弱网对抗都封装得很成熟,尤其是它的 SDK 内部已经处理了 ICE 和 DTLS 的很多细节,我只需要关注怎么把它的媒体流引出来,而不是从头去啃 WebRTC 原生 API 那一堆让人头大的回调。
FreeSWITCH 这边,它的 mod_sofia 模块本身就是个非常成熟的 SIP 栈,支持大量的编解码和协议扩展,而且它的 event socket 机制让我可以用外部进程来控制呼叫路由,不需要写 C 模块。这样一来,整个 Gateway 可以用 Python 或者 Node.js 来写业务逻辑,通过 ESL 或者 mod_xml_curl 跟 FreeSWITCH 交互,开发效率高很多。整体架构上,我把它分成三个平面:控制平面负责信令转换和呼叫路由,媒体平面负责 RTP 流的转发或转码,管理平面负责监控和日志。控制平面用 WebSocket 服务器接收来自浏览器的信令,然后转换成 SIP 消息发给 FreeSWITCH;媒体平面则根据协商结果决定是让声网 SDK 直接把 RTP 推给 FreeSWITCH,还是通过一个 RTP 代理来中转。
这个架构最大的好处是解耦。声网 SDK 负责它最擅长的事——在复杂网络环境下保证媒体质量;FreeSWITCH 负责它最擅长的事——SIP 呼叫控制和 PSTN 对接;中间的 Gateway 只做协议适配和状态同步,逻辑清晰,排查问题也容易定位。当然,代价是引入了一个额外的跳数,延迟会比纯端到端方案高一些,但实测下来在可控范围内,后面我会详细说延迟的构成和优化手段。
2. 核心协议差异与信令转换细节
2.1 WebRTC 与 SIP 在 SDP 上的关键差异
WebRTC 的 SDP 和 SIP 的 SDP 虽然都叫 SDP,但里面的内容差异大到让人怀疑它们是不是同一个标准。WebRTC 的 offer 里通常包含多个 m-line,每个 m-line 下面挂着 ICE 候选、DTLS 指纹、SRTP 的加密套件,还有 rtcp-mux 这种复用选项。而 SIP 侧的 SDP 往往更传统,m-line 里就是简单的 RTP/AVP 描述,编解码列表里是 PCMU、PCMA、G.722 这些,安全机制要么没有,要么用 SDES 来交换密钥。我第一次把两边 SDP 打出来对比的时候,发现 WebRTC 的 SDP 行数几乎是 SIP 侧的三倍,而且很多属性在 SIP 侧根本不认识。
转换的时候,最关键的是要把 WebRTC 的 ICE 候选和 DTLS 指纹剥离掉,因为 FreeSWITCH 作为 SIP 侧不会去处理这些。同时要把 WebRTC 的 SRTP 加密信息转换成 FreeSWITCH 能理解的 SDES 或者直接走明文 RTP。这里有个坑:如果直接走明文 RTP,在公网上传输会有安全风险,所以我在 Gateway 内部做了一次终结,也就是 WebRTC 侧到 Gateway 是 SRTP,Gateway 到 FreeSWITCH 是明文 RTP,但这段走内网或者专线,风险可控。另一个坑是编解码协商,WebRTC 默认优先用 Opus,但 FreeSWITCH 默认可能只支持 PCMU/PCMA,所以我在 Gateway 里强制把 Opus 转成 PCMU,或者让 FreeSWITCH 加载 mod_opus 来支持 Opus 直通。
2.2 信令通道的映射与状态同步
WebRTC 侧的信令通常走 WebSocket,消息格式可能是 JSON 或者自定义的文本协议;SIP 侧则是标准的 SIP 消息,走 UDP 或者 TCP。Gateway 要做的就是把 WebSocket 收到的 JSON 消息解析出来,提取出 SDP、呼叫方向、目标号码等信息,然后构造一个 SIP INVITE 发给 FreeSWITCH。这里的状态同步是个难点:WebRTC 侧的呼叫状态和 SIP 侧的呼叫状态不是一一对应的,比如 WebRTC 的 ICE 连接状态变化在 SIP 侧没有对应的信令,但如果不处理,可能会导致媒体流已经通了但 SIP 侧还在振铃。我的做法是在 Gateway 里维护一个状态机,把 WebRTC 的 PeerConnection 状态和 SIP 的 dialog 状态做映射,只有两边都进入 confirmed 状态才认为呼叫建立成功。
还有一个细节是 DTMF 的传输。WebRTC 侧通常用 RFC 2833 或者 SIP INFO 来传 DTMF,而 FreeSWITCH 支持 RFC 2833 和 SIP INFO,但两者对 payload type 的协商可能不一致。我在 Gateway 里统一用 RFC 2833,并且在 SDP 里显式声明 telephone-event 的 payload type 为 101,这样两边都能识别。实测下来,如果不显式声明,FreeSWITCH 有时会忽略 DTMF,导致用户按了数字但 IVR 没反应。
2.3 呼叫路由与号码变换
呼叫路由这块,WebRTC 侧的用户可能拨的是一个内部短号,而 SIP 侧需要的是一个完整的 E.164 号码。Gateway 里我加了一个路由表,根据被叫号码的前缀来决定是路由到 FreeSWITCH 的某个 gateway 还是直接落到某个 IVR。路由表用 JSON 配置,支持正则匹配和号码替换。比如用户拨 1001,Gateway 把它替换成 861001 再发给 FreeSWITCH,FreeSWITCH 再根据它的 dialplan 决定是振铃分机还是走外线。这个设计让号码变换逻辑集中在 Gateway 里,FreeSWITCH 的 dialplan 可以保持简洁,维护起来方便很多。
3. 媒体面处理与声网 SDK 的集成要点
3.1 声网 SDK 的媒体流引出方式
声网 SDK 本身是一个端到端的解决方案,它默认会把媒体流推到声网的云上,然后由声网的云来转发。但在这个项目里,我需要把媒体流引到本地,推给 FreeSWITCH。声网 SDK 提供了几种方式来实现这个需求:一种是使用它的旁路推流功能,把流推到指定的 RTMP 或 RTSP 地址;另一种是使用它的裸数据接口,直接拿到 PCM 音频和 YUV 视频数据,然后自己编码成 RTP 包发给 FreeSWITCH。我选择了后者,因为旁路推流会引入额外的延迟,而且格式转换不够灵活。
裸数据接口的使用需要注意几个点:音频数据是 PCM 格式,采样率通常是 48kHz,声道数可能是单声道或双声道,需要重采样到 8kHz 或 16kHz 再编码成 PCMU 或 Opus;视频数据是 YUV420p,需要编码成 VP8 或 H.264,然后打包成 RTP。声网 SDK 的回调频率是固定的,音频每 10ms 或 20ms 回调一次,视频每帧回调一次,所以我在 Gateway 里起了一个发送线程,按照 RTP 的时间戳递增来发包,保证媒体流的连续性。这里有个坑:如果发送线程被阻塞,声网 SDK 的回调会堆积,导致内存暴涨,所以我在回调里只做数据拷贝,把编码和发送放到另一个线程里做。
3.2 RTP 打包与时间戳同步
RTP 打包看起来简单,但时间戳同步是个精细活。音频的 RTP 时间戳增量是采样率乘以打包间隔,比如 8kHz 采样、20ms 打包,增量就是 160。视频的 RTP 时间戳增量是 90kHz 除以帧率,比如 30fps,增量就是 3000。但声网 SDK 回调的时间戳是它内部的时钟,跟系统时钟可能有偏差,所以我用了一个平滑算法:以第一个包的时间戳为基准,后续包的时间戳按照理论增量累加,如果实际回调时间跟理论时间偏差超过阈值,就微调基准,避免时间戳跳变导致播放端卡顿。实测下来,这个平滑算法能把音频的抖动控制在 5ms 以内,视频的抖动控制在 10ms 以内,基本听不出问题。
3.3 媒体中转与旁路的选择
媒体中转还是旁路,这是个架构层面的决策。中转的意思是所有 RTP 包都经过 Gateway,Gateway 可以做转码、录制、混音等操作,但带宽成本高,延迟也高;旁路的意思是 Gateway 只做信令,媒体流直接在 WebRTC 端和 FreeSWITCH 之间传输,延迟低,但 Gateway 无法干预媒体。我最终选择了中转,原因有三个:一是 WebRTC 端在 NAT 后面,FreeSWITCH 不一定能直接连到它;二是需要做 Opus 到 PCMU 的转码;三是需要录制通话内容用于质检。中转带来的延迟增加大约在 20ms 到 40ms 之间,对于语音通话来说完全可以接受。
4. 实操部署与关键配置解析
4.1 FreeSWITCH 侧的必要配置
FreeSWITCH 的配置主要集中在 sofia profile 和 dialplan 上。首先要在 sofia profile 里开启 WebSocket 支持,虽然这个项目里 WebSocket 是由 Gateway 处理的,但 FreeSWITCH 需要能接受来自 Gateway 的 SIP 消息。我在 internal profile 里加了 Gateway 的 IP 到 ACL 白名单,避免被当成攻击流量拦截。然后要确保 mod_opus 被加载,如果要做 Opus 直通的话;如果走转码,则确保 mod_pcmu 和 mod_pcma 可用。dialplan 里我加了一条规则,匹配 Gateway 发来的 INVITE,然后根据被叫号码前缀路由到不同的 destination。
<extension name="from_gateway"> <condition field="network_addr" expression="^192\.168\.1\.100$"/> <condition field="destination_number" expression="^86(\d+)$"> <action application="bridge" data="user/$1"/> </condition> </extension>这段 dialplan 的意思是:如果 INVITE 来自 Gateway 的 IP,并且被叫号码以 86 开头,就把 86 去掉,然后桥接到对应的分机。实际部署时,IP 和号码规则要根据环境调整。
4.2 Gateway 的信令处理流程
Gateway 用 Node.js 写,核心依赖是ws库处理 WebSocket,sip库处理 SIP 消息。启动后,它监听两个端口:一个给 WebSocket,一个给 SIP。WebSocket 收到浏览器的 offer 后,解析出 SDP,然后构造一个 SIP INVITE,把 SDP 里的 ICE 和 DTLS 信息剥离,替换成 FreeSWITCH 能接受的格式,然后发给 FreeSWITCH。FreeSWITCH 返回 200 OK 后,Gateway 把 SIP 的 answer SDP 转换成 WebRTC 的 answer SDP,再通过 WebSocket 发回浏览器。整个流程里,Gateway 要维护一个 session 对象,记录呼叫 ID、SDP、状态等信息,方便后续的 BYE 和 re-INVITE 处理。
const sip = require('sip'); const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); const sipStack = sip.create({ port: 5060 }); wss.on('connection', (ws) => { ws.on('message', (message) => { const msg = JSON.parse(message); if (msg.type === 'offer') { const sipInvite = buildSipInvite(msg.sdp, msg.target); sipStack.send(sipInvite); } }); }); sipStack.on('invite', (req) => { // 处理来自 FreeSWITCH 的 INVITE });这段代码是简化版,实际还要处理认证、超时、错误响应等。我踩过的坑是:SIP 消息的 Content-Length 必须准确,否则 FreeSWITCH 会丢弃消息;还有 Via 头的 branch 参数要唯一,否则响应会匹配错。
4.3 媒体面的 RTP 代理实现
RTP 代理用 C 写,因为要处理高并发的 UDP 包,Node.js 的性能不够。代理监听两个端口:一个收 WebRTC 侧的 SRTP,一个收 FreeSWITCH 侧的 RTP。收到 SRTP 后,先解密,然后重新打包成 RTP 发给 FreeSWITCH;收到 RTP 后,加密成 SRTP 发给 WebRTC 侧。加解密用 libsrtp 库,性能很好,单核能处理上千路并发。代理还要处理 RTCP,把发送端报告和接收端报告互相转换,保证两边的统计信息一致。这里有个细节:WebRTC 的 RTCP 里可能有 REMB 和 PLI,这些在 SIP 侧没有对应,需要丢弃或者转换成 FIR。
5. 常见问题排查与性能优化实录
5.1 单通与媒体超时问题
单通是这类项目里最常见的问题,表现是一方能听到另一方,但反过来听不到。排查思路是先在 Gateway 上抓包,看 RTP 包有没有双向流动。如果只有单向,说明另一方的 RTP 包没到或者被丢弃了。常见原因有三个:一是 SDP 里的 IP 地址是内网地址,对方路由不到;二是防火墙拦截了 UDP 包;三是 SRTP 解密失败,包被丢弃。我遇到过一次是 FreeSWITCH 的 SDP 里带了a=rtcp-mux,但 WebRTC 侧没开,导致 RTCP 包发到了错误的端口。解决办法是在 Gateway 里强制两边都开或都关 rtcp-mux,保持一致。
5.2 音频卡顿与抖动缓冲
音频卡顿通常跟抖动缓冲有关。WebRTC 侧有自适应抖动缓冲,但 SIP 侧往往没有,所以如果网络抖动大,SIP 侧就会卡。我在 Gateway 里加了一个简单的抖动缓冲,收到 RTP 包后不立即转发,而是等 20ms 再转发,这样能把抖动平滑掉。代价是增加了 20ms 延迟,但换来了更稳定的音频。实测下来,在网络抖动 50ms 以内的情况下,加了抖动缓冲后卡顿率从 5% 降到了 0.5% 以下。
5.3 呼叫建立延迟优化
呼叫建立延迟主要来自信令往返和 ICE 协商。WebRTC 的 ICE 协商通常需要收集候选、连通性检查,这个过程可能耗时几百毫秒。我的优化手段是:在 Gateway 里预收集 FreeSWITCH 侧的候选,提前告诉 WebRTC 侧,减少连通性检查的时间;同时把 SIP 的 100 Trying 和 180 Ringing 及时转发给 WebRTC 侧,让用户尽早听到回铃音。经过优化,呼叫建立延迟从平均 1.2 秒降到了 600ms 左右。
| 问题现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 单通 | SDP IP 错误 | 抓包看 RTP 方向 | 修正 SDP 里的 IP |
| 音频卡顿 | 抖动缓冲不足 | 统计 RTP 丢包和抖动 | 增加抖动缓冲 |
| 呼叫建立慢 | ICE 协商耗时 | 看 ICE 候选收集时间 | 预收集候选 |
| DTMF 无效 | payload type 不一致 | 看 SDP 里的 telephone-event | 统一为 101 |
| 视频花屏 | 丢包或时间戳跳变 | 看 RTP 序列号和时间戳 | 加平滑算法 |
5.4 并发压力下的资源管理
并发量上来之后,资源管理变得很重要。每个呼叫会占用一个 session 对象、一个 RTP 代理端口、以及一定的 CPU 用于加解密和转码。我在 Gateway 里加了一个连接池,复用 RTP 代理端口,减少端口耗尽的风险;同时用 Redis 存储 session 状态,方便水平扩展。实测单台 4 核 8G 的服务器能稳定支撑 500 路并发通话,CPU 占用在 60% 左右,瓶颈主要在 SRTP 加解密上,如果换成支持 AES-NI 的 CPU,能提升到 800 路以上。
6. 个人实操体会与后续扩展方向
这个项目从立项到稳定运行花了大概三个月,中间踩的坑主要集中在 SDP 转换和媒体面同步上。我最大的体会是:不要试图让 WebRTC 和 SIP 完全对等,它们的设计哲学不一样,强行对等只会让代码越来越复杂。更好的做法是在 Gateway 里做一次终结,把两边的差异消化掉,让每一侧都觉得自己在跟同类通信。另一个体会是日志要打全,尤其是 SDP 和 RTP 的统计信息,出问题的时候这些日志能省下大量排查时间。
后续如果要扩展,我觉得有几个方向值得尝试:一是支持视频通话,现在只做了音频,视频的 RTP 打包和关键帧请求会更复杂;二是支持会议混音,把多路 WebRTC 流混成一路发给 SIP 侧;三是把 Gateway 容器化,用 Kubernetes 做弹性伸缩,应对突发流量。这些方向我还在摸索,等有成熟经验了再另开一篇细说。