SIP.js + FreeSWITCH + WebRTC 网页电话实现:呼入呼出、保持与转移

发布时间:2026/10/16 3:21:26

SIP.js + FreeSWITCH + WebRTC 网页电话实现:呼入呼出、保持与转移 简介一套基于SIP协议库与FreeSWITCH开源软交换的WebRTC网页端电话应用示例面向需要快速验证SIP呼叫能力的Web开发者和音视频通信初学者。利用浏览器原生能力免插件即可完成网页呼叫代码结构简洁。压缩包内共四个文件包含网页结构、样式定义、呼叫脚本及说明文档整体仅79KB轻量紧凑。示例完整演示了电话呼入、呼出、转移、保持等典型业务操作修改脚本中的分机号码、认证密码与服务器地址即可在谷歌浏览器中直接运行测试免去了繁琐的环境配置。同时参考官方文档有助于理解UA注册、会话建立、呼叫控制等信令流程以及网页端集成开源软交换的配置要点。目前已有两千三百一十五人学习下载适合作为SIP与软交换联调入门参考也适合开发网页电话客服、远端坐席等项目的技术人员借鉴。1. 网页端电话为什么会选 SIP.js FreeSWITCH 这套组合我之前接了个网页话务台的活需求不复杂客服在浏览器里完成呼入、呼出、转移、保持不需要装任何软电话。第一版用 Socket.io 加 PeerConnection 自己做信令做到一半就翻车了——分机注册、被叫匹配、保持恢复这些电话语义全要自己重造还不如直接用现成的协议栈。后来换成 SIP.js FreeSWITCH WebRTC 这套组合SIP.js 在网页端负责发 SIP 信令FreeSWITCH 在服务端当 B2BUA 管话路WebRTC 只负责音频采集和播放。标题里的“sipjsFreeSWITCHwebrtc电话呼入、呼出、转移、保持网页端的应用示例”就是一套能把这几件事跑通的完整参考适合做客服工作台、网页呼叫中心、统一通信系统的同学照着改就能出自己的版本。选型这块不用犹豫浏览器里做电话SIP over WebSocket 就是唯一的主流路径。2. 总体架构设计信令链路与媒体链路的分离以及四个核心模块要理解这套方案先记住一句话WebRTC 只解决媒体传输不解决通话控制。通话控制注册、振铃、保持、转移、挂断是 SIP 的职责而 SIP.js 负责在浏览器里把这个职责执行起来FreeSWITCH 在服务端把所有话路统一管住。2.1 三层参与者浏览器、SIP.js 与 FreeSWITCH 的边界先看三个角色各自干什么。浏览器负责的是麦克风采集、扬声器播放、DTLS-SRTP 加解密以及 ICE 候选收集它本身不懂“分机”和“转移”是什么。SIP.js 在网页里扮演一个 SIP UAUser Agent它维护注册状态把用户操作翻译成 INVITE、BYE、REFER 这些 SIP 信令。FreeSWITCH 则是服务端的 B2BUABack-to-Back User Agent它会同时维护两条信令腿和两条媒体腿。为什么必须是 B2BUA而不只是 SIP 代理举个例子呼入时FreeSWITCH 与外线是一条腿SIP.js 是另一条腿媒体也一样浏览器发出来的 RTP 先到 FreeSWITCH再由它转发到对端。中间多转一次延迟会增加几毫秒但换来的是保持、转移、录音、强插都能在服务端可靠实现。如果只是 SIP 代理re-INVITE 带来的 SDP 修改就没法落到真正的媒体层保持和咨询转都会变扭。这个架构决定了后续所有功能的实现方式。参与者职责关键技术点浏览器采集、播放、加解密getUserMedia、RTCPeerConnectionSIP.js注册、呼叫控制REGISTER、INVITE、BYE、REFERFreeSWITCH路由话路、媒体转发Sofia SIP、B2BUA、拨号计划2.2 四个核心模块的职责划分整个网页端应用按功能拆成四个模块边界比较清晰也方便后面多人协作。注册与心跳模块负责 UA 启动、REGISTER 发送和 expires 续期会话控制模块负责接听、挂断、保持、取回、转移媒体控制模块负责采集麦克风、播放对端音频、发 DTMF状态回调模块把 SIP 层的生命周期事件翻译成 UI 状态。这四个模块里最容易做糊的是状态回调。SIP.js 的事件粒度很细有invitation的振铃、session的 established、refer的进度如果直接在组件里各写一套回调后面加功能就是灾难。我一般会在状态回调模块里做一个统一的状态机把 SIP 事件全部收敛成 idle、ringing、active、held、transferring 五个业务状态UI 只监听这五个状态变化。后面第四章会展开这个状态机的实现。2.3 三个关键设计决策WSS、SDP 协商与媒体路径第一个决策是信令必须走 WSS 而不是裸 WebSocket。浏览器页面在 HTTPS 环境下不允许发起ws://连接这是混合内容拦截在正式环境里必然出现“本地能通部署上去就注册失败”的玄学问题。SIP over WebSocket 的标准是 RFC 7118SIP.js 的 Transport 直接支持FreeSWITCH 的 Sofia profile 配置wss:7443就能监听。第二个决策是媒体协商交给 FreeSWITCH 做兜底。浏览器只支持 DTLS-SRTP但传统 SIP 网关可能只支持 SDES 或不加密的 RTPFreeSWITCH 作为 B2BUA 可以在一条腿上用 DTLS-SRTP、另一条腿上用明文的 RTP媒体格式转换是它最擅长的部分。第三个决策是媒体路径尽量靠近用户FreeSWITCH 转发媒体会引入一跳部署时把 FreeSWITCH 放在离用户近的内网节点不要让 RTP 绕到对端机房再回来。3. 呼入呼出落地拨号计划、分机注册与 Invite 会话对接这一章是整套方案里最需要照着抄的部分。呼出和呼入分别是两个方向的信令流但前端代码里有几条核心路径是共用的UA 初始化、注册、事件委托、会话状态监听。先把这些地基打好再加业务逻辑就顺手了。3.1 环境准备FreeSWITCH 侧开 WSS 入口并安装 SIP.js先装前端依赖一个命令npm install sip.js然后在 FreeSWITCH 的 Sofia profile 里加一个 WSS 监听入口。通常我会单独建一个 profile而不是改默认的 internal profile这样以后调整接入参数不会影响原有话路。配置文件关键是这几行profile namewss param namesip-port valuewss:7443/ param namedtls-version value1.2/ param nameinbound-codec-prefs valueOPUS/ param nameoutbound-codec-prefs valueOPUS/ param nameapply-nat-acl valuenat.auto/ /profilewss:7443表示监听 7443 端口的 WebSocket Secure 连接dtls-version指定 DTLS 版本codec 部分指定只协商 OPUS避免浏览器和 FreeSWITCH 在 codec 选择上扯皮。这里要注意证书问题WSS 需要合法证书开发阶段可以用自签名证书但浏览器会拦截需要手动信任正式环境一定要挂正式证书或者走反向代理。3.2 分机注册UA 启动和 REGISTER 续期的实现前端注册这一块核心代码如下。用 SIP.js 的UserAgent和Registerer来完成先建立 WSS 连接再发送 REGISTERimport { UserAgent, Registerer } from sip.js; const userAgent new UserAgent({ uri: UserAgent.makeURI(sip:1001pbx.local), transportOptions: { server: wss://pbx.local:7443 }, authorizationUsername: 1001, authorizationPassword: 123456 }); const registerer new Registerer(userAgent, { expires: 300 }); await userAgent.start(); await registerer.register();uri里的1001pbx.local是分机号加域名authorizationUsername和authorizationPassword用于 REGISTER 的 digest 认证。注册成功的前提是 FreeSWITCH 里存在对应的分机账号通常是directory/default.xml里配置的1001。expires: 300表示注册有效期 300 秒到时间后必须重新 REGISTER。这里有个坑如果页面一直开着300 秒后 FreeSWITCH 会认为分机离线。正确做法是写一个定时器在过期前 60 秒重新调用registerer.register()。不要等过期了再补中间那段空窗期来电会直接被 FreeSWITCH 路由失败。通常的做法是加个setInterval每次续期前先unregister()再register()避免重复 REGISTER 导致 401 循环。3.3 呼出从 INVITE 到 Established 的完整链路呼出是用户点了“拨号”按钮之后触发的一连串信令。前端代码只需要发起 INVITE后续响应由事件回调处理const target UserAgent.makeURI(sip:2002pbx.local); const session userAgent.invite(target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); session.stateChange.addListener((state) { if (state SessionState.Established) { // 通话建立更新 UI } else if (state SessionState.Terminated) { // 通话结束释放资源 } });invite方法返回一个Session对象它代表一路呼叫。sessionDescriptionHandlerOptions.constraints决定采集音频还是视频。信令层面的流程是前端发出 INVITE经过 WSS 到 FreeSWITCHFreeSWITCH 再向被叫分机发 INVITE被叫振铃返回 180接听后返回 200 OK前端回 ACK媒体开始流转。SIP.js 内部把这一串状态都封装好了我们要做的就是监听SessionState.Established和SessionState.Terminated。如果被叫号码是外线比如手机号FreeSWITCH 的拨号计划要负责把呼叫路由到网关。前端不需要知道被叫是内线还是外线全部交给服务端。这也是这套方案最省心的地方路由策略只写在 FreeSWITCH 一处前端永远只跟 FreeSWITCH 打交道。3.4 呼入接听onInvite 回调与拨号计划的对接呼入方向的核心在前端的delegate.onInvite回调里。当 FreeSWITCH 把外线来话路由到分机1001时前端会收到一个Invitation对象此时需要决定接听还是拒绝const userAgent new UserAgent({ // ... 其他配置 delegate: { onInvite(invitation) { // 这里触发页面振铃 UI等用户点击接听 invitation.accept().then(() { // 通话建立 }); } } });对应地FreeSWITCH 的拨号计划需要把呼入的路由做对。下面的例子是public拨号计划里的一段把来话匹配到分机1001extension nameweb-to-1001 condition fielddestination_number expression^(1001)$ action applicationbridge datauser/1001$${domain}/ /condition /extensionbridge动作把呼叫桥接到用户1001FreeSWITCH 会对1001注册的地址发起 INVITE也就是通过 WSS 推送到浏览器。前端onInvite触发后页面播放振铃音用户点击接听时调用invitation.accept()完成信令协商双方媒体开始互通。这套对接稳定后呼入呼出就形成闭环了。4. 保持与转移实现会话状态机与链路控制的方法呼入呼出打通之后真正体现网页话务台价值的是呼叫保持和转移。这两个功能在传统硬话机上都有标准流程但在网页端最容易出问题的地方是前端把状态搞错导致用户点了“保持”结果把麦克风关了或者点了“转移”结果自己先挂断了。这一章讲清楚背后的原理和代码。4.1 保持的实现re-INVITE 方向切换而不是停止采集首先要纠正一个常见误解保持不是把本地的麦克风stop()而是通过 re-INVITE 把媒体流方向从sendrecv改成sendonly。麦克风可以继续采集只是不再向对方发送媒体对方的 RTP 也不期望再收到。SIP.js 直接提供了session.hold()和session.unhold()async function toggleHold(session) { if (session.isHeld()) { await session.unhold(); } else { await session.hold(); } }isHeld()是实例方法返回当前是否处于保持状态。hold()内部发送一条带asendonly的 re-INVITEunhold()发送一条带asendrecv的 re-INVITE。关键是 SDP 协商成功后媒体方向才真正改变。FreeSWITCH 侧有一个对应的客户端操作uuid_hold。有时候前端已经发了 re-INVITE但 FreeSWITCH 因为 codec 或 SDP 协商失败导致保持没有生效UI 显示保持但对方还能听到声音。这种问题排查时先在fs_cli里执行uuid_hold uuid uuid_media uuiduuid_hold挂起媒体uuid_media恢复媒体。如果服务端强制保持成功说明问题出在 SIP.js 和 FreeSWITCH 的 SDP 协商上如果服务端也失败则要看 profile 配置里的dtls-version是否和浏览器协商一致。4.2 转移的实现REFER 信令与咨询转的区别转移在 SIP 里的标准动作是发送 REFER 请求。前端呼叫转移到另一个分机的核心调用如下const transferTarget UserAgent.makeURI(sip:3003pbx.local); session.refer(transferTarget).then(() { // REFER 已发送等待 NOTIFY 回调 });refer()发出后FreeSWITCH 作为 B2BUA 负责把呼叫转给目标分机3003被叫振铃、接听、挂断都由服务端控制前端只需要监听 REFER 的结果。要注意的是咨询转和盲转的区别上面这种是盲转直接告诉服务端“把当前通话转给 3003”前端自己不会保留一路新呼叫咨询转则需要先在当前通话之上建立一个咨询呼叫等对方接听后再把两路合并这属于更复杂的场景通常会用 FreeSWITCH 的bridge应用配合完成。这里最容易翻车的点REFER 发出后原来的 Session 并不会立刻结束要等 FreeSWITCH 返回 NOTIFY 200 OK 之后才真正释放。如果前端在refer()返回后立刻做了挂断清理就会导致转移中途断线。正确的做法是监听session.stateChange到Terminated之后再清理 UI。4.3 通话状态机从 SIP 事件到 UI 状态的收敛为了避免 UI 层被一堆 SIP 事件淹没我一般会做一个小的状态机把底层事件统一收敛成 5 个业务状态const CALL_STATE { IDLE: idle, RINGING: ringing, ACTIVE: active, HELD: held, TRANSFERRING: transferring };每个状态对应一组 UI 表现ringing显示振铃动画并播放铃声active显示通话计时器held显示“已保持”并把保持按钮切换成恢复transferring显示“正在转移”并禁用挂断以外的按钮。事件到状态的映射规则是呼入呼出时的invitation或session事件映射到ringing与activesession.isHeld()变化映射到held发出 REFER 到收 NOTIFY 结束映射到transferring。把所有 SIP 事件都收敛到这套枚举里UI 层永远只跟CALL_STATE打交道后面加静音、加录音、加呼叫驻留都只是加状态枚举不用改动组件逻辑。5. 常见问题排查注册掉线、单通、转移失效的翻车现场这套组合里有一半的问题不是代码写错而是信令和媒体没有对齐。我把项目里最容易踩的五个坑列出来全部按“现象 → 原因 → 解决”写血泪经验直接抄。5.1 页面开几分钟后注册失效来电接不起来现象UA 启动后一切正常但页面放了一会儿FreeSWITCH 那边查询分机状态显示离线来电能通但浏览器不振铃。原因expires: 300的注册过期后没有续期。SIP.js 的Registerer不会自动续期需要自己定时重新 REGISTER。另外如果部署在 NAT 后面网络地址变更也会让注册失效。解决在setInterval里以expires - 60秒为周期提前续期续期前先调用registerer.unregister()清掉旧注册再registerer.register()。同时检查 FreeSWITCH 的 profile 是否有apply-nat-acl配置没有的话加上避免 NAT 环境下注册地址错乱。5.2 呼出只有单通或者双方都没声音现象呼叫能建立界面显示通话中但只能听到对方或完全静音。原因浏览器强制 DTLS-SRTP而 FreeSWITCH 或对端网关协商出来的是普通 RTP或者 ICE 候选没有收集完整导致媒体包发错了地址。这是 WebRTC 电话最典型的媒体协商问题。解决在 FreeSWITCH profile 里确认dtls-version和 codec 配置查看rtp-start-port和rtp-end-port是否被防火墙挡掉抓包看 RTP 的源 IP 和目标 IP确认媒体是直连还是经过 FreeSWITCH 转发。通常做法是 FreeSWITCH 强制apply-candidate-acl让媒体统一走服务端转发不要指望浏览器和网关之间能打洞成功。5.3 保持后恢复两边都进入静音状态现象点击“保持”再点击“恢复”对方能听到你但你听不到对方或者双方都听不到。原因unhold()发出的 re-INVITE 没有被正确协商SDP 方向仍然是sendonly或者前端恢复时没有重新触发本地流的播放。解决确认session.unhold()的 Promise 真正 resolve 了再更新 UI在stateChange里监听Held与Unheld事件如果仍然失败在 FreeSWITCH 侧手动执行uuid_media uuid恢复媒体看是否存在协商问题。5.4 REFER 转移后通话被挂断目标分机没振铃现象点击转移后原通话直接挂断目标分机没有收到来电。原因前端发起 REFER 后旧 Session 还没到Terminated就被手工清理了或者 FreeSWITCH 配置拒绝了 transfer。解决不要在refer()返回后立刻清理会话等session.stateChange到Terminated再释放检查 FreeSWITCH 的 Sofia profile 是否有disable-transfer参数有的话设为 false。另外用咨询转时必须先确保咨询腿已经建立否则 FreeSWITCH 无法执行合并。5.5 页面刷新后重复注册同分机出现两个 UA现象按 F5 刷新页面FreeSWITCH 里1001出现了两个注册地址来电随机振铃到一个实例。原因浏览器刷新前没有断开旧的 WebSocket 连接也没有注销服务端暂时没有把旧注册标记为过期。解决在页面卸载前同步注销window.addEventListener(beforeunload, () { registerer.unregister(); userAgent.stop(); });同时把 UA 的sessionDescriptionHandler里的资源释放逻辑做好关页不关麦克风的问题也顺手解决了。6. 验收与答辩从功能到媒体质量的验证清单交付这套方案的时候不能只演示“能打通”要把验收标准写清楚否则很难说服别人这套东西真的能用。我整理的验收清单分四层功能、信令、媒体、异常。验收项操作步骤通过标准分机注册打开页面等待 UA 启动FreeSWITCHsofia status profile wss看到注册呼出拨打另一个分机对端振铃接听后双向音频正常呼入用另一部分机拨打本分机页面振铃接听后双向音频正常保持与恢复通话中点保持再点恢复UI 状态切换正确对方听到保持提示音恢复后正常通话转移通话中转移给第三分机第三分机振铃接听后原通话被替换异常恢复通话中强制断开网络页面提示通话中断UA 自动重连恢复注册答辩时最有说服力的演示方式不是逐个点按钮而是先展示一次完整呼入的信令流程。用浏览器的开发者工具打开 WebSocket 面板过滤出 INVITE、180、200 OK、ACK、BYE 这几个关键信令然后向对方说清楚WebRTC 只负责媒体传输SIP.js 把通话控制语义带进了浏览器FreeSWITCH 作为 B2BUA 把所有话路汇总到服务端统一管理。这一句话就能把方案的架构逻辑讲透。老师或评审通常还会追一个问题为什么不用点对点 WebRTC这时候回答的核心是点对点没有分机概念没有服务端路由保持和转移全靠自己造状态机而 B2BUA 让每一次保持、每一次转移、每一次录音都有服务端可查的会话记录出了问题能排查这才是生产系统该有的样子。从那以后我每次拿到新的 WebRTC 电话项目都强制走一遍这套验收清单从注册到媒体质量逐项过再也不必在上线前夜临时抓包救火。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/16 3:21:26

