sipjs+FreeSWITCH+WebRTC:网页端电话应用开发实战
简介这份资源是面向WebRTC语音通信开发者的实战示例包聚焦sipjs与FreeSWITCH的整合应用解决网页端软电话从零搭建的问题。内容覆盖电话呼入、呼出、转移、保持等核心呼叫控制功能适合具备一定前端与SIP协议基础、希望快速验证WebRTC通话链路的开发者参考。压缩包共4个文件包含1个html页面、1个css样式、1个js脚本以及1个txt说明文档整体约79KB结构精简其中html承载界面与呼叫逻辑js负责SIP信令交互css处理样式txt提供使用说明。在谷歌浏览器下运行后只需将分机号、密码与服务器地址替换为自己的配置即可直接进行功能测试省去从零摸索信令流程的时间。目前已有2315人学习下载可作为理解sipjs呼叫控制与FreeSWITCH对接的轻量级参考范例。1. 浏览器里直接打电话sipjsFreeSWITCHWebRTC 网页端电话应用拆解你有没有遇到过这种需求客户在网页上点一个按钮直接就能拨通客服的座机或手机不需要装任何插件也不需要软电话客户端。听起来像是要搞一套复杂的通信系统但实际拆下来核心就是 sipjs 负责浏览器端的信令FreeSWITCH 负责服务端的呼叫控制WebRTC 负责媒体传输。这三者拼在一起就是一个完整的网页端电话应用。这份资源给的正是一个能跑通的示例覆盖了呼入、呼出、转移、保持这四个电话场景里最常用的操作。适合谁前端想接触实时通信的、后端想了解 FreeSWITCH 对接的、以及需要快速搭一个 Web 呼叫中心的开发者。下面我按实际拆项目的顺序把这份示例从架构到代码到改造讲一遍。2. 技术栈与整体架构sipjs 和 FreeSWITCH 各自扛什么活2.1 三层结构拆开看这个项目的架构不复杂但每一层的职责边界要划清楚不然后面调试会很难受。浏览器端跑的是 sipjs它是一个 JavaScript 写的 SIP 协议栈负责注册、发起呼叫、接收呼叫、发送 DTMF、处理 hold 和 transfer 的信令。sipjs 不碰媒体媒体交给浏览器原生的 WebRTC API 处理。也就是说麦克风采集、编解码、RTP 打包这些事sipjs 不管它只管 SIP 消息的收发。中间层是 FreeSWITCH它同时扮演 SIP 代理和媒体服务器的角色。浏览器发过来的 SIP 消息由 FreeSWITCH 接收它根据 dialplan 决定这通电话往哪走——是转到另一个分机、转到外部网关、还是进会议、进 IVR。媒体方面FreeSWITCH 负责和浏览器协商 SDP建立 WebRTC 的媒体通道。最外层就是被叫方可能是另一个浏览器分机也可能是传统电话网里的号码。如果被叫也是 WebRTC 终端那媒体就是端到端的 SRTP如果被叫是传统电话FreeSWITCH 会做媒体转码和协议转换。这个结构的好处是前端开发者只需要跟 sipjs 的 API 打交道不需要理解 FreeSWITCH 的 dialplan 细节后端运维只需要保证 FreeSWITCH 的配置正确不需要关心浏览器兼容性。2.2 为什么选 sipjs 而不是其他方案WebRTC 网页端打电话常见的方案有 sipjs、JsSIP、以及一些商业 SDK。这份示例选 sipjs 的原因我拆下来觉得主要是几点。第一sipjs 的 API 设计比较现代基于 Promise 和事件订阅写起来不像老式回调那么绕。第二它对 WebRTC 的适配做得比较完整包括 hold、transfer 这些操作都有对应的 API不需要自己拼 SIP 消息。第三它的社区活跃度还可以遇到问题能搜到一些讨论。当然也有代价。sipjs 的文档不算特别详细很多用法得看源码或者示例。另外它对一些 SIP 扩展的支持不如 JsSIP 全面如果你的场景需要复杂的 SIP header 操作可能要评估一下。2.3 FreeSWITCH 侧的配置要点FreeSWITCH 这边核心是让 WebSocket 方式的 SIP 注册能正常工作。默认的 SIP profile 走的是 UDP/TCPWebRTC 需要走 WebSocket所以要在sofia.conf.xml里确认有一个 profile 监听了ws-binding和wss-binding。常见做法是单独建一个 internal 的 profile专门给 WebRTC 终端用端口比如 5066 走 ws7443 走 wss。然后在 dialplan 里给这些分机配好路由呼出的时候走网关呼入的时候转到对应分机。还有一个容易忽略的点WebRTC 强制要求媒体加密所以 FreeSWITCH 的 profile 里要开rtp-secure-media并且证书要配好。如果是自签名证书浏览器那边会拦开发阶段可以用wss加自签但生产环境必须用受信任的证书。!-- 片段sofia profile 中 WebRTC 相关配置 -- param namews-binding value:5066/ param namewss-binding value:7443/ param namertp-secure-media valuemandatory/ param nameapply-candidate-acl valuewan_v4.auto/这段配置的意思是ws 监听 5066wss 监听 7443媒体加密强制开启候选地址 ACL 允许外网地址。apply-candidate-acl这个参数在 NAT 环境下特别关键不配的话经常出现单通或者媒体建立不起来。3. 核心模块拆解从注册到通话的完整链路3.1 初始化与注册UA 和 Registerer 的配合sipjs 的入口是UserAgent它代表一个 SIP 终端。创建 UA 的时候要传入 WebSocket 的地址、认证信息、以及一些媒体配置。// 创建 UserAgent 实例 const ua new SIP.UserAgent({ uri: SIP.UserAgent.makeURI(sip:${username}${domain}), transportOptions: { server: wss://${domain}:7443/ws, // FreeSWITCH 的 wss 地址 }, authorizationUsername: username, authorizationPassword: password, delegate: { onInvite: (invitation) { // 收到呼入时触发后面 3.3 节展开 handleIncomingCall(invitation); }, }, }); // 启动 UA 并注册 await ua.start(); const registerer new SIP.Registerer(ua); await registerer.register();这段代码里transportOptions.server指向 FreeSWITCH 的 WebSocket 地址注意协议是wss不是https。authorizationUsername和authorizationPassword对应 FreeSWITCH 里配的分机账号密码。delegate.onInvite是呼入的入口后面会单独讲。注册成功后registerer会定期刷新注册状态默认过期时间是 600 秒。如果网络断开sipjs 会自动尝试重连但重连后需要重新注册这个逻辑在示例里是通过监听transport的状态变化来处理的。3.2 呼出流程Inviter 的创建与媒体协商呼出用Inviter类传入目标 URI 和媒体选项。// 发起呼叫 const target SIP.UserAgent.makeURI(sip:1002${domain}); const inviter new SIP.Inviter(ua, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false, // 示例只做音频 }, }, }); // 监听呼叫状态 inviter.stateChange.addListener((state) { switch (state) { case SIP.SessionState.Establishing: console.log(正在振铃...); break; case SIP.SessionState.Established: console.log(通话已建立); attachMedia(inviter.sessionDescriptionHandler); break; case SIP.SessionState.Terminated: console.log(通话结束); break; } }); await inviter.invite();sessionDescriptionHandlerOptions.constraints控制媒体类型这里只开音频。inviter.invite()发出 INVITE 请求FreeSWITCH 收到后根据 dialplan 路由到被叫。媒体建立的关键在attachMedia这个函数里。sipjs 的sessionDescriptionHandler会暴露一个peerConnection对象需要把远端媒体流挂到 audio 标签上。function attachMedia(sdh) { const pc sdh.peerConnection; const remoteStream new MediaStream(); pc.getReceivers().forEach((receiver) { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); const audioElement document.getElementById(remoteAudio); audioElement.srcObject remoteStream; audioElement.play(); }这里有个坑getReceivers()拿到的 track 在部分浏览器里需要等ontrack事件触发后才完整。示例里是在Established状态之后才调用attachMedia这时候 track 已经就绪了。3.3 呼入处理Invitation 的接受与拒绝呼入通过delegate.onInvite回调进来参数是一个Invitation对象。function handleIncomingCall(invitation) { // 在界面上显示来电信息 showIncomingUI(invitation.remoteIdentity.uri.toString()); // 用户点击接听 document.getElementById(answerBtn).onclick async () { await invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false }, }, }); attachMedia(invitation.sessionDescriptionHandler); }; // 用户点击拒接 document.getElementById(rejectBtn).onclick () { invitation.reject(); }; }invitation.accept()会发出 200 OK完成 SDP 协商。invitation.reject()发出 486 或 603具体取决于 FreeSWITCH 的配置。呼入场景里来电显示的信息在invitation.remoteIdentity里可以拿到 URI 和 display name。如果 FreeSWITCH 那边配了 caller ID这里就能显示出来。3.4 保持与转移Refer 和 Re-INVITE 的实际用法保持操作在 sipjs 里是通过sessionDescriptionHandler的 hold 相关方法实现的本质上是发一个 re-INVITE把 SDP 里的媒体方向改成sendonly或inactive。// 保持通话 async function holdSession(session) { const sdh session.sessionDescriptionHandler; await sdh.hold(); console.log(通话已保持); } // 恢复通话 async function unholdSession(session) { const sdh session.sessionDescriptionHandler; await sdh.unhold(); console.log(通话已恢复); }转移分两种盲转和 attended transfer。盲转是直接发 REFER让 FreeSWITCH 把被叫转到第三方。// 盲转把当前通话转到 1003 async function blindTransfer(session, targetUri) { const target SIP.UserAgent.makeURI(sip:${targetUri}${domain}); await session.refer(target); console.log(盲转已发起); }Attended transfer 要复杂一些需要先建立一个新通话然后把两个 session 关联起来。示例里只做了盲转因为 attended transfer 在 FreeSWITCH 侧的 dialplan 配置要求更高容易翻车。注意转移功能依赖 FreeSWITCH 的transfer相关 dialplan 配置如果 REFER 发出去没反应先检查 dialplan 里有没有对应的 extension。4. 跑起来之前环境搭建与配置清单4.1 FreeSWITCH 最小配置要让这个示例跑起来FreeSWITCH 这边至少要做三件事配一个支持 WebSocket 的 SIP profile、建两个测试分机、配一条呼出 dialplan。分机配置在directory/default/下每个分机一个 XML 文件。关键字段是id、password、以及user/params里的vm-password。!-- 分机 1001 的配置片段 -- user id1001 params param namepassword value1234/ /params variables variable nameuser_context valuedefault/ /variables /userdialplan 里加一条测试路由让 1001 拨 1002 能通extension namelocal_extension condition fielddestination_number expression^(100[0-9])$ action applicationbridge datauser/$1/ /condition /extension这条 dialplan 的意思是如果被叫号码匹配 1000 到 1009就 bridge 到对应的 user。bridge是 FreeSWITCH 里最常用的应用负责把两条腿接起来。4.2 前端项目的启动方式示例的前端部分是一个静态页面加几个 JS 文件不需要构建工具直接用浏览器打开就行。但因为 WebRTC 要求安全上下文file://协议下麦克风权限拿不到所以要用一个本地 HTTP 服务。常见做法是用npx serve或者python -m http.server起一个静态服务。然后浏览器访问https://localhost:端口或者http://localhost:端口。注意如果是http协议Chrome 对localhost是放行的但其他 IP 不行。前端代码里要改的配置就几个FreeSWITCH 的域名或 IP、WebSocket 端口、分机账号密码。这些在config.js里集中管理。// config.js const CONFIG { domain: 192.168.1.100, // FreeSWITCH 地址 wsPort: 5066, // ws 端口 wssPort: 7443, // wss 端口 username: 1001, password: 1234, };4.3 验证步骤从注册到通话的检查点跑起来之后按这个顺序验证第一步打开浏览器控制台看有没有WebSocket connected的日志。如果没有检查 FreeSWITCH 的 ws-binding 是否监听正确防火墙是否放行。第二步看注册是否成功。sipjs 注册成功后会打印Registered或者类似日志。如果失败看 401 还是 403401 一般是密码错403 可能是 ACL 拦截。第三步拨一个分机号看能不能振铃。如果振铃但接起来没声音多半是媒体协商问题检查 SDP 里的 candidate 地址是不是内网地址。第四步测试保持和转移。保持的时候看 FreeSWITCH 的日志里有没有 re-INVITE转移的时候看 REFER 有没有被正确处理。5. 避坑与排查那些让我加班到凌晨的问题5.1 注册成功但拨号没反应现象浏览器控制台显示注册成功但点拨号按钮后没有任何反应FreeSWITCH 日志里也看不到 INVITE。原因sipjs 的Inviter创建时目标 URI 的 domain 部分和 UA 的 domain 不一致导致 SIP 消息发到了错误的方向。或者invite()方法没有被正确 await。解决检查makeURI里的 domain 是否和 UA 的 domain 完全一致包括端口。另外确认inviter.invite()是在 UA 启动之后调用的。5.2 能振铃但接起来没声音现象主叫能听到回铃音被叫也能振铃但接起来之后双方都听不到声音。原因WebRTC 的 ICE candidate 收集到了内网地址而 FreeSWITCH 在 NAT 后面导致媒体流走不通。这是最典型的单通问题。解决在 FreeSWITCH 的 profile 里配apply-candidate-acl和ext-rtp-ip把公网地址告诉浏览器。如果是在本地测试确认浏览器和 FreeSWITCH 在同一个网段或者用host模式的 candidate。5.3 保持之后恢复不了现象点保持之后通话正常挂起但点恢复时卡住最后超时断开。原因sipjs 的hold()和unhold()在部分版本里对 SDP 的处理有 bug特别是当 FreeSWITCH 返回的 SDP 里媒体方向是inactive而不是sendonly时。解决升级 sipjs 到最新版本或者在unhold之前先检查sessionDescriptionHandler的状态。如果还是不行可以手动构造 re-INVITE把媒体方向改回sendrecv。5.4 转移之后原通话没挂断现象盲转发起后第三方开始振铃但原通话没有自动挂断导致两条腿都占着。原因REFER 发出后FreeSWITCH 需要根据Refer-Sub和NOTIFY来确认转移结果。如果 dialplan 里没有配transfer相关的处理FreeSWITCH 可能不会主动挂断原腿。解决在 dialplan 里加transfer的处理逻辑或者在 sipjs 侧监听refer的结果收到成功 NOTIFY 后手动terminate原 session。5.5 浏览器控制台报证书错误现象用wss连接时浏览器报net::ERR_CERT_AUTHORITY_INVALIDWebSocket 连不上。原因FreeSWITCH 用的是自签名证书浏览器不信任。解决开发阶段可以在浏览器里手动信任自签证书或者用ws代替wss但生产环境必须用wss。生产环境要换成受信任的 CA 证书Lets Encrypt 是常见选择。6. 二次开发从示例到可用的网页呼叫中心这份示例给的是一个最小可跑通的骨架要变成实际可用的东西还有几个方向可以改。第一个方向是加通话状态管理。示例里状态是散落在各个回调里的实际项目里我会用一个状态机来统一管理比如idle、registering、ringing、active、held、transferring。这样 UI 的按钮状态和通话状态能严格对应不会出现“明明在通话中但按钮显示可拨号”这种问题。第二个方向是加通话记录和录音。FreeSWITCH 侧可以通过mod_callcenter或者record_session来做录音前端只需要在通话结束时拉取记录列表。通话记录可以用 FreeSWITCH 的 CDR 模块写到数据库或者文件里前端通过一个简单的 REST 接口去查。第三个方向是加多路通话支持。sipjs 本身支持多个 session 并存但 UI 上要处理切换和保持的逻辑。常见做法是维护一个 session 列表当前活跃的 session 显示在主区域其他的折叠在侧边栏。第四个方向是加视频通话。示例里只开了音频要加视频的话把constraints里的video改成true然后在attachMedia里同时处理 video track。但要注意视频对带宽和 FreeSWITCH 的转码能力要求更高测试的时候先用小分辨率。// 视频通话的 constraints 示例 const constraints { audio: true, video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 }, }, };这里我一般会把分辨率限制在 640x480帧率 15因为网页端视频通话不需要太高清带宽和 CPU 都省。如果 FreeSWITCH 那边开了转码还要注意 codec 的协商顺序VP8 和 H.264 的优先级要在 profile 里配好。第五个方向是加断线重连。sipjs 的 transport 断开后UA 需要重新 start 和 register。示例里没有处理这个实际项目里要监听transport的disconnected事件然后做指数退避重连。重连成功后如果之前有活跃通话要提示用户通话已中断。最后一个方向是加权限控制。示例里分机账号是硬编码在前端的实际项目里肯定不能这么干。常见做法是后端发一个短时效的 token前端拿 token 去 FreeSWITCH 换 SIP 凭证或者用 FreeSWITCH 的mod_xml_curl动态生成分机配置。提示改造的时候建议先把 FreeSWITCH 的日志级别调到debug这样 SIP 消息的每一步都能看到排查问题会快很多。上线前再调回notice或warning。从那以后我每次对接 WebRTC 项目都会先把 FreeSWITCH 的sofia status和sofia profile internal siptrace on打开确认信令链路通了再动前端代码。这个习惯帮我省了很多来回猜的时间。希望帮到你。本文还有配套的精品资源点击获取