简介:一套基于SIP.js与FreeSWITCH的WebRTC网页端电话应用示例,面向需要在浏览器中快速实现电话呼入、呼出、转接与保持功能的开发者,适合作为SIP.js与WebRTC联调的入门参考。压缩包共4个文件:一个HTML入口页面负责界面结构,一个CSS文件控制样式,一个JavaScript脚本(sip-0.7.8.js)承载SIP客户端逻辑,另附readme说明文档,整体仅79KB,结构精简、便于阅读和二次修改。目前已有2314人学习下载,表明其在SIP.js + FreeSWITCH集成场景中具有实际参考价值。按说明更改分机号、密码和服务器地址,即可在谷歌浏览器中直接运行,验证呼入、呼出、转移、保持等核心流程;同时可结合sipjs.com官方文档,进一步理解网页端SIP注册、会话控制与信令交互原理。该示例默认以谷歌浏览器为运行环境,部署轻量,适合在已有FreeSWITCH环境中快速搭建可测试的WebRTC电话原型。
1. 为什么我在网页端接电话,而不是用传统话机
先从一个最实际的场景说起:公司客服坐席、远程办公的同事、经常不在工位上的售前工程师——这些人每天最重要的事情就是接电话、打电话、转接电话。传统SIP话机摆在桌面上,人不在工位就接不到;软电话装在Windows电脑上,换个Mac就得重新折腾;浏览器里敲个链接就能登录一个网页电话,打开就能用,关掉就走,这个体验对现代办公来说几乎是刚需。
WebRTC技术成熟之后,浏览器原生支持音视频通话,再配合sip.js这个纯JavaScript的SIP协议栈,完全不依赖Flash、不依赖插件,直接把FreeSWITCH变成网页电话的“交换机”。我最早是在一个客服系统改造项目里接触到这套方案的:客户要求坐席端零安装、零维护,IT部门不想再一台一台去装软电话客户端,也不想维护话机固件升级。当时对比了WebRTC网关方案和sip.js直连方案,最终选了sip.js直连FreeSWITCH的路线——OpenSIPS配合WebRTC网关那套太复杂,sip.js走WSS协议直连,架构最简单,排查问题也直观。
这篇文章要讲的,就是基于sip.js + FreeSWITCH + WebRTC,在网页端实现电话呼入、呼出、呼叫转移、呼叫保持这四条核心链路。整套方案对中小团队做内部通信工具、客服工作台、电话SaaS应用都有直接参考价值。
核心关键词先放这:sip.js处理浏览器端的SIP信令,FreeSWITCH负责媒体协商和通话路由,WebRTC负责浏览器里的音频采集与播放。三者之间的协作关系,通俗点说就是:sip.js是电话机的“手柄和听筒”,FreeSWITCH是电话局的“程控交换机”,WebRTC是两者之间的“声音通道”。下面我从架构设计、环境搭建、核心代码、踩坑记录四个维度把这套方案完全拆开。
2. 架构选型:sip.js直连FreeSWITCH,还是走WebRTC网关
2.1 两种主流方案的特点对比
在确定技术路线时,我先把方案群里所有可能的路径盘了一遍。网页端接入FreeSWITCH,常见的有三条路:
- FreeSWITCH内置WebRTC支持,通过WSS协议直接让sip.js注册到FreeSWITCH——这是最直接的一条路,FreeSWITCH自带mod_sofia支持WSS,只需要配置TLS证书和WS绑定端口,逻辑上等同于一个SIP话机通过加密的WebSocket注册上去。
- 前端先连一个WebRTC网关(比如mediasoup、Janus、Licode),网关再与FreeSWITCH对接——这种方案适合大规模并发、需要SFU混流、需要录像录屏、需要多方会议的场景,但架构复杂,多一跳延迟,运维成本高。
- 用云厂商的WebRTC通话能力,再通过PSTN网关接入传统电话网——适合没有自建SIP经验的团队,但每个月都有不低的通道费用,而且呼叫控制逻辑被封装在厂商SDK里,企业内部特殊路由策略很难定制。
我最终选第一条路,主要原因是业务场景是一对一的话务通信,并发量撑死在几十路,FreeSWITCH内置的B2BUA模式足够应付。sip.js直连可以让开发人员直接在浏览器里定位问题——freeswitch控制台能看到注册消息,sip.js的debug日志能看到UA状态机,哪一层出问题一目了然。真实业务中,有一个项目初期也考虑过Janus网关方案,但评估之后发现Janus虽然媒体处理能力强,但信令链路过长,每个通话都要在Janus和FreeSWITCH之间做桥接,呼叫转移和呼叫保持这种操作在网关上实现起来很别扭,排错更是层层叠加。
2.2 关键端口与协议路径
整套架构里的核心信令路径和媒体路径是这样走的:
浏览器里的sip.js通过WSS(WebSocket Secure)连接到FreeSWITCH的7443端口,实际上sip.js在底层还是SIP协议,只是把SIP消息封装进WebSocket帧里传输。FreeSWITCH收到INVITE之后,媒体协商走WebRTC标准的SDP交换,音频通过SRTP/UDP在浏览器和FreeSWITCH之间直接传输,不经过任何中转服务器。打电话出去的时候,FreeSWITCH通过自己的SIP中继或者PSTN网关路由到运营商,对方接听之后,浏览器里的WebRTC音频流和对方经过编解码转换后接通。
端口路径可以简单记录一下:
- WSS信令端口:7443,sip.js注册和呼叫控制走这里。
- WS信令端口:5066,如果内网调试没有TLS证书,可以用这个明文的WebSocket端口测试。
- RTP媒体端口:FreeSWITCH默认的RTP端口范围是16384-32768,需要在防火墙里放行UDP。
- 如果还要对接PSTN,SIP中继的5080端口也要确认可达。
2.3 为什么FreeSWITCH而不是Asterisk
很多人会问,Asterisk也能做SIP服务器,为什么用FreeSWITCH?我个人的看法是:Asterisk在传统电话领域生态更老、文档更多,但WebRTC支持方面FreeSWITCH的mod_sofia对WSS、SRTP、DTLS的原生支持更完善,配置上更“开箱即用”。而且FreeSWITCH的拨号计划用XML写,写一套WebRTC分机的路由规则比Asterisk的extensions.conf更直观,调试的时候可以reload而不影响在线通话。当然Asterisk新版也支持WebRTC,但如果你跟我一样希望用一套系统同时管WebRTC分机和传统SIP中继,FreeSWITCH的稳定性更值得信任。
3. 环境准备:FreeSWITCH编译安装与WebRTC相关模块激活
3.1 版本选择与依赖处理
这一步非常关键,版本选错会让后面踩进深坑。我目前的稳定组合是:Ubuntu 20.04/22.04 LTS + FreeSWITCH 1.10.x(1.10.10之后的release)。1.10版本对WebRTC的DTLS-SRTP支持已经非常稳定,mod_sofia和mod_verto都默认编译进来。
编译安装之前需要先解决依赖。FreeSWITCH官方文档推荐的依赖列表比较长,但实际生产经验告诉我,重点确保以下几项装好即可:
- build-essential、cmake、autoconf、automake、libtool(编译工具链)
- libssl-dev(TLS/WSS必需)
- libsndfile1-dev、libltdl-dev(音频处理)
- libopus-dev、libspeex-dev(编码器,Opus对WebRTC尤其重要)
- libavformat-dev(可选,录音相关)
如果是从源码编译,可以按官方标准流程走:先./bootstrap.sh生成configure脚本,再./configure --disable-dependency-tracking。这个disable-dependency-tracking参数近期在社区里讨论很多,它的作用就是告诉configure不要去做多余的依赖缓存追踪,减少编译过程中的一些过时依赖检测,特别适合连续多次编译或交叉编译场景。实际体验中加上这个参数后,configure阶段明显更快,而且能避免某些依赖库版本变化导致的假错误。
3.2 mod_sofia关键配置:WSS端口、TLS证书、WebRTC分机
安装完成后,重点修改FreeSWITCH的conf目录。WebRTC注册的核心配置在/usr/local/freeswitch/conf/sip_profiles/internal.xml和internal-ipv6.xml中。
首先确认internal这个SIP profile里以下几项配置存在且值正确:
<param name="ws-binding" value=":5066"/> <param name="wss-binding" value=":7443"/>注意:如果只配置了wss-binding而没有配置ws-binding,不少版本的FreeSWITCH在启动时会警告甚至拒绝启动。两个都配上,ws用于内网HTTP环境的调试,wss用于生产环境。
TLS证书配置方面,FreeSWITCH默认自带一个自签名证书,路径通常在/usr/local/freeswitch/cert/下。生产环境建议换成企业自己的证书,让浏览器不再提示不安全。替换证书之后需要修改internal.xml里的<param name="tls-cert-dir" value="/usr/local/freeswitch/cert"/>,然后重启FreeSWITCH。
WebRTC分机的创建逻辑与传统SIP分机完全一样,在/usr/local/freeswitch/conf/directory/default/下新增一个XML文件即可,比如1001.xml:
<include> <user id="1001"> <params> <param name="password" value="123456"/> <param name="vm-enabled" value="false"/> </params> <variables> <variable name="user_context" value="default"/> <variable name="effective_caller_id_number" value="1001"/> <variable name="effective_caller_id_name" value="WebRTC User 1001"/> </variables> </user> </include>3.3 启动验证:wss端口是否监听
配置完成后启动FreeSWITCH:
/usr/local/freeswitch/bin/freeswitch -nc -nf-nc是不进入控制台交互模式,-nf是不后台fork,日志直接打到终端,方便第一时间看到错误。启动后检查端口监听:
netstat -tlnp | grep -E '5066|7443'如果看到5066和7443都在监听,说明mod_sofia的WebSocket绑定成功。接着可以在FreeSWITCH控制台执行:
sofia status profile internal输出里能看到internal profile的WS binding和WSS binding状态,一切正常就可以进入下一步。
4. 前端核心实现:sip.js注册、呼入呼出、保持与转移
4.1 初始化sip.js UA并处理注册状态
前端使用sip.js 0.21.x版本,这个版本是目前稳定性和API设计最平衡的版本。0.20之后到0.21经历了core API重构,0.21的语义更简洁,示例也更多。如果是从旧项目升级过来的,注意0.21的UA构造方式有变化。
初始化代码如下:
import { UA } from 'sip.js'; const ua = new UA({ uri: 'sip:1001@your-freeswitch-domain.com', transportOptions: { // 注意这里是wss,不是ws server: 'wss://your-freeswitch-domain.com:7443', traceSip: true }, authorizationUsername: '1001', authorizationPassword: '123456', register: true, logLevel: 'debug' }); ua.on('registered', () => { console.log('分机注册成功'); updatePhoneStatus('online'); }); ua.on('unregistered', () => { console.log('分机注册断开'); updatePhoneStatus('offline'); }); ua.on('registrationFailed', (cause) => { console.error('注册失败,原因:', cause); updatePhoneStatus('error'); }); ua.start();几个关键点需要特别说明:
uri里的domain部分必须和FreeSWITCH里配置的domain一致,默认是服务器的IP或者$${domain}变量对应的值。如果注册不上,优先检查这个匹配关系。traceSip: true和logLevel: 'debug'是开发阶段最宝贵的排错工具,可以看到浏览器发出的每条SIP消息。生产环境记得关掉,否则控制台会被刷爆。- SIP over WebSocket在建立连接之前会先通过WSS握手建立TCP连接,所以如果WSS端口不通,
ua.start()之后会一直重试或者直接报WebSocket Connection Failed。
4.2 呼出:从浏览器拨打电话
呼出是所有功能里最容易被误用的一个。很多人第一次实现呼出的时候,习惯把对方的号码直接塞进ua.call()的target参数,这样也能通,但往往在FreeSWITCH侧的路由判断上出问题。我在实际项目中推荐用带域名后缀的SIP URI方式:
function makeCall(calleeNumber) { // 号码统一格式化为SIP URI,方便FreeSWITCH侧做路由匹配 const target = `sip:${calleeNumber}@your-freeswitch-domain.com`; const session = ua.call(target, { mediaConstraints: { audio: true, video: false }, // 第一次呼叫时需要的本地媒体流 // 如果不传sessionDescriptionHandlerOptions,sip.js会自动处理 sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); session.on('progress', () => { console.log('对方振铃中'); updateCallStatus('ringing'); }); session.on('accepted', () => { console.log('通话已接通'); updateCallStatus('connected'); startCallTimer(); }); session.on('bye', () => { console.log('通话结束'); updateCallStatus('idle'); stopCallTimer(); }); session.on('failed', (cause) => { console.error('呼叫失败:', cause); updateCallStatus('failed'); }); // 保存session,方便后续挂断、保持、转移操作 currentSession = session; }这里有一个我踩过的坑:ua.call()之后必须立刻在返回的session上监听事件,尤其是accepted事件。如果监听的代码写在异步回调之后,很可能错过早期事件,导致通话已经接通了界面还停留在“呼叫中”状态。
媒体协商方面,sip.js默认使用WebRTC的getUserMedia获取麦克风音频。注意在HTTPS环境下才能稳定获取麦克风权限,http://localhost是例外。如果部署在生产环境,务必确保整个页面通过HTTPS访问,同时WSS端口也需要有有效的TLS证书。
4.3 呼入:监听来电并接通
呼入的核心就是监听ua.on('invite')事件。当FreeSWITCH把来电路由到1001分机时,sip.js的UA会触发这个事件,传入一个Session实例,此时就是来电振铃阶段。
ua.on('invite', (session) => { console.log('有来电,主叫号码:', session.remoteIdentity.uri.user); currentSession = session; updateCallStatus('incoming'); // 播放来电铃声,用Web Audio API生成简单的振铃音 playRingTone(); // 用户点击接听按钮时调用 window.answerIncomingCall = () => { session.accept({ mediaConstraints: { audio: true, video: false } }); stopRingTone(); updateCallStatus('connected'); startCallTimer(); }; // 用户点击拒接按钮时调用 window.rejectIncomingCall = () => { session.reject(); stopRingTone(); updateCallStatus('idle'); }; session.on('bye', () => { // 对方挂断,或者FreeSWITCH侧挂断 stopRingTone(); updateCallStatus('idle'); stopCallTimer(); }); session.on('failed', () => { stopRingTone(); updateCallStatus('idle'); }); });一个需要注意的细节是:session.accept()最好在用户真正点击接听按钮时才调用,不要在invite事件触发后立刻自动accept。如果页面里为了测试方便自动accept,来电会瞬间接通,用户根本来不及准备。同时,接受呼叫后浏览器会请求麦克风权限,这个权限弹窗可能比呼叫接通慢,所以建议在页面加载时先请求一次麦克风权限,接受后保存在变量里供后续通话复用。
4.4 呼叫保持:让对方听到等待音
呼叫保持的实现原理是:在通话过程中,向Far End发送一个re-INVITE,把SDP里的媒体流方向修改为sendonly或者inactive,从而暂停双向音频传输。sip.js封装了这个过程,直接调用session的hold()和unhold()方法即可。
function holdCall() { if (!currentSession) return; currentSession.hold({ // hold时的音频方向配置,sendonly表示我们只发送,不接收(或者反过来取决于实现) // 实际项目中常用inactive来完全静音媒体 audio: { direction: 'inactive' } }) .then(() => { console.log('呼叫已保持'); updateCallStatus('held'); }) .catch((err) => { console.error('保持失败:', err); }); } function unholdCall() { if (!currentSession) return; currentSession.unhold({ audio: { direction: 'sendrecv' } }) .then(() => { console.log('呼叫已恢复'); updateCallStatus('connected'); }) .catch((err) => { console.error('恢复失败:', err); }); }这里要注意的是,FreeSWITCH作为B2BUA,它在收到re-INVITE媒体修改请求后,能不能正确协商取决于它是否在SDP里正确处理inactive和sendrecv属性。实测下来FreeSWITCH 1.10处理sip.js的hold请求非常标准,但对端如果走的是PSTN网关,可能不会对媒体暂停做任何提示,对方听到的仍然是“沉默”,这时候最好由FreeSWITCH侧提供一个MOH(Music on Hold)资源,在保持期间给对端播放等待音乐。
MOH配置在FreeSWITCH里默认就是启用的,保持时如果sip.js把媒体方向切到inactive,FreeSWITCH会自动向对端播放默认的等待音。不需要额外配置就能获得比较完整的体验。
4.5 呼叫转移:attended transfer还是blind transfer
呼叫转移是通话控制里最复杂的部分。sip.js提供了refer()方法,底层走SIP REFER请求,但实际项目里很多团队对这个方法的封装并不完善。我建议把呼叫转移分成两种场景来处理:
- 盲转(Blind Transfer):用户A和B正在通话,A希望直接把B转移到C,不再参与后续通话。此时A的浏览器发送REFER请求给FreeSWITCH,FreeSWITCH让B和C直接建立通话,A退出。
- 协商转(Attended Transfer):A先和C建立一个新的咨询通话,确认C愿意接收后,再把B转移给C。这个场景需要两个Session同时存在于前端,一个保持一个活跃。
// 盲转实现 function blindTransfer(targetNumber) { if (!currentSession) return; const target = `sip:${targetNumber}@your-freeswitch-domain.com`; currentSession.refer(target) .then(() => { console.log('转移请求已发送'); // 转移后当前session即将结束,可以在bye事件里清理状态 }) .catch((err) => { console.error('转移失败:', err); }); }盲转看上去简单,但实际部署时我建议把转移的核心逻辑写在FreeSWITCH的拨号计划里,前端只触发一个预定义的拨号串,比如**010加目标号码。这样做的理由是:FreeSWITCH侧可以通过XML路由精确控制转移权限、超时策略、失败回调,而sip.js的refer实现直接透传到FreeSWITCH后,控制力度会弱一些。当然,如果只是做Demo或者内部小工具,直接refer是完全可行的。
协商转需要维护两个session,实现上要复杂不少:
- 用户A在和B通话(session1),点击“咨询转”按钮。
- 前端对session1先调用hold(),让B进入等待状态。
- 用同一个UA发起一个新的call到C,得到session2。
- 用户A和C通话确认后,对session1调用refer,refer的target是session2的远端URI。
- 成功后session2被挂断,session1转移完成。
协商转的坑在于:两个session的媒体流同时存在,浏览器在两个WebRTC连接之间切换音频输出,如果处理不当用户会听到回声或者丢失声音。实际测试中Chrome处理两个RTCPeerConnection的音频输出还算稳定,但Edge和Safari偶尔会有问题,建议协商转时优先关闭session2的本地扬声器播放,只保留session1的音频。
4.6 挂断与清理状态
挂断逻辑相对简单,但状态清理要彻底。sip.js中挂断通过session.bye()实现:
function hangupCall() { if (!currentSession) return; // 通知对端挂断 currentSession.bye() .then(() => { console.log('呼叫已挂断'); }) .catch((err) => { console.error('挂断失败:', err); // 如果bye失败,可能是连接已断开,直接调用terminate强制结束 currentSession.terminate(); }) .finally(() => { currentSession = null; updateCallStatus('idle'); stopCallTimer(); stopRingTone(); }); }这里强调一点:挂断之后别忘记释放本地媒体流。sip.js在session结束时会自动清理RTCPeerConnection,但麦克风设备可能仍然被浏览器占用,特别是在切换分机或页面只刷新了一次但多次发起呼叫的场景下,麦克风灯会一直亮着。我通常在通话结束后主动调用navigator.mediaDevices.getUserMedia的track停止接口全面释放。
5. FreeSWITCH侧拨号计划设计:让网页电话真正打通内外线
5.1 WebRTC分机互拨与呼出路由
前端功能做得再花哨,FreeSWITCH拨号计划写得不对,一切白搭。我在开发阶段把拨号计划分成三类测试:内部WebRTC分机互拨、通过SIP中继呼出PSTN、外部PSTN呼入到WebRTC分机。
内部互拨的Dialplan非常简单:
<extension name="webrtc-internal"> <condition field="destination_number" expression="^(10[0-9][0-9])$"> <action application="bridge" data="sofia/internal/${destination_number}@$${domain}"/> </condition> </extension>这样1001拨打1002时,FreeSWITCH直接将呼叫桥接到1002分机。由于双方都是WebRTC分机,媒体全程走SRTP,没有编解码转换,延迟非常低。
呼出到PSTN的Dialplan需要配合SIP中继,假设网关名称为gw_pstn:
<extension name="webrtc-pstn-out"> <condition field="destination_number" expression="^(0[0-9]+)$"> <action application="bridge" data="sofia/gateway/gw_pstn/${destination_number}"/> </condition> </extension>外线呼入到WebRTC分机的路由,在呼入的context里写:
<extension name="pstn-in-to-webrtc"> <condition field="destination_number" expression="^(\d+)$"> <action application="bridge" data="sofia/internal/${destination_number}@$${domain}"/> </condition> </extension>5.2 呼叫保持时的MOH设置
前端触发hold之后,FreeSWITCH会检测媒体流方向变成inactive,然后自动向对端播放MOH。在mod_switch的默认配置里,MOH文件在/usr/local/freeswitch/sounds/music/8000/目录下,安装FreeSWITCH后自带几首示例音乐。如果不喜欢默认音乐,把自己的wav文件放进去,修改autoload_configs/local_stream.conf.xml里的文件路径即可。
注意MOH文件建议用8000Hz采样率的wav,否则部分PSTN网关会不识别。8000Hz是传统电话网的采样率,WebRTC走Opus编码是48000Hz,FreeSWITCH会自动转码。
5.3 呼叫转移的Dialplan配合
如果前端直接发送REFER,FreeSWITCH默认的defaultcontext里已经有对REFER的处理,但建议在分机所属的user context里显式配置transfer_ringback等变量,控制转移过程中的振铃音。更稳妥的做法是在转移路由里增加一个extension专门处理:
<extension name="transfer-route"> <condition field="destination_number" expression="^\*\*(10[0-9][0-9])$"> <action application="set" data="transfer_on_ring=$${destination_number}"/> <action application="bridge" data="sofia/internal/${destination_number}@$${domain}"/> </condition> </extension>这个配置的作用是:当分机拨打**1002这样的转移号码时,FreeSWITCH先把目标号码记录下来,等当前通话结束后自动把另一段桥接过去。这个模式相当于把盲转逻辑完全放到服务端,前端只负责发起一个“预约转移”,对前端来说更安全,不会因为网络波动导致REFER丢失。
6. 调试过程中最值得记录的五个问题
6.1 WSS证书不受信任,浏览器直接拒绝连接
开发环境最常见的问题。Chrome对WSS的证书校验非常严格,如果使用FreeSWITCH自带的自签名证书,浏览器控制台报ERR_CERT_AUTHORITY_INVALID,sip.js会卡在WebSocket连接阶段,UA永远不会进入registered状态。
解决方案有两条路:一是把自签名证书导入到系统的信任链里,但每台办公电脑都得做一遍,不现实;二是用内网自建的CA签发证书,同时配置内部DNS域名解析,让所有办公电脑信任同一个根证书,这样一次性解决所有WSS连接问题。前者适合本地开发,后者适合企业部署。
6.2 注册成功但呼出失败,SDP音频编解码不匹配
这个问题的典型表现是:sip.js已经注册成功,UI显示在线,但一打电话FreeSWITCH日志立刻报1060 ERROR或者488 Not Acceptable Here,原因基本都在编解码协商。WebRTC默认使用Opus编码,但FreeSWITCH分机注册时如果只启用了PCMU/PCMA,双方在SDP里找不到共同编解码就会协商失败。
解决方式是在WebRTC分机所在的dialplan接听端设置setPCMA和setOPUS变量,或者在FreeSWITCH的vars.xml里把outbound_codec_prefs和inbound_codec_prefs都加上OPUS。
<X-PRE-PROCESS cmd="set" data="global_codec_prefs=OPUS,PCMU,PCMA"/> <X-PRE-PROCESS cmd="set" data="outbound_codec_prefs=OPUS,PCMU,PCMA"/> <X-PRE-PROCESS cmd="set" data="inbound_codec_prefs=OPUS,PCMU,PCMA"/>改完重启FreeSWITCH,或执行reloadxml后重新注册分机。
6.3 呼入时浏览器不响铃,排查发现是autoplay策略
手机端和部分桌面浏览器对音频自动播放有严格限制,页面加载后如果用户没有点击过页面,AudioContext默认是suspended状态,sip.js的振铃音不会响。这个问题一度让我误以为是呼入事件没触发。
解决办法是在页面初始化时监听用户的第一次点击,主动调用一次audioContext.resume(),同时在用户点击“登录”或“接听”按钮后提前创建AudioContext并解锁。
6.4 呼叫保持后恢复,对方听不到声音
这种情况多发生在sip.js的hold与FreeSWITCH的MOH播放冲突时。当sip.js发送re-INVITE将媒体方向改为inactive后,FreeSWITCH开始播放MOH给对端,这个MOH流来自本地文件,方向是FreeSWITCH到对端。当sip.js发送unhold恢复时,方向变为sendrecv,理论上FreeSWITCH会停止MOH播放,但某些版本下MOH播放不会立即停止,导致对端在恢复通话后的前几秒还能听到背景音乐。
解决方案有两个:一是升级到最新1.10.x补丁版本;二是在unhold成功后等200~300ms再播放本地来电提示音,给FreeSWITCH一个释放MOH stream的时间窗口。
6.5 NAT穿透问题:办公网络和家庭网络下RTP不通
WebRTC的SRTP流量在NAT环境下一般能通过ICE协商穿透,但如果FreeSWITCH部署在云服务器上,且没有正确配置公网IP,媒体流会绑定在内网IP上导致浏览器无法接收RTP包。
在FreeSWITCH的internal.xml里加上:
<param name="ext-rtp-ip" value="你的公网IP或域名"/> <param name="ext-sip-ip" value="你的公网IP或域名"/>如果是动态IP环境,配合STUN服务可以自动探测公网IP,sip.js在WebRTC层也会做ICE协商,双管齐下基本能解决。
7. 一个可以直接跑起来的最小Demo逻辑
前面技术点讲得比较分散,这里整理一份最小Demo完整流程,方便你照着搭一个能跑通的骨架:
操作流程分成四步:
- FreeSWITCH侧准备好一个WebRTC分机,比如1001,密码123456,确保5066和7443端口在监听。
- 前端搭建一个静态页面,引入sip.js 0.21.x的UMD包。
- 页面提供四个按钮:拨号输入框+呼叫按钮、接听按钮、挂断按钮、保持/恢复按钮。
- 页面加载后用1001注册UA,注册成功后在页面顶部显示“在线”状态。
注册成功后,用另一个SIP客户端(比如Zoiper配置同一个分机或者再注册一个1002)拨打1001,页面会触发invite事件并显示来电;点击接听即可通话。拨出时输入1002,FreeSWITCH会路由到1002分机。保持和转移的逻辑前文都有对应代码,组合进页面即可。
下面是一段最小页面骨架的核心结构:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"/> <title>WebRTC 网页电话Demo</title> <script src="https://unpkg.com/sip.js@0.21.2/dist/sip.min.js"></script> </head> <body> <div id="status">未注册</div> <input id="callee" placeholder="输入分机号或手机号" /> <button id="callBtn">呼叫</button> <button id="answerBtn">接听</button> <button id="hangupBtn">挂断</button> <button id="holdBtn">保持</button> <script src="phone.js"></script> </body> </html>对应的phone.js就是前面所有核心代码块的集合,只需要把其中的updatePhoneStatus等UI方法映射到DOM元素上即可。
8. 线上部署还需要注意的几件事
8.1 一定要用HTTPS和WSS
浏览器安全策略是死的:非安全上下文调用getUserMedia会被拒绝,WSS证书错误会导致WebSocket连接失败。生产环境建议在Nginx层统一终止HTTPS,再反向代理到前端静态资源;WSS则直接由FreeSWITCH的7443端口提供。如果企业内部强制要求所有流量都走Nginx,可以配置Nginx的stream模块把7443端口代理到FreeSWITCH,但注意WebSocket的Upgrade头需要正确透传。
8.2 并发数评估与FreeSWITCH性能
sip.js直连方案下,每一路通话在FreeSWITCH中会创建一个B2BUA session,内部包含两个leg:一个leg对浏览器侧走WebRTC,另一个leg对PSTN或者另一个WebRTC分机。一个FreeSWITCH实例在普通2核4G云服务器上跑几十路并发没有问题,但要注意媒体转发都是CPU密集任务,尤其是有编解码转换的时候。如果预期并发超过100路,建议横向扩容FreeSWITCH,或者评估引入WebRTC网关做SFU。
8.3 录音与合规
网页端通话如果需要录音,可以在FreeSWITCH的dialplan里加record_session应用,录音文件格式默认为wav,路径可配置。录音功能上线前务必确认本地的通话录音合规要求,尤其是涉及客服业务时,需要自动语音提示告知对方“本次通话可能被录音”。
8.4 前端状态机设计
网页电话最怕状态混乱:正在通话中收到新来电、保持状态下挂断、转移过程中用户又点了拨号,这些边界场景如果不做一个状态机,页面会进入一个用户看不懂的状态。我的做法是把电话状态机抽象成idle/ringing/calling/connected/held/transferring六个枚举,每个用户操作只允许在特定状态下触发,其余操作直接置灰或者拦截。
9. 实际使用后的感受和建议
整套方案跑通之后,我的最大感受是:sip.js直连FreeSWITCH的路线是这个需求里“技术复杂度和可控性”平衡得最好的方案,没有之一。它的核心优势在于信令路径直接、问题容易定位、FreeSWITCH侧能力完全暴露给前端开发者。团队里一个熟悉JavaScript的开发就能搞定网页端全部功能,后台同事只需要维护FreeSWITCH的XML配置,两边通过SIP协议握手,边界清晰。
如果你在一个纯前端团队里,没有SIP背景,我建议先把FreeSWITCH的自带Demo跑通,再用Zoiper或者microSIP这样的软电话验证分机注册和互拨,最后再写网页端代码。这是我犯过的最大错误:一上来就写前端,结果分机注册都失败,排查了半天才发现是TLS证书没配好。从最小的环节开始验证,每一层都确定没问题再往上叠加,这是电信级系统开发的通用方法,WebRTC电话也一样。
如果后续想继续扩展,可以把目光放到多方会议、通话队列、语音菜单、自动外呼这些场景,FreeSWITCH都原生支持。但核心的sip.js + FreeSWITCH + WebRTC三角关系只要吃透,后面那些高阶功能都会水到渠成。
本文还有配套的精品资源,点击获取