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 是 SRTPGateway 到 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 配置支持正则匹配和号码替换。比如用户拨 1001Gateway 把它替换成 861001 再发给 FreeSWITCHFreeSWITCH 再根据它的 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 包都经过 GatewayGateway 可以做转码、录制、混音等操作但带宽成本高延迟也高旁路的意思是 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 namefrom_gateway condition fieldnetwork_addr expression^192\.168\.1\.100$/ condition fielddestination_number expression^86(\d)$ action applicationbridge datauser/$1/ /condition /extension这段 dialplan 的意思是如果 INVITE 来自 Gateway 的 IP并且被叫号码以 86 开头就把 86 去掉然后桥接到对应的分机。实际部署时IP 和号码规则要根据环境调整。4.2 Gateway 的信令处理流程Gateway 用 Node.js 写核心依赖是ws库处理 WebSocketsip库处理 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 里带了artcp-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 做弹性伸缩应对突发流量。这些方向我还在摸索等有成熟经验了再另开一篇细说。