用代码定义架构图:从DSL设计到自动布局的完整实践

先说一个可能很多人都有过的经历:给系统画架构图,第一版画了两天,定稿之后产品需求变了三回,我蹲在画图软件里改了四回连线,最后图上的箭头走向连我自己都解释不清了。后来我实在受不了这种状态,开始研究&q…

2026/10/16 3:21:26

大华SDK Java接入实战:从初始化到生产部署的完整指南

简介:大华SDK是一套面向网络摄像头与视频监控设备的二次开发工具包,这份资源在完整SDK基础上额外提供了JAVA版DEMO,特别适合需要在大华硬件环境中进行Java业务开发的工程师。整个资源包共1372个文件,约18.6MB,包含大量…

2026/10/16 3:21:26

AI写kernel能超越手写,编译器就该退场?

1. 这个标题到底在聊什么先把话说直白一点:这个标题问的是一个很具体的技术问题——当大模型能直接生成高性能计算内核(kernel)的时候,传统编译器还有没有存在的必要?这不是一个纯学术问题,它直接关系到每一…

2026/10/16 4:41:29

联合储能参与配电网优化调度:建模、评估与Matlab实现

最近整理联合储能参与配电网优化调度的这套仿真框架时,我最大的感触是:储能接入配电网这件事,难点从来不在“把储能模型写进潮流计算”,而在“怎么让储能和新能源、网络约束一起被塞进同一个优化问题里,还能稳定求到解…

