1. 项目缘起与整体设计思路
1.1 为什么要在浏览器和传统电话网之间搭一座桥
做过实时通信的人大概都有过这种体验:浏览器端用 WebRTC 做音视频通话,体验确实丝滑,延迟低、抗丢包强、回声消除也省心;可一旦业务要跟传统电话网对接,麻烦就来了。传统电话网跑的是 SIP 协议,媒体走的是 RTP,而浏览器只认 WebRTC 那一套(SRTP、ICE、DTLS)。这两套体系就像两个说不同语言的人,硬要对话,中间必须有个翻译。
这个翻译就是WebRTC2SIP Gateway。它的核心职责很明确:把浏览器发出来的 WebRTC 媒体流,转换成标准 SIP/RTP 能识别的格式,再送进传统电话网络;反过来,电话网回来的音频,也要转成 WebRTC 能吃的流推回浏览器。听起来简单,真做起来,坑多到能写一本书。
我这次选的技术组合是声网音视频 SDK + FreeSWITCH。为什么这么搭?先说声网 SDK 的角色——它负责浏览器这一侧的媒体采集、编解码、网络传输,把 WebRTC 那套复杂的东西封装成几个 API,省掉大量底层适配工作。而 FreeSWITCH 是软交换里的老牌选手,SIP 协议栈成熟、媒体处理能力强、社区资料多,拿它做 SIP 侧的对接再合适不过。两者中间需要一个网关服务来桥接,这就是我们要开发的核心部分。
这套方案适合谁参考?如果你正在做在线客服、远程医疗、呼叫中心、或者任何需要"网页直接打电话"的场景,又不想从零造轮子,那这篇内容应该能帮你少走不少弯路。哪怕你只是对 WebRTC 和 SIP 的互通原理好奇,后面的拆解也能让你把整个链路看明白。
1.2 整体架构的分层拆解
在动手之前,我习惯先把架构画清楚,不然写到一半容易乱。这套 Gateway 方案从逻辑上分四层:
- 接入层:浏览器通过声网 SDK 加入频道,媒体流以 WebRTC 标准格式进出。这一层对开发者最友好,基本不用碰底层协议。
- 网关层:这是我们要开发的核心。它一边通过声网的服务端 API 或 SDK 接收媒体流,一边用 SIP 协议跟 FreeSWITCH 通信。媒体要在这里完成格式转换——WebRTC 的 Opus 音频、VP8/H.264 视频,转成 SIP 侧常用的 PCMU/PCMA 或 G.711,视频则看对端能力决定是否转码。
- 交换层:FreeSWITCH 负责 SIP 信令的解析、路由、呼叫控制。它把网关发来的呼叫请求转成标准 SIP INVITE,或者把外部呼入的请求转给网关。
- 落地层:最终对接 PSTN 网关、运营商线路或者企业内部的 SIP 话机。
这个分层的好处是职责清晰。声网 SDK 管好浏览器侧,FreeSWITCH 管好电话侧,中间的网关只做"翻译"和"转发",不掺和信令路由的复杂逻辑。我试过把信令也塞进网关自己处理,结果代码膨胀得厉害,后来还是老老实实让 FreeSWITCH 扛这块。
1.3 方案选型的几个关键取舍
选声网 SDK 而不是纯开源 WebRTC,主要图的是稳定性和省心。纯开源方案你得自己搭 STUN/TURN、处理 NAT 穿透、调回声消除参数,光是这些就够折腾几周。声网把这些都封装好了,浏览器端几行代码就能加入频道,媒体质量还有保障。代价是它是个商业服务,有成本,但对大多数业务来说,省下的开发时间远比服务费值钱。
选 FreeSWITCH 而不是 Asterisk,是因为 FreeSWITCH 的媒体处理更灵活,支持的模式更多,而且它的 mod_sofia 模块对 SIP 的支持非常完整。Asterisk 也能做,但在高并发和媒体转码场景下,FreeSWITCH 的表现更稳。当然,如果你团队对 Asterisk 更熟,也不是不能换,核心思路是一样的。
中间网关的开发语言,我倾向用 Go 或 Node.js。Go 的并发模型适合处理大量媒体流,Node.js 则开发快、生态好。这次方案里我用 Go 做示例,因为它的 goroutine 处理双向流特别顺手。
2. 核心细节解析与实操要点
2.1 声网 SDK 侧的媒体接入要点
浏览器端接入声网 SDK,核心就三步:初始化、加入频道、发布流。但这里面有几个细节不注意,后面网关对接会出问题。
第一,音频编码格式。声网默认用 Opus,这是 WebRTC 的标准音频编码,音质好、延迟低。但 SIP 侧通常只认 G.711(PCMU/PCMA),所以网关必须做转码。如果你希望减少转码开销,可以在声网 SDK 里配置让浏览器直接发 G.711,但这样会牺牲一些音质和抗丢包能力。我的建议是保持 Opus,让网关转码,因为 Opus 在网络抖动时的表现明显更好。
第二,视频编码。如果业务只做语音,视频这块可以忽略。但要做视频通话,就得考虑 VP8/H.264 到 SIP 侧视频编码的转换。SIP 视频通常用 H.264,所以网关需要把声网传来的 VP8 转成 H.264。这个转码开销不小,如果并发高,得考虑用硬件加速。
第三,频道和用户 ID 的映射。声网用频道名和 UID 标识用户,SIP 用 URI(比如 sip:1001@domain)。网关必须维护一张映射表,把 UID 和 SIP URI 对应起来。这张表的设计直接影响呼叫路由的效率,建议用 Redis 之类的内存数据库存,别用文件。
注意:声网 SDK 的 UID 是 32 位无符号整数,而 SIP URI 是字符串,映射时要注意类型转换和唯一性。我踩过一次坑,两个用户 UID 转成字符串后前缀相同,导致路由错乱,后来加了分隔符才解决。
2.2 FreeSWITCH 的配置与 SIP 对接
FreeSWITCH 这边,核心是配置好 SIP profile 和 dialplan。SIP profile 决定了 FreeSWITCH 监听哪个端口、用什么传输协议(UDP/TCP/TLS)、支持哪些编解码。跟网关对接时,我一般新建一个独立的 profile,避免跟现有配置冲突。
配置里几个关键参数:
sip-port:SIP 监听端口,默认 5060,如果跟其他服务冲突就改。rtp-ip和sip-ip:媒体和信令的 IP,多网卡机器上必须指定清楚,不然媒体流可能走错网卡。inbound-codec-prefs和outbound-codec-prefs:编解码优先级。跟网关对接时,把 PCMU/PCMA 放前面,确保转码后的音频能被正确识别。dtmf-type:DTMF 传输方式,一般用 RFC2833,兼容性好。
Dialplan 负责把呼入的请求路由到网关。比如网关发来一个呼叫请求,目标号码是 1001,dialplan 就要匹配这个号码,然后 bridge 到对应的 SIP 用户或者网关。这块配置不难,但正则表达式要写对,不然呼叫会失败。
提示:FreeSWITCH 的日志级别建议调到 info 或 debug,排查问题时能看到完整的 SIP 信令交互。生产环境再调回 warning,避免日志爆盘。
2.3 网关层的媒体转码与信令桥接
网关是整个方案里最复杂的部分。它要同时处理两件事:信令桥接和媒体转码。
信令桥接方面,网关需要把声网 SDK 的事件(比如用户加入频道、发布流)转换成 SIP 信令(INVITE、ACK、BYE),反之亦然。这里的关键是状态机。一个呼叫从发起到接通再到挂断,中间有多个状态,网关必须准确跟踪每个呼叫的状态,不然会出现"电话挂了但媒体流还在跑"这种问题。
媒体转码方面,网关要从声网的服务端接收媒体流,解码成 PCM,再编码成 G.711 发给 FreeSWITCH;反过来也一样。这个过程对 CPU 消耗较大,尤其是视频转码。如果并发不高,用软件转码就行;并发高了,得考虑用专门的媒体服务器或者硬件转码卡。
我实测下来,单台 4 核 8G 的机器,纯音频转码能撑 200 路左右,视频转码就只能撑 20 路左右。所以视频场景一定要提前规划好容量。
2.4 网络与 NAT 穿透的注意事项
WebRTC 最大的敌人是 NAT。浏览器在 NAT 后面,SIP 服务器可能在另一个 NAT 后面,两边要通信,必须解决穿透问题。声网 SDK 自带 NAT 穿透能力,浏览器侧基本不用操心。但网关到 FreeSWITCH 这一段,如果跨 NAT,就得配置好 STUN/TURN 或者直接用公网 IP。
我的建议是:网关和 FreeSWITCH 尽量部署在同一内网,避免跨 NAT。如果必须跨,那就在 FreeSWITCH 的 SIP profile 里配置ext-sip-ip和ext-rtp-ip,告诉对端自己的公网地址。同时网关侧也要配置对应的 NAT 映射,确保媒体流能正确到达。
注意:RTP 端口范围要在防火墙里放开,默认是 16384-32768。如果只放开几个端口,高并发时会出现媒体流建立失败。这个坑我踩过,排查了半天才发现是端口不够用。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
先把基础环境搭起来。我用的是一台 Linux 服务器(Ubuntu 20.04),装 FreeSWITCH 和 Go 环境。
FreeSWITCH 的安装,官方有编译脚本,但编译时间长,依赖多。图省事可以用包管理器装,但版本可能偏旧。我一般用官方源:
# 添加 FreeSWITCH 官方源 apt-get install -y gnupg2 wget lsb-release wget -O - https://files.freeswitch.org/repo/deb/debian-release/fsstretch-archive-keyring.asc | apt-key add - echo "deb http://files.freeswitch.org/repo/deb/debian-release/ `lsb_release -sc` main" > /etc/apt/sources.list.d/freeswitch.list apt-get update apt-get install -y freeswitch-meta-all装完后,systemctl start freeswitch启动,fs_cli进去看看状态。
Go 环境直接下官方包解压就行,版本用 1.20 以上。网关项目依赖几个库:声网的服务端 SDK(Go 版)、SIP 协议库(比如github.com/emiago/sipgo)、以及 RTP 处理库。
go mod init webrtc2sip-gateway go get github.com/emiago/sipgo go get github.com/pion/rtp go get github.com/pion/rtcp3.2 FreeSWITCH 的 SIP profile 配置
在/etc/freeswitch/sip_profiles/下新建一个gateway.xml,内容参考:
<profile name="gateway"> <settings> <param name="sip-port" value="5080"/> <param name="rtp-ip" value="192.168.1.100"/> <param name="sip-ip" value="192.168.1.100"/> <param name="inbound-codec-prefs" value="PCMU,PCMA"/> <param name="outbound-codec-prefs" value="PCMU,PCMA"/> <param name="dtmf-type" value="rfc2833"/> <param name="context" value="gateway"/> </settings> </profile>这里端口用 5080,避免跟默认的 5060 冲突。context指向一个自定义的 dialplan,后面配路由用。
然后在/etc/freeswitch/dialplan/下新建gateway.xml:
<include> <context name="gateway"> <extension name="to_gateway"> <condition field="destination_number" expression="^(\d+)$"> <action application="bridge" data="sofia/gateway/webrtc_gw/$1"/> </condition> </extension> </context> </include>这段的意思是:任何数字号码的呼叫,都 bridge 到名为webrtc_gw的网关。这个网关就是我们的 WebRTC2SIP Gateway。
3.3 网关服务的核心代码结构
网关用 Go 写,核心分三个模块:SIP 信令处理、媒体流处理、声网事件处理。
SIP 信令处理用sipgo库,监听 5080 端口,接收 FreeSWITCH 发来的 INVITE,解析出目标号码,然后通过声网 API 通知浏览器端有来电。
package main import ( "context" "log" "github.com/emiago/sipgo" "github.com/emiago/sipgo/sip" ) func main() { ua, _ := sipgo.NewUA() srv, _ := sipgo.NewServer(ua) srv.OnInvite(func(req *sip.Request, tx sip.ServerTransaction) { // 解析目标号码 to := req.To() log.Printf("收到呼叫请求: %s", to.Address.User) // 通知声网侧有来电 notifyAgoraIncoming(to.Address.User) // 回 100 Trying res := sip.NewResponseFromRequest(req, 100, "Trying", nil) tx.Respond(res) }) srv.ListenAndServe(context.Background(), "udp", "0.0.0.0:5080") }媒体流处理用pion/rtp库,接收 RTP 包,解码成 PCM,再编码成 G.711 发给 FreeSWITCH。这块代码量大,核心是维护一个双向的媒体管道。
func handleRTPStream(conn *net.UDPConn, agoraStream chan []byte) { buf := make([]byte, 1500) for { n, addr, err := conn.ReadFromUDP(buf) if err != nil { log.Printf("读取 RTP 失败: %v", err) continue } // 解析 RTP 包 pkt := &rtp.Packet{} pkt.Unmarshal(buf[:n]) // 解码 Opus 到 PCM pcm := decodeOpus(pkt.Payload) // 编码 PCM 到 G.711 g711 := encodeG711(pcm) // 发送到 FreeSWITCH sendToFreeSWITCH(g711, addr) } }声网事件处理这块,需要调用声网的服务端 API,监听频道事件。声网提供了 RESTful API 和回调机制,网关要注册一个回调地址,接收用户加入、离开、发布流等事件。
3.4 呼叫流程的完整走通
把上面几块拼起来,一个完整的呼叫流程是这样的:
- 浏览器用户 A 通过声网 SDK 加入频道,发布音频流。
- 用户 A 点击"呼叫电话",浏览器调用网关的 HTTP API,传入目标电话号码。
- 网关收到请求,构造 SIP INVITE,发给 FreeSWITCH。
- FreeSWITCH 根据 dialplan,把 INVITE 路由到 PSTN 网关或者 SIP 话机。
- 对端振铃,回 180 Ringing,网关转发给浏览器,浏览器播放回铃音。
- 对端接听,回 200 OK,网关通知声网侧,建立媒体通道。
- 媒体流开始双向传输:浏览器→声网→网关→FreeSWITCH→对端,反之亦然。
- 通话结束,任意一方挂断,网关发送 BYE,清理媒体资源。
这个流程里,第 6 步和第 7 步是最容易出问题的。媒体通道建立时,要确保 SDP 协商正确,双方的编解码、端口、IP 都匹配。我遇到过 SDP 里 IP 写成内网地址导致媒体流不通的情况,后来在 FreeSWITCH 配置里加了ext-rtp-ip才解决。
提示:调试时可以用
tcpdump抓 RTP 包,看媒体流有没有真正到达。如果 SIP 信令通了但没声音,十有八九是 RTP 没通。
4. 常见问题与排查技巧实录
4.1 信令通了但没声音的排查思路
这是最常见的问题,没有之一。SIP 信令显示呼叫已接通,但双方都听不到声音。排查步骤我总结成一张表:
| 排查点 | 检查方法 | 常见原因 |
|---|---|---|
| RTP 端口 | netstat -anu | grep 16384 | 防火墙没放开端口范围 |
| SDP 内容 | 抓包看 INVITE 的 SDP | IP 写成内网地址 |
| 编解码 | 看 FreeSWITCH 日志的 codec 协商 | 双方编解码不匹配 |
| NAT | 检查ext-rtp-ip配置 | 跨 NAT 时媒体地址错误 |
| 媒体转码 | 看网关 CPU 占用 | 转码失败或丢包 |
我踩过最坑的一次是编解码协商成功但实际发的是另一种格式,原因是声网 SDK 配置里开了"自适应编码",网络差时自动切了编码,但网关没跟着切。后来把自适应关了,固定用 Opus,问题就没了。
4.2 呼叫建立慢或超时的处理
呼叫建立慢,通常是 DNS 解析或者 SIP 重传导致的。FreeSWITCH 默认的 SIP 超时是 30 秒,如果网络延迟高,可能还没收到响应就超时了。可以在 profile 里调大sip-invite-timeout。
另一个原因是网关处理 INVITE 的逻辑太重,比如同步调用声网 API 导致阻塞。我的做法是把声网 API 调用改成异步,先回 100 Trying,等声网侧确认后再回 180 或 200。
注意:SIP 信令的时序很严格,100 Trying 必须在收到 INVITE 后立刻回,不然对端会重传 INVITE,导致重复呼叫。
4.3 媒体质量差、卡顿、回声的优化
媒体质量问题的根源通常有三个:网络抖动、转码开销、回声消除没做好。
网络抖动方面,声网 SDK 自带 jitter buffer,一般不用管。但网关到 FreeSWITCH 这一段,如果网络不稳,就得自己加 jitter buffer。我用pion库的jitterbuffer包,效果不错。
转码开销方面,前面说过,视频转码很吃 CPU。如果卡顿,先看 CPU 占用,超过 70% 就得考虑扩容或者降码率。
回声方面,WebRTC 侧声网已经做了 AEC(回声消除),但转码成 G.711 后,如果对端是免提电话,可能还会产生回声。这种情况只能在对端设备上做处理,网关层能做的有限。
4.4 高并发下的资源管理
并发一高,问题就集中爆发。我总结了几条经验:
- 媒体端口池化:不要每个呼叫都新建 UDP 连接,用端口池复用,减少系统调用开销。
- 转码线程池:转码是 CPU 密集型任务,用固定大小的线程池,避免 goroutine 爆炸。
- 呼叫状态清理:呼叫结束后,及时释放媒体资源、删除映射表条目,不然内存会泄漏。
- 限流:网关入口加限流,超过容量直接拒绝,别让系统雪崩。
我实测过,不做端口池化的话,500 并发时系统调用占用能到 30% 的 CPU,池化后降到 5% 以下。这个优化收益很大,建议一开始就做。
4.5 日志与监控的搭建
排查问题离不开日志。网关的日志要记录几个关键信息:呼叫 ID、SIP 信令交互、媒体流状态、转码耗时。FreeSWITCH 的日志用fs_cli看,或者配置写到文件。
监控方面,我一般用 Prometheus + Grafana,暴露几个核心指标:当前呼叫数、转码队列长度、媒体丢包率、CPU/内存占用。这些指标能提前发现瓶颈,避免故障。
提示:日志里别打完整的 SDP 和 RTP 包,太占空间。只打关键字段,比如 IP、端口、编解码类型。
5. 后续扩展与个人经验体会
这套方案跑通之后,能扩展的方向不少。比如加视频通话,网关支持 H.264 转码就行;加录音功能,在网关层把媒体流复制一份写到文件;加 AI 降噪,在转码前插一个降噪模块。这些扩展都不难,核心架构不用动。
我个人在实际操作中的体会是,WebRTC2SIP Gateway 这玩意儿,难点不在单个技术点,而在"衔接"。声网 SDK 和 FreeSWITCH 各自都很成熟,但把它们连起来,中间的状态同步、媒体格式转换、异常处理,才是真正花时间的地方。我建议一开始别追求大而全,先把纯音频的呼叫跑通,再逐步加视频、加功能。每加一个功能,都要回归测试,确保原有流程不受影响。
最后分享一个小技巧:调试时把 FreeSWITCH 的sofia global siptrace on打开,能看到完整的 SIP 信令交互,配合网关的日志,基本能定位 90% 的问题。这个命令我用了好几年,每次排查都靠它。