SIP.js与FreeSWITCH实战:网页端电话呼入呼出转移保持全流程
简介这是一份面向WebRTC语音通信开发者的实战示例资源聚焦sipjs与FreeSWITCH的集成解决网页端实现电话呼入、呼出、转移与保持等核心功能的落地问题。适合具备一定前端与SIP协议基础、希望快速验证软电话能力的开发者参考。压缩包共4个文件包含1个html页面、1个css样式、1个js脚本及1个txt说明整体约79KB结构精简其中html承载界面与交互入口js负责SIP信令与媒体处理css控制通话面板样式txt提供必要的使用说明。在谷歌浏览器下运行后只需将分机号、密码与服务器地址替换为自己的配置即可直接进行功能测试省去从零搭建信令与媒体协商的繁琐过程。目前已有2315人学习下载可作为理解sipjs调用方式、FreeSWITCH对接流程以及WebRTC通话控制逻辑的参考样例帮助读者快速定位关键代码并迁移到自身项目中。1. 从一通打不出去的电话说起为什么选 SIP.js FreeSWITCH 做网页端话务某天下午A同学在实验室里对着浏览器控制台发愁——他要在网页端实现电话呼入、呼出、转移、保持后端用 FreeSWITCH前端用 SIP.js结果注册上了但一拨号就断转移更是直接静默失败。这不是个例。很多做企业通信类毕设或课程设计的同学卡在“信令通了但媒体不通”“单路通话能跑、多路就崩”这类问题上翻遍文档也找不到一份从注册到转移全流程打通的完整示例。这套方案要解决的就是这件事用 SIP.js 在浏览器侧建立 WebRTC 软电话通过 FreeSWITCH 完成信令路由与媒体桥接实现网页端电话的呼入、呼出、盲转、 attended 转移和通话保持。适合正在做通信工程、计算机相关毕设或课设的学生也适合需要快速搭一套 Web 呼叫中心原型的开发者。整套方案围绕“能跑通、能演示、能答辩”来组织不堆概念直接落到模块划分和关键代码。2. 需求拆解与技术选型为什么是 SIP.js 配 FreeSWITCH2.1 网页端话务的四个硬需求先把需求摆清楚不然后面选型没有依据。网页端电话应用核心需求通常就四条第一呼出。用户在网页上输入号码或点联系人发起一路通话对方振铃、接听、通话、挂断全流程状态要能在界面上反映出来。第二呼入。外部电话打进来网页端要能振铃、显示主叫号码、接听、通话。这里涉及浏览器音频权限和自动播放策略是翻车高发区。第三转移。通话中把对方转给第三方号码分盲转和 attended 转移。盲转是直接甩出去attended 是先和第三方通话确认再转。企业场景里 attended 转移更常用但实现复杂度也更高。第四保持。通话中暂时搁置对方播放保持音乐或静音恢复后继续通话。保持期间要保证媒体流不中断、SDP 不重协商失败。这四条需求决定了技术选型不能只看“能不能注册”而要看“媒体能不能稳住、状态能不能同步”。2.2 SIP.js 与 FreeSWITCH 的分工SIP.js 是浏览器侧的 SIP 协议栈负责把 SIP 信令封装成 JavaScript API同时对接 WebRTC 的 RTCPeerConnection 做媒体协商。它不负责媒体转发只负责“谈”和“控”。FreeSWITCH 是服务端的软交换机负责注册鉴权、呼叫路由、媒体桥接、录音、会议、IVR 等。它把浏览器的 WebRTC 媒体流和外部电话的 RTP 流桥接起来是整套系统的中枢。两者配合的逻辑是SIP.js 在浏览器里注册到 FreeSWITCH 的 WSS 端口呼出时发 INVITEFreeSWITCH 根据 dialplan 路由到目标呼入时 FreeSWITCH 收到外部呼叫查路由后向已注册的 SIP.js 分机发 INVITE转移和保持通过 SIP 的 REFER 和 re-INVITE 实现FreeSWITCH 负责执行和状态回传。选这套组合而不是纯 WebSocket 自研信令原因是 SIP 协议成熟、FreeSWITCH 的 dialplan 灵活、SIP.js 对 WebRTC 的适配已经处理了大部分浏览器兼容问题。自己造轮子光 ICE 穿透和 SDP 协商就能耗掉整个毕设周期。2.3 环境与依赖版本环境这块通常的做法是FreeSWITCH 1.10.x编译时开启--enable-opus和 WebRTC 相关模块浏览器端用 SIP.js 0.20.x 或 0.21.x这两个版本 API 相对稳定前端框架不限Vue 或 React 都行核心是 SIP.js 的封装证书必须用 HTTPS/WSS浏览器不允许非安全上下文访问麦克风注意FreeSWITCH 的 WSS 端口默认是 7443SIP.js 连接地址格式为wss://your-host:7443不是sip:开头。3. 总体架构设计三层结构与五个核心模块3.1 三层架构的职责划分整套系统分三层接入层是浏览器里的 SIP.js 实例负责注册、呼叫控制、媒体采集与播放。它通过 WebSocket Secure 连到 FreeSWITCH 的 WSS 端口媒体走 DTLS-SRTP。交换层是 FreeSWITCH负责分机注册、dialplan 路由、媒体桥接、转移执行、保持音乐播放。它同时对接外部 SIP 中继或网关实现网页端和外部电话的互通。业务层是前端应用逻辑负责联系人管理、通话记录、状态展示、按钮交互。这一层不碰 SIP 协议细节只调用封装好的通话控制接口。三层之间通过明确的接口通信接入层和交换层之间是 SIP over WSS交换层和业务层之间通过 FreeSWITCH 的 Event Socket 或 REST API 同步状态。3.2 五个核心模块模块一注册与心跳模块。SIP.js 的UserAgent负责注册需要处理注册失败重试、网络断开重连、心跳保活。注册状态要暴露给 UI让用户知道当前是否在线。模块二呼出模块。封装invite()方法处理拨号、振铃、接通、挂断的状态流转。关键是要在Session的progress、accepted、terminated事件里更新 UI。模块三呼入模块。监听UserAgent的invite事件收到后创建Invitation对象展示来电界面用户接听则accept()拒接则reject()。模块四转移模块。盲转用refer()attended 转移需要先invite()第三方建立通话后再refer()原通话。FreeSWITCH 侧要配置好refer的 dialplan 处理。模块五保持模块。通过invite()发送 re-INVITE把媒体方向设为sendonly或inactiveFreeSWITCH 侧播放保持音乐。恢复时再发一次 re-INVITE 把方向改回sendrecv。3.3 信令与媒体流走向呼出时浏览器 SIP.js 发 INVITE → FreeSWITCH 收到后查 dialplan → 路由到目标分机或外部网关 → 目标振铃 → 接听后 FreeSWITCH 回 200 OK → SIP.js 收到后建立 RTCPeerConnection → 媒体流经 FreeSWITCH 桥接。呼入时外部呼叫到达 FreeSWITCH → dialplan 匹配后向已注册的 SIP.js 分机发 INVITE → 浏览器振铃 → 用户接听 → 200 OK → 媒体建立。转移时SIP.js 发 REFER → FreeSWITCH 收到后执行转移逻辑 → 向目标发 INVITE → 目标接听后原通话挂断 → 转移完成。保持时SIP.js 发 re-INVITESDP 中asendonly→ FreeSWITCH 收到后播放保持音乐 → 恢复时发 re-INVITEasendrecv→ 媒体恢复。4. 关键模块实现从注册到转移的代码落地4.1 SIP.js 注册与 UserAgent 配置注册是第一步也是最容易出问题的一步。下面是一个可用的配置示例// SIP.js 0.21.x 注册配置 const config { uri: sip:1001your-fs-host, // 分机 URI transportOptions: { server: wss://your-fs-host:7443, // FreeSWITCH WSS 地址 traceSip: true // 调试时开启生产关闭 }, authorizationUsername: 1001, // 鉴权用户名 authorizationPassword: your-pass, // 鉴权密码 displayName: WebPhone-1001, sessionDescriptionHandlerFactoryOptions: { iceGatheringTimeout: 3000, // ICE 收集超时单位毫秒 peerConnectionConfiguration: { iceServers: [{ urls: stun:stun.example.com:3478 }] } } }; const userAgent new SIP.UserAgent(config); // 注册状态监听 userAgent.delegate { onConnect: () console.log(WSS 已连接), onDisconnect: (error) console.log(WSS 断开, error), onInvite: (invitation) handleIncomingCall(invitation) // 呼入入口 }; userAgent.start().then(() { const registerer new SIP.Registerer(userAgent); registerer.register({ expires: 300, // 注册有效期秒 requestDelegate: { onAccept: () console.log(注册成功), onReject: (response) console.log(注册失败, response.message.statusCode) } }); });这段代码里uri和authorizationUsername必须和 FreeSWITCH 里配置的分机一致。iceGatheringTimeout设 3000 毫秒是经验值太短会导致 ICE 候选收集不全太长会让呼叫建立变慢。onInvite是呼入的总入口所有外部来电都从这里进来。提示调试阶段把traceSip设为true控制台会打印完整的 SIP 信令排查注册和呼叫问题非常有用。生产环境记得关掉否则日志量很大。4.2 呼出与呼入的状态机呼出的核心是invite()和事件监听// 发起呼出 function makeCall(targetNumber) { const target sip:${targetNumber}your-fs-host; const inviter new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } // 只要音频 } }); inviter.stateChange.addListener((state) { switch (state) { case SIP.SessionState.Establishing: updateUI(正在呼叫...); break; case SIP.SessionState.Established: updateUI(通话中); attachMedia(inviter.sessionDescriptionHandler); // 绑定音频 break; case SIP.SessionState.Terminated: updateUI(通话结束); break; } }); inviter.invite(); return inviter; }呼入的处理逻辑类似但方向相反// 处理呼入 function handleIncomingCall(invitation) { // 展示来电界面显示主叫号码 showIncomingUI(invitation.remoteIdentity.uri.toString()); // 用户点击接听 document.getElementById(answer-btn).onclick () { invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); }; // 用户点击拒接 document.getElementById(reject-btn).onclick () { invitation.reject(); }; // 监听状态变化 invitation.stateChange.addListener((state) { if (state SIP.SessionState.Established) { attachMedia(invitation.sessionDescriptionHandler); } }); }attachMedia的作用是把远端媒体流绑定到audio元素上这一步不做通话就是静音的。通常的做法是遍历sessionDescriptionHandler.peerConnection.getReceivers()找到音频 track创建MediaStream后赋给 audio 元素的srcObject。4.3 转移与保持的实现细节盲转最简单直接调refer()// 盲转把当前通话转给 targetNumber function blindTransfer(session, targetNumber) { session.refer(sip:${targetNumber}your-fs-host, { requestDelegate: { onAccept: () console.log(转移请求已接受), onReject: (response) console.log(转移失败, response.message.statusCode) } }); }Attended 转移要复杂一些需要先建立一路新通话确认后再转// Attended 转移先呼叫第三方接通后再转 async function attendedTransfer(originalSession, targetNumber) { // 第一步保持原通话 await holdSession(originalSession); // 第二步呼叫第三方 const consultSession makeCall(targetNumber); // 第三步第三方接通后用户确认转移 consultSession.stateChange.addListener(async (state) { if (state SIP.SessionState.Established) { // 用户点击“完成转移” document.getElementById(complete-transfer).onclick () { originalSession.refer(consultSession, { requestDelegate: { onAccept: () { consultSession.bye(); // 挂断咨询通话 console.log(Attended 转移完成); } } }); }; } }); }保持的实现依赖 re-INVITE// 保持通话 function holdSession(session) { const options { sessionDescriptionHandlerModifiers: [ SIP.Web.SessionDescriptionHandlerModifiers.hold // SIP.js 内置的保持修饰符 ] }; return session.invite(options); } // 恢复通话 function unholdSession(session) { const options { sessionDescriptionHandlerModifiers: [ SIP.Web.SessionDescriptionHandlerModifiers.unhold ] }; return session.invite(options); }SIP.js 内置了hold和unhold修饰符它们会修改 SDP 中的媒体方向属性。FreeSWITCH 收到sendonly的 SDP 后会自动播放保持音乐。恢复时发sendrecv媒体流恢复。注意保持和恢复之间要有状态锁防止用户快速连点导致 re-INVITE 冲突。通常的做法是在发起 re-INVITE 后禁用按钮收到 200 OK 后再启用。4.4 FreeSWITCH dialplan 配置FreeSWITCH 侧的 dialplan 决定了呼叫怎么路由。下面是一个支持呼入、呼出、转移的最小配置!-- /etc/freeswitch/dialplan/default.xml 片段 -- extension namewebphone-outbound condition fielddestination_number expression^(\d{4,})$ action applicationbridge datauser/$1/ /condition /extension extension namewebphone-inbound condition fielddestination_number expression^1001$ action applicationbridge datauser/1001/ /condition /extension extension nametransfer-handler condition fielddestination_number expression^transfer-(\d)$ action applicationtransfer data$1 XML default/ /condition /extensionwebphone-outbound处理呼出目标号码是 4 位以上数字时直接 bridge 到对应分机。webphone-inbound处理呼入匹配 1001 分机后 bridge 过去。transfer-handler处理转移匹配transfer-前缀后执行 transfer。分机注册配置在directory/default/下每个分机一个 XML 文件关键字段是password和user_context。WebRTC 分机需要额外配置rtp_secure_media和enable_3pcc。5. 避坑与常见问题排查5.1 注册成功但拨号无声现象SIP.js 显示注册成功拨号后对方振铃接听后双方都听不到声音。原因通常是 ICE 候选收集不全或 SDP 协商失败。浏览器在 HTTPS 下才能访问麦克风如果页面是 HTTPgetUserMedia会静默失败导致 SDP 里没有媒体描述。解决确认页面是 HTTPS检查iceGatheringTimeout是否太短在 FreeSWITCH 侧开启rtp_debug看 RTP 包是否到达。通常把iceGatheringTimeout调到 3000 以上能解决大部分问题。5.2 呼入时浏览器不振铃现象外部电话打进来FreeSWITCH 日志显示已向分机发 INVITE但浏览器没有任何反应。原因SIP.js 的onInvite回调没有正确绑定或者UserAgent的 delegate 在注册后被覆盖。解决检查userAgent.delegate.onInvite是否在start()之前设置。如果用了框架注意组件销毁重建时 delegate 是否被重新赋值。通常的做法是把 delegate 设置放在start()之前且只设置一次。5.3 转移后原通话不挂断现象盲转或 attended 转移后目标接通了但原通话仍然保持出现两路通话。原因FreeSWITCH 的 dialplan 没有正确处理 REFER或者refer()的requestDelegate没有在onAccept里挂断原 session。解决在refer()的onAccept回调里显式调用session.bye()。同时检查 FreeSWITCH 的transfer配置确保refer后执行了transfer而不是bridge。5.4 保持后恢复失败现象保持正常但恢复时 re-INVITE 被拒绝通话直接挂断。原因SDP 中的媒体方向属性不匹配或者 FreeSWITCH 的hold音乐播放与 re-INVITE 冲突。解决确认hold和unhold修饰符成对使用不要在保持状态下直接发unhold。在 FreeSWITCH 侧检查hold_music配置确保保持音乐文件存在且格式正确。5.5 多路通话时媒体串流现象同时有两路通话时声音串到另一路或者一路挂断另一路也断。原因attachMedia时没有区分 session所有音频流绑到了同一个 audio 元素。解决每个 session 创建独立的 audio 元素用 session id 做 key 管理。通常的做法是维护一个MapsessionId, HTMLAudioElementsession 结束时销毁对应的 audio 元素。6. 验收与答辩怎么证明这套方案真的能跑验收这件事很多同学栽在“功能都写了但演示时翻车”。我一般会按下面的清单走一遍确保答辩时不出意外。功能验收清单验收项操作步骤预期结果常见失败原因注册打开页面输入分机密码状态显示“在线”WSS 地址错误、证书不受信任呼出输入分机号点击呼叫对方振铃接听后双向通话麦克风权限未授权、ICE 超时呼入用另一分机呼叫当前分机页面振铃显示主叫号码onInvite 未绑定、自动播放被拦截盲转通话中点击盲转输入目标号原通话挂断目标振铃REFER 未处理、dialplan 未匹配Attended 转移通话中点击咨询接通后完成转移原通话转给第三方保持状态未同步、re-INVITE 冲突保持/恢复通话中点击保持再点击恢复保持时听音乐恢复后正常通话SDP 方向属性错误、按钮未加锁答辩演示技巧第一提前把 FreeSWITCH 和浏览器都重启一遍清掉残留注册。SIP 注册有有效期残留注册会导致新注册被拒。第二准备两个浏览器窗口或两个分机一个呼出一个呼入现场演示双向通话。不要只演示单向评委通常会问“呼入呢”。第三转移演示时先演示盲转再演示 attended 转移。Attended 转移的步骤多提前把第三方号码写在纸上避免现场输入错误。第四保持演示时让评委听到保持音乐。如果 FreeSWITCH 没配保持音乐保持就是静音评委可能以为通话断了。第五准备一份 FreeSWITCH 的日志截图显示 INVITE、200 OK、REFER、re-INVITE 的完整信令流程。评委问“底层怎么实现的”时直接拿日志说话。代码层面的验收点UserAgent的 delegate 是否在start()前设置attachMedia是否按 session 区分 audio 元素refer()的onAccept是否挂断原 sessionhold和unhold是否成对且加了状态锁FreeSWITCH dialplan 是否覆盖了呼出、呼入、转移三条路由从那以后我每次做通信类项目都会在答辩前把这份清单走一遍尤其是“两个浏览器窗口互拨”这一步能提前暴露 80% 的媒体问题。希望这套方案能帮你把网页端电话的呼入、呼出、转移、保持一次跑通答辩时不用再对着控制台发呆。本文还有配套的精品资源点击获取