2026/10/16 4:41:29

OTFS完整MATLAB代码例程:从时延-多普勒域到收发端实现

简介:OTFS完整代码例程包面向数字通信与5G/6G物理层研究者,以及希望深入理解正交时频空间调制技术的开发人员,尤其适合高速移动与非视距场景下的仿真学习。代码覆盖OTFS调制、信道生成、接收解调、消息传递检测与干扰消除等关键环节&#xff…

2026/10/16 4:41:29

Mxnet实战:注意力机制在SNLI自然语言推理中的应用

1. 从标题拆解这个项目的真实意图1.1 标题里藏着哪些关键信息看到“Mxnet (40): 注意力机制自然语言推理(SNLI)数据集”这个标题,我第一反应是:这是一条典型的技术学习笔记式命名,括号里的“40”大概率是某个系列教程的…

2026/10/16 4:41:29

基于Mxnet与注意力机制的SNLI自然语言推理实战

1. 项目缘起与整体设计思路自然语言推理(Natural Language Inference,简称NLI)这个任务,说白了就是让机器判断两句话之间的逻辑关系:一句话叫“前提”,另一句话叫“假设”,机器需要判断假设相对…

2026/10/16 4:36:29

Linux 下 GeoClue AccessDenied 报错排查与修复指南

开篇:这个报错比你想象的常见如果你在 Linux 桌面上跑过任何带位置查询功能的应用——比如天气组件、地图软件、或者通过浏览器访问某个想拿你地理位置的网页——那你八成见过这条报错:"Unable to start GeoClue client: GDBus.Error:org.freedeskt…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