简介:这份资源是面向WebRTC语音通信开发者的实战示例包,围绕sipjs与FreeSWITCH搭建网页端电话应用,解决浏览器内实现分机注册、呼入呼出、通话转移与保持等核心功能的落地问题。适合具备一定前端与SIP协议基础、希望快速验证软交换对接效果的开发者参考。压缩包共4个文件,包含1个html页面、1个css样式、1个js脚本以及1个txt说明,整体约79KB,结构精简,其中html与js承担界面与SIP信令逻辑,css负责通话面板样式,txt提供使用说明。在谷歌浏览器中运行后,只需将分机号、密码与服务器地址替换为自己的配置,即可直接进行功能测试,省去从零搭建信令交互的调试成本。目前已有2315人学习下载,可作为理解sipjs与FreeSWITCH配合方式的入门范例,也可在此基础上扩展通话记录、状态提示等业务功能。
1. 从一通打不通的网页电话说起
某公司客服团队想把呼叫中心搬到浏览器里,需求很明确:坐席打开网页就能接打电话,支持呼入、呼出、通话中转接、保持,还要能跟现有的 FreeSWITCH 软交换对接。团队一开始想用 WebRTC 原生 API 硬写,结果 SIP 信令、媒体协商、DTMF 传输全得自己造轮子,两周下来连注册都没跑通。后来换成 sipjs 做信令层、FreeSWITCH 做媒体和路由中枢,三天就把呼入呼出跑通了。这套 sipjs + FreeSWITCH 的组合,本质上是把浏览器当成一台软电话,用 WebRTC 承载媒体,用 SIP 协议跟 FreeSWITCH 通信。它适合两类人:一是要做网页端呼叫中心、在线客服、远程坐席的开发者;二是毕设或课设选题涉及 VoIP、实时通信、软交换的学生。下面这套方案,从架构设计到模块实现再到验收答辩,可以照着改。
2. 需求拆解与技术选型:为什么是 sipjs 加 FreeSWITCH
2.1 四个核心功能对应的技术需求
先把需求拆成可落地的技术点。呼入意味着浏览器要能接收 FreeSWITCH 路由过来的 INVITE 请求,并正确应答;呼出意味着浏览器要主动发起 INVITE,携带 SDP 协商媒体参数;转移分两种,盲转是直接发 REFER 让 FreeSWITCH 把对方接到新号码, attended transfer 则需要先保持当前通话再发起新呼叫;保持则是通过 re-INVITE 把媒体流方向设为 sendonly 或 inactive。
这四个功能对信令层的要求完全不同。呼入呼出考验的是 SIP 注册和会话管理能力,转移考验的是 REFER 消息的构造和事件订阅,保持考验的是 SDP 的重新协商。sipjs 作为纯 JavaScript 实现的 SIP 协议栈,把这四类信令都封装成了高层 API,开发者不需要手写 SIP 报文,只需要调用invite()、refer()、hold()这类方法。FreeSWITCH 则负责媒体路由、IVR、录音、队列这些呼叫中心必备能力,两者通过 WebSocket 传输 SIP 信令,通过 SRTP 传输媒体流。
2.2 为什么不用原生 WebRTC 或 Asterisk
原生 WebRTC 只解决了媒体传输问题,信令层完全空白。你要自己实现 SIP 注册、INVITE 事务、SDP 协商、DTMF 传输,工作量至少翻三倍。Asterisk 虽然也支持 WebRTC,但它的 SIP over WebSocket 实现相对封闭,调试时日志不够透明,遇到媒体协商失败很难定位是浏览器端还是服务端的问题。FreeSWITCH 的 mod_sofia 模块对 WebSocket 支持成熟,日志分级清晰,配合sofia global siptrace on可以直接看到完整的 SIP 报文交互,排查问题时不会变成黑匣子。
另一个关键点是 sipjs 的浏览器兼容性。它在 Chrome、Firefox、Edge 上表现一致,底层自动处理了浏览器之间的 SDP 差异。如果团队后续要接入 Electron 桌面端或移动端 WebView,sipjs 的 API 不需要改动,只需要确认 WebRTC 权限和网络环境即可。
2.3 整体架构与数据流
整个系统的数据流分三层。信令层:浏览器通过 WebSocket 连接 FreeSWITCH 的 5066 端口(默认 ws 端口),sipjs 在此之上建立 SIP 会话。媒体层:浏览器通过 WebRTC 的 RTCPeerConnection 与 FreeSWITCH 的 RTP 端口交换 SRTP 包,FreeSWITCH 内部用 mod_conference 或 mod_dptools 做混音和路由。业务层:网页端用 JavaScript 管理通话状态机,FreeSWITCH 用 dialplan 和 event socket 控制呼叫流程。
浏览器 (sipjs) <--WebSocket(SIP)--> FreeSWITCH <--SIP/RTP--> 外部电话网络 | | +----------WebRTC(SRTP)---------------+这个架构的好处是信令和媒体分离,调试时可以单独抓 SIP 包或 RTP 包。FreeSWITCH 的 dialplan 可以灵活配置呼入路由,比如按被叫号码前缀分发到不同坐席组,或者接入 IVR 做按键导航。
3. 环境搭建与基础配置:从零把通道打通
3.1 FreeSWITCH 端的最小配置
FreeSWITCH 默认安装后需要改三个地方。第一,启用 WebSocket 支持,在conf/sip_profiles/internal.xml里确认ws-binding参数存在,通常默认监听 5066 端口。第二,配置一个供浏览器注册的分机,在conf/directory/default/下新建1001.xml,内容如下:
<include> <user id="1001"> <params> <param name="password" value="1234"/> </params> <variables> <variable name="user_context" value="default"/> </variables> </user> </include>这个配置创建了分机 1001,密码 1234,注册后进入 default 上下文。第三,在 dialplan 里加一条路由,让呼入到 1001 的请求能正确到达浏览器:
<extension name="to_webrtc"> <condition field="destination_number" expression="^1001$"> <action application="bridge" data="user/1001"/> </condition> </extension>bridge会把呼叫转给已注册的 1001 分机,如果浏览器在线就会振铃。注意user_context要和 dialplan 的 context 一致,否则路由不生效。
3.2 浏览器端 sipjs 初始化
sipjs 的初始化分三步:创建 UserAgent、注册、监听事件。下面是最小可用代码:
const { UserAgent, Registerer, Inviter, SessionState } = SIP; const ua = new UserAgent({ uri: 'sip:1001@your-fs-domain', transportOptions: { server: 'wss://your-fs-domain:5066' // 注意用 wss 不是 ws }, authorizationUsername: '1001', authorizationPassword: '1234', displayName: '坐席1001' }); const registerer = new Registerer(ua); ua.start().then(() => { registerer.register(); console.log('SIP 栈已启动,注册中...'); }); registerer.stateChange.addListener((state) => { console.log('注册状态:', state); // Registered 表示成功 });server地址必须用wss://,因为浏览器页面如果是 HTTPS,混合内容会被拦截。如果 FreeSWITCH 没配 TLS 证书,可以用自签名证书,但浏览器会提示不安全,开发阶段可以手动信任。authorizationUsername和authorizationPassword要和 FreeSWITCH 目录里的配置一致。
3.3 验证注册与媒体通道
注册成功后,在 FreeSWITCH 控制台执行sofia status profile internal reg应该能看到 1001 的注册记录。如果看不到,先检查 WebSocket 连接是否建立:浏览器控制台 Network 面板里应该有 101 Switching Protocols 的请求。如果 WebSocket 通了但注册失败,通常是密码错误或 domain 不匹配。
媒体通道的验证方法是拨一个测试号码。在 dialplan 里加一条echo测试路由:
<extension name="echo_test"> <condition field="destination_number" expression="^9196$"> <action application="answer"/> <action application="echo"/> </condition> </extension>浏览器呼叫 9196,如果听到自己的回声,说明 SRTP 媒体通道正常。听不到回声但通话状态是 active,通常是 ICE 协商失败,检查 FreeSWITCH 的rtp-ip和ext-rtp-ip配置,确保浏览器能访问到 FreeSWITCH 的 RTP 端口范围。
4. 核心功能实现:呼入、呼出、转移、保持
4.1 呼出与呼入的会话管理
呼出用Inviter类,呼入用ua.on('invite')监听。先看呼出:
function makeCall(targetNumber) { const inviter = new Inviter(ua, `sip:${targetNumber}@your-fs-domain`, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); inviter.stateChange.addListener((state) => { if (state === SessionState.Established) { console.log('通话已建立'); attachMedia(inviter.sessionDescriptionHandler); } else if (state === SessionState.Terminated) { console.log('通话结束'); } }); inviter.invite(); return inviter; }sessionDescriptionHandlerOptions里的constraints控制媒体类型,只开音频可以降低带宽和回声问题。attachMedia负责把远端媒体流挂到 audio 标签上:
function attachMedia(sdh) { const pc = sdh.peerConnection; const remoteStream = new MediaStream(); pc.getReceivers().forEach((receiver) => { if (receiver.track) remoteStream.addTrack(receiver.track); }); document.getElementById('remoteAudio').srcObject = remoteStream; }呼入的处理逻辑类似,只是方向反过来:
ua.on('invite', (invitation) => { console.log('收到呼入请求,来自:', invitation.remoteIdentity.uri.toString()); invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }).then(() => { attachMedia(invitation.sessionDescriptionHandler); }); });这里有个容易翻车的点:invitation.accept()必须在用户点击接听按钮后调用,不能自动接听,否则浏览器会因为没有用户手势而阻止音频播放。通常的做法是在页面上弹一个接听/拒接的 UI,用户点击后再执行 accept。
4.2 通话保持与恢复的实现细节
保持的本质是重新协商 SDP,把媒体方向改成sendonly或inactive。sipjs 没有直接提供hold()方法,需要手动操作 sessionDescriptionHandler:
async function holdCall(session) { const sdh = session.sessionDescriptionHandler; const pc = sdh.peerConnection; // 获取本地 SDP,修改媒体方向 const offer = await pc.createOffer({ offerToReceiveAudio: true }); offer.sdp = offer.sdp.replace(/a=sendrecv/g, 'a=sendonly'); await pc.setLocalDescription(offer); await session.sessionDescriptionHandler.setLocalDescription(offer); // 发送 re-INVITE session.sessionDescriptionHandler.sendOffer(); console.log('通话已保持'); }恢复时把sendonly改回sendrecv再发一次 re-INVITE。注意sendonly表示只发不收,对方会听到静音;inactive表示收发都停,适合长时间保持。实际项目中我一般用sendonly,因为恢复时协商更快。
4.3 盲转与 attended transfer 的信令流程
盲转用 REFER 消息,sipjs 封装成了refer()方法:
function blindTransfer(session, targetNumber) { session.refer(`sip:${targetNumber}@your-fs-domain`) .then(() => { console.log('盲转请求已发送'); }) .catch((err) => { console.error('盲转失败:', err); }); }盲转发出后,FreeSWITCH 会收到 REFER 并尝试呼叫目标号码,当前通话会挂断。如果目标号码无人接听,原通话不会自动恢复,这是盲转的固有风险。
Attended transfer 需要两步:先保持当前通话,再发起一个新呼叫,等新通话建立后再发 REFER 把两个会话连起来。代码逻辑如下:
async function attendedTransfer(originalSession, targetNumber) { // 第一步:保持原通话 await holdCall(originalSession); // 第二步:呼叫目标号码 const newInviter = makeCall(targetNumber); // 第三步:新通话建立后,发 REFER 带 Replaces 头 newInviter.stateChange.addListener(async (state) => { if (state === SessionState.Established) { const referTo = `sip:${targetNumber}@your-fs-domain?Replaces=${originalSession.dialog.callId}%3Bto-tag%3D${originalSession.dialog.remoteTag}%3Bfrom-tag%3D${originalSession.dialog.localTag}`; await originalSession.refer(referTo); console.log('Attended transfer 完成'); } }); }Replaces头是关键,它告诉 FreeSWITCH 用新通话替换原通话。这个头的格式容易写错,callId、remoteTag、localTag必须从原会话的 dialog 对象里取,不能手拼。如果 FreeSWITCH 返回 400 错误,先检查这三个值是否正确。
5. 避坑与常见问题排查
5.1 注册成功但呼出无声
现象:浏览器显示注册成功,拨号后对方能振铃,但接通后双方都听不到声音。原因通常是 ICE 协商失败或 SRTP 密钥不匹配。FreeSWITCH 默认的rtp-ip可能是内网地址,浏览器在外网时无法直连。解决方法是设置ext-rtp-ip为公网地址,并在 dialplan 里启用bypass_media让媒体走 FreeSWITCH 中转。如果还是无声,在浏览器控制台执行pc.getStats()查看inbound-rtp的bytesReceived是否增长,不增长说明 RTP 包没到。
5.2 呼入时浏览器不振铃
现象:外部电话呼入分机 1001,FreeSWITCH 日志显示user/1001已桥接,但浏览器没有任何反应。原因通常是 dialplan 的 context 和分机注册时的user_context不一致,或者bridge的user/前缀写成了sofia/internal/。解决方法是先用sofia status profile internal reg确认分机在线,再检查 dialplan 的condition是否匹配被叫号码。如果都正常,在 FreeSWITCH 控制台执行console loglevel 7打开详细日志,看 INVITE 是否发到了 WebSocket 连接上。
5.3 转移后原通话未挂断
现象:盲转发出后,新通话建立了,但原通话还保持在线,坐席听到两个声音。原因是 REFER 的Refer-To头格式不对,FreeSWITCH 没有正确解析。检查refer()传入的 URI 是否带了sip:前缀,以及目标号码是否在 dialplan 里有路由。另一个可能是 FreeSWITCH 的transfer应用需要额外参数,在 dialplan 里加<action application="set" data="hangup_after_bridge=true"/>可以确保桥接成功后原通话自动挂断。
5.4 保持后恢复失败
现象:通话保持正常,但点击恢复时浏览器报InvalidStateError或setRemoteDescription失败。原因是 re-INVITE 的 SDP 版本号没有递增,或者 ICE 候选重新收集时超时。解决方法是在修改 SDP 后手动递增o=行里的版本号,并确保setLocalDescription和setRemoteDescription成对调用。如果 ICE 超时,在sessionDescriptionHandlerOptions里加iceGatheringTimeout: 3000缩短等待时间。
5.5 DTMF 按键无响应
现象:通话中按数字键,IVR 没有反应。原因是 DTMF 传输方式不匹配。sipjs 默认用 RFC 2833,但 FreeSWITCH 可能配置成了 SIP INFO。在ua的sessionDescriptionHandlerOptions里加dtmfType: 'RFC2833'强制指定,或者在 FreeSWITCH 的sip_profiles/internal.xml里设置dtmf-type为rfc2833。如果还是不行,用session.info()手动发 SIP INFO 消息测试。
6. 验收标准与答辩演示技巧
验收这套系统,我一般分四步走。第一步,功能验收:呼入、呼出、盲转、attended transfer、保持恢复、DTMF 六个场景逐一测试,每个场景至少跑三遍,记录成功率。第二步,媒体质量验收:用pc.getStats()抓取packetsLost和jitter,丢包率低于 1%、抖动低于 30ms 算合格。第三步,并发验收:用脚本模拟 10 个浏览器同时注册并呼出,观察 FreeSWITCH 的 CPU 和内存占用,确认没有会话泄漏。第四步,异常验收:拔网线、关浏览器、FreeSWITCH 重启,看系统能否自动恢复注册和通话状态。
答辩演示时,不要只放 PPT。直接开两个浏览器窗口,一个模拟坐席,一个模拟客户,现场走一遍完整流程:客户呼入、坐席接听、保持、盲转到另一个坐席、挂断。演示过程中故意制造一个异常,比如保持后不恢复直接挂断,然后展示 FreeSWITCH 日志里会话正常清理。评委通常会问“如果 FreeSWITCH 挂了怎么办”,提前准备一个重连机制的说明:sipjs 的ua.on('disconnected')事件里加自动重连逻辑,配合registerer.register()重新注册。
下面这张表是我验收时用的检查清单,可以直接抄:
| 验收项 | 通过标准 | 测试方法 |
|---|---|---|
| 注册 | 3 秒内状态变为 Registered | 刷新页面观察控制台 |
| 呼出 | 对方振铃并接通,双向音频 | 拨打 echo 测试号 |
| 呼入 | 浏览器振铃,点击接听可通话 | 外部电话呼入分机 |
| 盲转 | 原通话挂断,新通话建立 | 转移后检查两路会话状态 |
| 保持 | 对方听到静音,恢复后正常 | 保持 10 秒后恢复 |
| DTMF | IVR 正确识别按键 | 拨打 IVR 按数字键 |
最后说一个血泪经验:每次改完 FreeSWITCH 配置,一定要执行reloadxml和sofia profile internal restart,只改文件不重启模块,配置不会生效。从那以后我每次部署前都强制走一遍fs_cli -x "reloadxml"和fs_cli -x "sofia profile internal restart",再开始测试。希望帮到你。
本文还有配套的精品资源,点击获取