资讯动态

WebRTC网页电话开发:sipjs与FreeSWITCH集成指南

发布时间:2026/10/16 12:47:20 来源:尧图企业网站定制
简介这是一套基于SIP.js、FreeSWITCH与WebRTC的网页端电话应用示例覆盖浏览器电话呼入、呼出、转移与保持等核心操作。主要面向需要快速验证SIP通信场景的WebRTC开发者也适合正在调研FreeSWITCH集成方案的前端工程师。压缩包共4个文件整体仅79KB一个HTML页面负责呼叫控制界面与按钮交互一个CSS文件定义界面样式一个JS核心文件提供SIP协议注册与呼叫控制逻辑另有一个文本说明文件标注分机号、密码及服务器地址的修改位置和测试注意事项。使用者无需搭建复杂服务在谷歌浏览器中打开页面将代码中的分机号、密码和服务器地址替换为自己的FreeSWITCH环境参数即可直接测试呼入、呼出、转移与保持功能快速验证整个电话业务流程。目前已有2315人学习使用说明该示例对同类场景有较高参考价值借助SIP.js官方文档对照这份示例还能理解SIP注册流程、呼叫控制信令以及WebRTC媒体协商在真实项目中的配合方式从而降低自研网页电话功能的入门门槛。1. 网页电话应用不装客户端一个浏览器标签页完成呼叫全流程先抛一个反直觉的结论sipjsFreeSWITCHWebRTC这套组合做出来的网页电话其实不需要你理解SIP报文长什么样。你只需要在浏览器里点一个按钮它就能呼出、呼入、保持、转移媒体流走WebRTC信令走WebSocket。它解决的问题很具体企业通讯录点击呼叫、呼叫中心坐席工作台、网页在线客服一键升级语音通话——这些场景要的不是再装一个软电话而是把电话能力嵌进网页业务系统里。这份示例工程就是一套能直接跑起来的蓝图适合准备用WebRTC做通话业务的开发者也适合那些想搞清楚SIP状态机却不想从RFC文档入手的后端工程师。2. 技术选型与三层架构sipjs、FreeSWITCH、WebRTC各自负责什么2.1 三层分工浏览器只做信令翻译媒体面全部交给WebRTC这个示例的架构可以用一句话概括浏览器里的sipjs负责把用户操作翻译成SIP消息FreeSWITCH负责信令路由和媒体转发WebRTC负责浏览器与服务器之间的实时音视频通道。三者缺一不可但也别让它们互相越权。先看浏览器这一层。sipjs本质上是一个运行在浏览器里的SIP协议栈它把REGISTER、INVITE、BYE、REFER这些SIP方法封装成了JavaScript API。你调用ua.call()它帮你构造并发送INVITE你调用session.hold()它帮你构造并发送re-INVITE。这里有个关键点sipjs只是信令层真正的音视频数据不经过它而是直接通过RTCPeerConnection在浏览器和FreeSWITCH之间传输。很多新手误以为sipjs把音频也封装了结果调试时去抓WebSocket里的二进制数据自然什么都抓不到。FreeSWITCH这一层承担三个角色。第一是注册服务器分机的认证、在线状态管理都在它这里完成第二是信令路由器决定一个INVITE该桥接到哪个分机、哪个网关第三是媒体转发者浏览器发过来的SRTP包经过它转成标准RTP再送给对端。示例里用的是mod_sofia处理SIP信令媒体面则由内置的RTP引擎完成。为什么不建议绕过FreeSWITCH直接在两个浏览器之间点对点通信因为纯点对点意味着你要自己搞定NAT穿透、终端离线、多方通话、录音、话单统计这些问题。FreeSWITCH站在中间这些事它都做了。你的网页客户端只需要管好当前这一个通话会话这个边界划分是架构上最划算的设计。2.2 版本匹配sipjs版本、FreeSWITCH版本、浏览器限制跑这个示例之前版本匹配是第一个容易翻车的地方。sipjs的API在0.7.x到0.20.x之间有过一次大裂变0.7时代用ua.register()注册之后改成实例化时传register: true会话获取远端媒体流的方式也从session.connection变成了session.sessionDescriptionHandler.peerConnection。这份示例基于0.13.x以后的API编写如果你之前看过老教程记得把session.connection这类写法全部忘掉。组件推荐版本说明FreeSWITCH1.10.x稳定mod_sofia的WebSocket支持完整sip.js0.13.xSessionManager/UA两种风格API稳定浏览器Chromium内核较新版本WebRTC一整套支持完整Node.js14仅用于本地起静态资源服务不影响信令端口分配是个容易忽略的细节。FreeSWITCH默认的SIP端口5060走UDP浏览器里的WebSocket协议没法直接连它。示例里需要在sofia profile里单独开启一个WebSocket监听端口常见做法是7443同时承载WSS。如果你在本地用HTTP调试页面WebSocket也要用ws://而不是wss://不然会碰到混合内容拦截。一个快速验证环境是否就绪的命令是检查端口监听状态# 列出当前监听的端口确认7443处于LISTEN状态 netstat -an | grep 7443 # 进入FreeSWITCH控制台查看sofia profile状态 fs_cli -x sofia status如果7443没在监听多半是sofia profile没重新加载执行reload mod_sofia再看一次。如果sofia status里看不到你的profile检查autoload_configs目录下的sofia.conf.xml是否把这个profile的enabled设成了true。2.3 拿到示例后先看这三个文件示例工程的目录结构不复杂但有几个文件你必须优先搞清楚。我一般拿到一个工程先花十分钟做一次定向扫描找出入口文件、配置文件和核心业务文件其他先不看。webrtc-phone/ ├── client/ │ ├── index.html # 页面骨架拨号盘、通话状态栏、保持/转移按钮 │ ├── phone.js # sip.js封装UA初始化、注册、呼叫、挂断 │ ├── transfer.js # 转移逻辑盲转与咨询转两条路径 │ ├── hold.js # 保持逻辑本地保持与远端保持 │ └── config.js # WS地址、分机号、密码、STUN配置 ├── server/ │ ├── freeswitch/ │ │ ├── dialplan/ │ │ │ └── webrtc.xml # 呼入呼出的路由规则 │ │ ├── directory/ │ │ │ └── users.xml # 分机账号配置 │ │ └── sofia.conf # WebSocket profile配置 │ └── entry.js # 静态资源服务本地调试用 └── README.mdconfig.js是第一个要看的因为所有连接参数都集中在这里改错一个就全线不通。phone.js是第二个要看的它包含了整个UA生命周期的管理逻辑。第三个是server/freeswitch/下的三份配置它们决定了FreeSWITCH侧的分机认证、路由规则和WebSocket监听方式。理解了这三块后面跑起来遇到问题你知道去哪查。FreeSWITCH的分机配置有个容易忽略的坑users.xml里的分机ID和密码必须和config.js里的一致。很多人在sip.js里改了一个分机号忘了同步改FreeSWITCH的用户目录结果REGISTER一直返回401。这不是代码问题是配置漂移。3. 呼入呼出核心链路从UA注册到RTP接通的完整事件流3.1 注册链路UA初始化与REGISTER事务sipjs的注册过程实质上是两件事建立WebSocket连接然后在这个连接上发送REGISTER请求。示例代码里config.js集中管理连接参数// config.js export const sipConfig { // WebSocket地址必须是ws://或wss://不能填UDP的5060端口 wsURI: ws://your-freeswitch-server:7443, // 分机号对应FreeSWITCH的directory/users.xml里的id user: 1001, // 分机密码sofia认证时校验 password: 123456, // STUN地址用于浏览器端NAT穿透 stunURI: stun:public-stun-server, // 默认呼叫目标 destination: 1002 };参数说明wsURI是FreeSWITCH里mod_sofia监听的WebSocket端口通常在sofia profile里通过param namews-binding value:7443/开启。user和password必须和FreeSWITCH用户目录一致否则REGISTER会返回401或403。stunURI在局域网调试时可以留空但要部署到公网环境这个字段必须有值否则跨网段的媒体流大概率建立不起来。UA初始化和注册的核心逻辑在phone.js里import { UA } from sip.js; const ua new UA({ // 用户标识格式是 sip:分机号服务器域名 uri: sip:${sipConfig.user}${sipConfig.domain}, // WebSocket传输层配置 transportOptions: { wsServers: [sipConfig.wsURI], // 连接断开后会自动重连避免FreeSWITCH重启导致页面永久掉线 connectionTimeout: 10 }, // 认证用的分机号与密码 authorizationUsername: sipConfig.user, authorizationPassword: sipConfig.password, // 实例化后立即发起REGISTER register: true }); ua.on(registered, () { // 注册成功事件UI在这里把状态改成在线 console.log(分机上线); updateUI(online); }); ua.on(registrationFailed, (cause) { // 注册失败事件cause里带原因码 console.error(注册失败原因码:, cause.cause); updateUI(offline); });这段逻辑说明register: true让UA在实例化后自动发起REGISTER事务不需要手动调用ua.register()。有一点必须注意registrationFailed不是一次性事件注册过期刷新失败同样会触发它。FreeSWITCH默认的注册过期时间是3600秒到期前自动刷新如果刷新时WebSocket恰好断了你会看到这个事件再次触发。所以UI状态不能只在页面加载时处理一次要把它当成持续的心跳信号。3.2 呼出从点击拨号到INVITE再到RTP接通呼出流程在sipjs里面调用链很短ua.call()创建一个会话然后监听会话事件。示例里呼出的核心代码在phone.js中function makeCall(destination) { // 构造被叫URI分机号拼上服务器域名 const target sip:${destination}${sipConfig.domain}; // 创建会话并发出INVITE请求 const session ua.call(target, { media: { constraints: { audio: true, video: false } }, sessionDescriptionHandlerOptions: { // 覆盖默认的ICE配置不写这里就去不到STUN peerConfiguration: { iceServers: [ { urls: sipConfig.stunURI } ] } } }); // 远端返回200 OK后触发accepted此时媒体流已开始传输 session.on(accepted, () { updateCallStatus(connected, session); }); // 4xx/5xx响应或超时都会触发failed session.on(failed, (cause) { console.error(呼叫失败:, cause.cause); updateCallStatus(failed); }); // 对端挂断或BYE到达后触发terminated session.on(terminated, () { updateCallStatus(idle); }); }参数说明media.constraints直接对应WebRTC的getUserMedia约束这里只采集音频。peerConfiguration.iceServers如果不配置或配了不可达的STUN地址浏览器会退化为host候选局域网内能通跨网段必然失败。这个问题的典型表现是呼叫一直停在Ringing状态控制台里能看到ICE gathering长时间不完成。呼出之后FreeSWITCH侧通过dialplan决定把呼叫转给谁。示例的webrtc.xml里核心规则是这样写的extension namewebrtc-dial condition fielddestination_number expression^(\d{4})$ action applicationbridge datauser/${destination_number}${domain}/ action applicationanswer/ /condition /extension这段dialplan的逻辑任何四位数的被叫号码FreeSWITCH直接桥接到对应分机。这里有个容易被忽视的顺序问题先bridge再answer媒体面会直接对接反过来先answer再bridgeFreeSWITCH会先成为媒体端点多一次媒体转发虽然功能正常但会增加延迟。示例里的写法是刻意保持的。3.3 呼入invite事件驱动与浏览器振铃呼入的代码逻辑与呼出是对称的只是在UA层面多了一个invite事件。示例里监听这个事件后做了三件事判断当前是否有活动通话、触发振铃、绑定会话事件ua.on(invite, (session) { // 如果当前已经有活动通话直接拒绝新的呼入 if (currentSession currentSession.isAlive()) { session.reject(); return; } currentSession session; // 触发浏览器振铃。这里必须用AudioContext // 直接new Audio()会被浏览器的自动播放策略拦掉 playRingtone(); bindSessionEvents(session); }); function acceptCall() { if (!currentSession) return; stopRingtone(); currentSession.accept({ media: { constraints: { audio: true, video: false } } }); }逻辑说明session.accept()必须等用户点了接听按钮后再调用不能在invite事件回调里直接accept否则就变成自动应答了。session.reject()对应SIP层的486 Busy对端会听到忙音。这里的playRingtone()用Web Audio API生成一段提示音而不是播放音频文件原因是浏览器自动播放策略会拦截没有用户手势的音频播放但你用AudioContext在事件回调里创建振荡器反而能绕开这个限制。呼入场景下还有一个时序需要注意invite事件到达时浏览器还远没到媒体协商阶段此时sip.js内部已经开始准备SDP应答但你的页面不应该立刻操作sessionDescriptionHandler。如果在这个阶段强行调用session.hold()之类的方法会得到InvalidStateError因为会话还没进入Accepted状态。3.4 会话清理挂断之后必须做的事挂断电话不是发一个BYE就完了。示例里专门写了一个cleanupSession()函数把资源释放收口这是从一次血泪经验里提炼出来的function cleanupSession(session) { // 如果会话还活着先发BYE if (session session.isAlive()) { session.bye(); } // 停掉所有本地轨道释放麦克风设备 const pc session?.sessionDescriptionHandler?.peerConnection; if (pc) { pc.getLocalStreams()?.forEach(stream { stream.getTracks().forEach(track track.stop()); }); pc.close(); } // 重置全局状态变量 currentSession null; callState CALL_STATE.IDLE; updateUI(idle); }为什么必须做这一步因为浏览器标签页不主动关掉PeerConnection的话麦克风指示灯会一直亮着设备一直被占用。很多示例代码只监听terminated事件就完事结果用户挂断电话后发现麦克风权限还在这在真实场景里是严重的隐私问题。terminated事件触发只代表SIP会话结束不代表媒体层资源都释放了。4. 呼叫保持与转移re-INVITE、REFER与状态机设计4.1 保持re-INVITE与本地媒体流处理呼叫保持这个操作SIP底层的本质是发送一个re-INVITE把SDP的媒体方向改成sendonly。对端收到这个SDP后知道你要保持就不再向你发送RTP媒体流。sipjs把这一串逻辑封装成了session.hold()和session.unhold()但UI层的工作它不管。示例里hold.js的核心逻辑是这样let isHeld false; let localStream null; async function toggleHold(session) { if (!isHeld) { // 发送re-INVITESDP方向变为sendonly await session.hold(); // 停掉本地音频轨道彻底切断环境音采集 localStream session.sessionDescriptionHandler.peerConnection .getLocalStreams()[0]; if (localStream) { localStream.getTracks().forEach(track track.enabled false); } isHeld true; } else { // 恢复重新启用轨道并发送recvonly/sendrecv await session.unhold(); if (localStream) { localStream.getTracks().forEach(track track.enabled true); } isHeld false; } }逻辑说明session.hold()发送re-INVITE对端知道你要保持停止发送RTP。但浏览器本地的麦克风采集不会自动停所以还要手动把track的enabled设为false否则保持状态下对方虽然听不到你但浏览器CPU还在持续做音频编码设备指示灯也亮着。恢复时先把track重新启用再调用unhold()顺序不能反。4.2 转移盲转与咨询转的两条路径转移是SIP里最有技术含量的一块因为它在同一时刻发起两个独立事务给被转移方发REFER给转移目标发INVITE。示例代码里同时实现了两种转移方式它们的API调用路径完全不同。转移类型触发动作涉及会话用户体验盲转A点转移输入B分机号直接转只有一个A-B会话转完即挂断不确认B是否接听咨询转先呼叫BB接听后保持A再点完成转移两个会话A-B和A-C转接前可以先和B沟通更稳妥盲转的代码很短但细节多function blindTransfer(session, target) { session.refer({ extraHeaders: [ // Refer-To头必须写完整URI不能只写分机号 Refer-To: sip:${target}${sipConfig.domain} ] }); // 盲转后当前会话进入pending状态需要主动挂断 setTimeout(() { if (session.isAlive()) { session.bye(); } }, 500); }逻辑说明session.refer()发送REFER请求对端收到后会自行向目标发起呼叫。这里的500毫秒延时是故意加的目的是给REFER事务留出完成时间避免立即BYE导致对端来不及处理。Refer-To头如果只写分机号不写域名某些UA实现会解析失败表现为转移后对方听到忙音。咨询转则分为三步示例代码用状态变量管理这个流程function attendedTransferStart(session, target) { // 第一步呼叫B建立第二路会话 const newSession ua.call(sip:${target}${sipConfig.domain}); newSession.on(accepted, () { // 第二步保持当前通话 session.hold(); pendingTransfer newSession; updateUI(已接通B确认后点完成转移); }); } function attendedTransferComplete(currentSession) { if (!pendingTransfer) return; const remoteIdentity pendingTransfer.remoteIdentity.uri.toString(); // 第三步用REFER把当前通话指向B并且用Replaces头关闭B侧的原通话 currentSession.refer({ extraHeaders: [ Refer-To: sip:${remoteIdentity}, Replaces: ${pendingTransfer.remoteIdentity.uri.toString()} ] }); }参数说明Replaces头是咨询转成功的关键它让远端B把和新会话的同名会话释放掉避免转移后出现一个幽灵通话分机占线。如果不带这个头B的旧通道可能一直挂着下次呼叫B会提示占线。示例里用pendingTransfer保存中间态防止用户在已保持A但还没接通B时点完成转移按钮导致Replaces匹配不到对应会话。4.3 状态机管理把所有信令事件收敛到UI可渲染的状态集合整个示例里最有设计价值的部分不是某个具体功能而是用状态机把SIP事件风暴收敛成了一个UI可渲染的有限集合。SIP的事件是异步且乱序的可能出现保持状态下收到转移完成、振铃中收到hold请求这类边界时序如果UI直接绑定在session事件上按钮状态会错乱。const CALL_STATE { IDLE: idle, RINGING: ringing, ACTIVE: active, HELD: held, TRANSFERRING: transferring, TERMINATED: terminated }; let callState CALL_STATE.IDLE; function handleSessionEvent(event, session) { switch (callState) { case CALL_STATE.IDLE: if (event invite) { callState CALL_STATE.RINGING; } break; case CALL_STATE.RINGING: if (event accepted) { callState CALL_STATE.ACTIVE; } else if (event failed) { callState CALL_STATE.IDLE; } break; case CALL_STATE.ACTIVE: if (event hold) { callState CALL_STATE.HELD; } else if (event terminated) { callState CALL_STATE.IDLE; } break; default: break; } renderUI(callState); }这段设计的意义在于事件是流式的但状态是快照式的。任何时刻页面上所有按钮的可用性都由callState一个变量决定。比如HOLD状态下要让保持按钮变成恢复TRANSFERRING状态下要让拨号盘整体禁用——这些UI逻辑全部收敛到renderUI(callState)一个函数里。加了新功能时你只需要新增一个状态和几条转换边不会改动其他逻辑的入口。5. 避坑指南跑通示例前必须提前知道的六个坑5.1 现象浏览器报WebSocket连接失败FreeSWITCH控制台看不到任何连接记录原因FreeSWITCH的mod_sofia没有开启WebSocket监听默认的sofia profile只监听UDP 5060不配ws-binding参数浏览器根本连不上。解决在sofia profile的配置里加上WebSocket监听参数然后重新加载模块param namews-binding value:7443/ param namewss-binding value:7443/执行reload mod_sofia后用netstat -an | grep 7443确认端口处于监听状态。如果只想跑本地调试用ws://就够了同时要注意页面本身是HTTP协议时不能连WSS浏览器会拦截混合内容。5.2 现象REGISTER返回403 Forbidden原因分机号存在但认证方式不对。FreeSWITCH的sofia对一个来源IP有三种处理允许注册、拒绝注册、按密码认证。很多默认配置把WebSocket的来源IP当成了不可信或白名单外的地址。解决在dialplan或ACL里把WebSocket来源网段加入信任列表但更推荐的做法是显式给分机配置密码认证。在directory/users.xml里给分机加上param nameauth-method valuepassword/确保浏览器端每一次REGISTER都用分机密码去换token而不是依赖来源IP白名单。5.3 现象能注册但呼叫时立刻返回488 Not Acceptable Here原因SDP协商失败。sipjs发出的SDP里没有FreeSWITCH能接受的编解码或者ICE候选全部是host类型无法穿越。解决先确认media.constraints里audio: true没有丢再看FreeSWITCH的sip_profiles里是否加载了OPUS编解码没有mod_opus时会话两端的SDP格式没法对齐。常见做法是在profile里加一行param nameinbound-codec-prefs valuePCMU,PCMA,OPUS/这行配置指定了入呼叫可以使用的编解码优先级能解决绝大多数488。5.4 现象通话显示接通但只有单向音频原因媒体路径被NAT阻断。FreeSWITCH向浏览器转发RTP时找不到正确地址或者浏览器返回的RTP到达了错误的端口。解决先判断是哪个方向没有声音。浏览器听不到对端说明FreeSWITCH到浏览器的RTP方向断了对端听不到浏览器说明浏览器RTP没到FreeSWITCH或没被转发。用fs_cli进入控制台呼叫过程中执行rtp print能看到RTP流的状态。实际处理时给浏览器配好STUN地址是最常见的解法如果STUN解决不了就上TURNTURN能保证极端NAT环境下媒体流双向可达。5.5 现象转移后原通话虽然挂了但FreeSWITCH里的通道还残留原因盲转时REFER发送成功但Refer-To头格式不完整导致对端解析失败或者FreeSWITCH侧ACL禁止了REFER。解决检查Refer-To头是否写了完整URIRefer-To: sip:1002your-domain不能写成只有分机号的形式。另外在FreeSWITCH的sofia profile里检查disable-transfer参数有没有被设成true这个参数一旦开启所有REFER请求都会被拒绝。我排查时经常在A端发起转移后立即去看fs_cli -x show channels如果看到两个通道还保持着就说明REFER事务没完成问题大概率出在头格式上。5.6 现象挂断电话后浏览器麦克风指示灯还亮着原因SIP会话结束了但媒体层的RTCPeerConnection没有被释放浏览器继续占用麦克风设备。解决在terminated事件回调里调用cleanupSession()把本地轨道的track.stop()和pc.close()都执行一遍。注意要先停track再关PeerConnection顺序反了可能导致资源释放不干净。6. 二次开发从演示Demo到生产环境的四个改造点6.1 改造一把硬编码配置改成后端动态下发config.js里是写死的分机号和密码生产环境不可能这样。我一般会加一个初始化接口页面加载时先向后端请求当前坐席的通话配置再创建UA。这样分机轮换、坐席禁用都能在服务端控制不用改前端代码。async function initPhone() { const resp await fetch(/api/agent/sip-config, { credentials: include }); const { wsURI, extension, password, stun, domain } await resp.json(); createUA({ wsURI, extension, password, stun, domain }); }6.2 改造二增加通话时长统计与CDR上报生产环境必须有话单。最简单的实现是在accepted事件里用performance.now()记录开始时间在terminated里计算通话时长并以fetch方式上报后端。注意避免用Date.now()做差值系统时间校准会带来负值偏差。6.3 改造三保持音从浏览器端移到服务器端示例里的保持是纯SIP re-INVITE对端听到的是静音。真实业务通常需要等待音乐。在FreeSWITCH侧可以这样做决定保持时浏览器只发一个自定义HTTP信令给后端由后端在FreeSWITCH里对该通道执行播放指令媒体流由FreeSWITCH直供被保持方浏览器端只维持SIP会话不动。这样改的好处是浏览器更轻保持音乐不依赖页面存活。6.4 改造四用WebRTC Internals做通话质量自检每次改完功能我会在浏览器打开chrome://webrtc-internals跑一遍完整呼叫流程重点看三个指标ICE候选是否成功配对、实时RTT是否稳定、收发包是否有持续增长。这三个指标能覆盖掉信令层之外的大部分媒体问题。从那以后我每次改造SIP相关功能都强制走一遍这个流程先看信令再看媒体最后看状态机。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