news 2026/10/20 7:06:57

sipjs与FreeSWITCH实现网页呼叫中心:呼入呼出转移保持全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
sipjs与FreeSWITCH实现网页呼叫中心:呼入呼出转移保持全解析

简介:这份资源是面向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 秒后恢复
DTMFIVR 正确识别按键拨打 IVR 按数字键

最后说一个血泪经验:每次改完 FreeSWITCH 配置,一定要执行reloadxml和sofia profile internal restart,只改文件不重启模块,配置不会生效。从那以后我每次部署前都强制走一遍fs_cli -x "reloadxml"和fs_cli -x "sofia profile internal restart",再开始测试。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 7:04:59

GraphRAG技术全解析:知识图谱如何让大模型检索更智能

先聊一个我自己踩过的坑。前两年给某企业内部知识库做问答系统&#xff0c;用的是当时最主流的向量化RAG方案——文档切片、Embedding、向量召回、拼上下文给大模型。Demo跑起来很唬人&#xff0c;一问一个准&#xff0c;可一上真实业务数据就露馅&#xff1a;问“A项目的负责人…

作者头像 李华
网站建设 2026/10/20 7:03:59

JMeter接口压测实战:从环境搭建到并发测试报告解读

刚上手接口压测那会儿&#xff0c;我第一个遇到的问题是“工具选哪个”。市面上能压测的工具不少&#xff0c;但 JMeter 这个名字在简历和招聘需求里出现频率实在太高。它是 Apache 下的开源工具&#xff0c;纯 Java 写的&#xff0c;主要用来做性能测试、负载测试&#xff0c;…

作者头像 李华
网站建设 2026/10/20 7:03:38

OpenCvSharp C# WinForms 图片显示与处理Demo全解析

简介&#xff1a;OpenCvDemo.rar 是一套基于 OpenCvSharp 的 C# 图片显示与交互控件示例工程&#xff0c;面向需要在 WinForm/WPF 界面中集成 OpenCV 图像处理能力的开发人员与初学者。压缩包共 105 个文件、约 129MB&#xff0c;包含 19 个 .cs 源码、25 个 .dll 运行库、17 个…

作者头像 李华
网站建设 2026/10/20 7:03:18

Unity WebGL播放海康RTSP:转HLS全流程方案与避坑指南

简介&#xff1a;面向Unity/C#开发者的WebGL视频监控方案demo&#xff0c;核心是基于AVProVideo插件拉取并播放海康摄像头实时画面&#xff0c;解决WebGL平台下的视频解码与渲染问题。作者重写了Http请求模块&#xff0c;并加入无水印AVProVideo的发布配置指导文件&#xff1b;…

作者头像 李华
网站建设 2026/10/20 7:02:40

Web自动化测试流程详解:从用例筛选到脚本维护的完整实践

做Web自动化测试有些年头了&#xff0c;从最早写那种“打开浏览器、点两下、断言一下”的练手脚本&#xff0c;到后来在项目里搭起能跑几百条用例的完整测试体系&#xff0c;整个过程走下来&#xff0c;最大的感受是&#xff1a;自动化不是写代码难&#xff0c;而是流程难。网上…

作者头像 李华