☰
JsSIP+FreeSWITCH音视频Demo:WebRTC软电话从注册到通话全链路实战
2026/10/18 4:21:47 网站建设 项目流程

简介:这是一份面向Web音视频通信开发者的JSSIP实战示例包,围绕JSSIP与FreeSWITCH的配合展开,适合想入门或验证SIP通信方案的初中级前端与VoIP开发者。包内共385个文件,以115个html页面、63个js脚本、71个less与34个css样式为主,辅以jpg、png、svg等图片素材及yml、json等配置,压缩包约4.42MB,结构完整便于直接运行调试。示例覆盖SIP注册、音视频呼叫的发起接听挂断、媒体协商与编码选择、INVITE/ACK/BYE等信令流程,并延伸至基于MESSAGE的短信收发、WebSocket长连接及错误处理机制,还涉及与FreeSWITCH自定义API的交互思路。目前已有1440人学习下载,读者可借此理解SIP协议在浏览器中的落地方式,借鉴其信令组织与媒体管理写法,为自建Web通话应用提供可复用的基础代码与排错参考。

1. 从一通打不通的坐席电话说起:这个 JsSIP 音视频 Demo 到底能干什么

很多做呼叫中心或者在线客服系统的开发者都遇到过这种场景:后端 FreeSWITCH 明明跑得好好的,软电话注册也显示成功,但一到浏览器里发起音视频通话,要么是单通、要么是直接无声无画面,控制台里 SIP 信令来回飞,就是拿不到媒体流。这个 JsSIP 音视频 Demo 就是冲着这类问题来的——它把浏览器端 SIP 协议栈、WebRTC 媒体协商、FreeSWITCH 侧的 dialplan 配置串成了一条能跑通的最小链路,适合正在做 WebRTC 软电话、坐席话机、视频客服的开发者直接拿来当起点。项目本身不依赖复杂框架,核心就是 JsSIP 这个库加上一套可复现的 FreeSWITCH 配置,跑起来之后你能看到完整的注册、呼叫、接听、挂断流程,也能顺着代码把 SDP 协商和 ICE 候选交换的细节扒清楚。对于想搞明白“浏览器到底怎么跟 FreeSWITCH 说上话”的人来说,这比啃 RFC 快得多。

2. 技术选型与整体架构:JsSIP 和 FreeSWITCH 是怎么握手的

2.1 为什么是 JsSIP 而不是 SIP.js

浏览器端 SIP 协议栈的选择其实不多,主流就是 JsSIP 和 SIP.js 两个。这个 Demo 选 JsSIP,核心原因是它对 FreeSWITCH 的兼容性在实战里更稳。JsSIP 把 SIP 的注册、会话、事务这些概念封装得比较薄,你能直接拿到RTCSession对象去操作底层 RTCPeerConnection,排查问题时不会被框架藏掉太多细节。SIP.js 的抽象层更厚,写业务快,但一旦媒体协商出问题,日志里能看到的东西反而少。这个 Demo 的定位是教学和排障,所以选了更“透明”的 JsSIP。

从架构上看,整个链路分三层:浏览器里的 JsSIP 负责 SIP 信令和 WebRTC 媒体采集;中间的 WebSocket 通道承载 SIP over WS;FreeSWITCH 作为 SIP 服务器和媒体网关,负责注册鉴权、呼叫路由和媒体转发。FreeSWITCH 这边需要开启ws-binding和wss-binding,并且给坐席分机配好dialplan,让浏览器发起的 INVITE 能正确落到目标分机上。

2.2 目录结构与关键文件

项目结构很扁平,没有用构建工具,直接静态文件加一个本地服务就能跑:

jssip-audio-video-demo/ ├── index.html # 入口页面,包含 UI 和脚本引用 ├── js/ │ ├── app.js # 主逻辑:注册、呼叫、接听、挂断 │ └── config.js # SIP 服务器地址、分机账号等配置 ├── css/ │ └── style.css # 简单的通话界面样式 └── README.md # 运行说明和 FreeSWITCH 配置片段

config.js是你要改的第一个文件,里面集中放了 WebSocket 地址、SIP URI、密码和显示名称。把配置抽出来单独放,是为了避免改一个地址要翻遍整个app.js。app.js里按功能拆了几个函数:initUA负责创建JsSIP.UA实例并绑定事件,makeCall发起呼叫,answerCall接听,hangup挂断。每个函数都不长,读起来不费劲。

2.3 信令与媒体的完整链路

一次成功的呼叫,信令和媒体是两条线并行的。信令线走 WebSocket:浏览器发 REGISTER 到 FreeSWITCH,鉴权通过后发 INVITE,FreeSWITCH 回 100 Trying、180 Ringing、200 OK,浏览器回 ACK,会话建立。媒体线走 WebRTC:双方通过 SDP offer/answer 交换编解码器和 ICE 候选,然后 DTLS 握手,最后 SRTP 加密传输音视频。这个 Demo 里你能在控制台看到 JsSIP 打印的 SIP 消息,也能在RTCPeerConnection的onicecandidate回调里看到候选地址的收集过程。两条线任何一条断了,通话都起不来,所以排查时一定要分开看。

3. 跑起来:从 FreeSWITCH 配置到浏览器发起第一通呼叫

3.1 FreeSWITCH 侧的最小配置

FreeSWITCH 默认的internalprofile 监听 5060,但浏览器走的是 WebSocket,需要额外开ws-binding。在conf/sip_profiles/internal.xml里确认这两行没被注释:

<!-- 监听 WebSocket,端口 5066 --> <param name="ws-binding" value=":5066"/> <!-- 如果走 HTTPS 页面,需要 wss --> <param name="wss-binding" value=":7443"/>

然后给坐席分机加一个用户,在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>

password就是浏览器端config.js里要填的密码,user_context决定这个分机走哪个 dialplan。改完配置后reloadxml再reload mod_sofia,让 FreeSWITCH 重新加载。

3.2 浏览器端配置与注册

config.js里需要填三个关键值:WebSocket 地址、SIP URI 和密码。WebSocket 地址的格式是ws://<FreeSWITCH_IP>:5066,注意这里不要写成http://,JsSIP 只认ws://或wss://。

// config.js const SIP_CONFIG = { // FreeSWITCH 的 WebSocket 地址,端口对应 ws-binding socket: 'ws://192.168.1.100:5066', // SIP URI,格式 sip:分机号@服务器地址 uri: 'sip:1001@192.168.1.100', // 分机密码,和 directory 里配的一致 password: '1234', // 显示名称,会出现在对方来电显示上 displayName: '坐席1001' };

注册逻辑在app.js的initUA里:

function initUA() { const socket = new JsSIP.WebSocketInterface(SIP_CONFIG.socket); const ua = new JsSIP.UA({ sockets: [socket], uri: SIP_CONFIG.uri, password: SIP_CONFIG.password, display_name: SIP_CONFIG.displayName }); // 注册成功回调,此时可以发起呼叫 ua.on('registered', () => { console.log('注册成功,可以呼叫了'); }); // 注册失败,重点看 reason 字段 ua.on('registrationFailed', (e) => { console.error('注册失败:', e.reason); }); ua.start(); return ua; }

JsSIP.WebSocketInterface负责维护 WebSocket 连接,断线会自动重连。ua.start()之后会先发 REGISTER,收到 200 OK 后触发registered事件。如果卡在registrationFailed,先看e.reason,常见的是密码错、分机不存在或者 FreeSWITCH 的ws-binding没生效。

3.3 发起呼叫与接听

呼叫的核心是ua.call(),传入目标 SIP URI 和媒体参数:

function makeCall(ua, targetUri) { const session = ua.call(targetUri, { mediaConstraints: { audio: true, video: true }, pcConfig: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ] } }); // 对方接听后触发 session.on('accepted', () => { console.log('通话已建立'); // 把远端媒体流挂到 video 标签上 const remoteStream = new MediaStream(); session.connection.getReceivers().forEach((receiver) => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); document.getElementById('remoteVideo').srcObject = remoteStream; }); // 呼叫失败,看 cause session.on('failed', (e) => { console.error('呼叫失败:', e.cause); }); return session; }

mediaConstraints里同时开 audio 和 video,如果只想语音就把video设成false。pcConfig里的 STUN 服务器用于收集公网候选地址,局域网内其实可以不加,但加上更保险。accepted事件触发时,session.connection就是底层的RTCPeerConnection,从getReceivers()里拿远端轨道是最直接的方式。接听方则是在newRTCSession事件里调session.answer(),参数和呼叫时类似。

4. 核心模块拆解:注册、会话与媒体协商的代码细节

4.1 UA 实例的生命周期管理

JsSIP.UA是整个应用的根对象,它的状态机决定了你能做什么操作。UA 有stopped、started、registered几个关键状态,只有在registered状态下才能发起呼叫。很多新手会犯一个错:页面加载完立刻调makeCall,结果报InvalidStateError,就是因为 UA 还没注册完。正确的做法是把呼叫按钮的disabled属性绑到registered事件上,注册成功再放开。

UA 还负责处理重连。WebSocket 断开后,JsSIP 会自动尝试重连,重连成功会重新注册。你可以在ua.on('disconnected')里更新 UI 提示,在ua.on('registered')里恢复。注意不要自己手动去 new 一个新的 UA,那样会创建多条 WebSocket 连接,FreeSWITCH 那边会看到重复注册。

4.2 RTCSession 的事件与状态流转

RTCSession代表一通具体的通话,它的状态从null到invite_sent、1xx、2xx、confirmed,最后到ended。每个状态变化都有对应事件,最常用的是progress(收到 180 Ringing)、accepted(收到 200 OK)、confirmed(ACK 发出)、ended(通话结束)。排查单通问题时,重点看accepted之后connection.iceConnectionState的变化,如果一直是checking然后变failed,说明 ICE 候选没配对成功,通常是 NAT 环境没配 TURN 服务器。

session.connection是 JsSIP 暴露出来的RTCPeerConnection,你可以直接给它加oniceconnectionstatechange监听:

session.connection.oniceconnectionstatechange = () => { console.log('ICE 状态:', session.connection.iceConnectionState); if (session.connection.iceConnectionState === 'failed') { // ICE 失败,检查 STUN/TURN 配置和防火墙 console.warn('ICE 协商失败,检查网络环境'); } };

这个日志在排查媒体不通时非常有用,能帮你快速区分是信令问题还是媒体问题。

4.3 SDP 协商中的编解码器匹配

FreeSWITCH 默认支持的编解码器和浏览器不一定完全一致。浏览器一般优先用 Opus 传音频、VP8 传视频,FreeSWITCH 这边需要确认mod_opus和mod_vp8已经加载。在 FreeSWITCH 控制台执行show codecs能看到当前支持的编解码器列表。如果 SDP 协商后双方没有共同编解码器,通话会建立但媒体流是空的,现象就是“接通了但没声音”。

你可以在app.js里打印本地 SDP 和远端 SDP 来对比:

session.on('accepted', () => { // 打印远端 SDP,看编解码器协商结果 console.log('远端 SDP:', session.remote_description.sdp); });

在远端 SDP 里找m=audio和m=video行,后面的数字就是选中的 payload type,对照a=rtpmap就能知道用了哪个编解码器。如果发现选中的是PCMU而不是Opus,说明 Opus 没协商上,需要检查 FreeSWITCH 的mod_opus是否加载。

5. 避坑与排查:那些让通话起不来的常见问题

5.1 注册成功但呼叫直接失败

现象是控制台显示registered,但点呼叫后立刻触发failed,cause是Not Found或Service Unavailable。原因通常是 FreeSWITCH 的 dialplan 没有匹配到目标分机。浏览器发的 INVITE 里 Request-URI 是sip:1002@服务器地址,FreeSWITCH 需要在defaultcontext 里找到对应的 extension。解决方法是检查conf/dialplan/default.xml里有没有类似<action application="bridge" data="user/1002"/>的规则,或者直接用user/1002这种格式让 FreeSWITCH 走用户目录查找。

5.2 接通后单通或无声

现象是双方都显示通话中,但只有一方能听到声音。原因多半是 NAT 环境下 SDP 里的 IP 地址是内网地址,对方拿不到正确的媒体地址。解决方法是给 FreeSWITCH 的internalprofile 配ext-sip-ip和ext-rtp-ip,让 SDP 里带公网地址。如果浏览器和 FreeSWITCH 不在同一网段,还需要在pcConfig里加 TURN 服务器,TURN 会中继媒体流,保证双方都能收到包。

5.3 视频黑屏但音频正常

现象是能听到声音,但视频区域一直是黑的。原因通常是视频编解码器没协商上,或者mediaConstraints里video没开。先确认makeCall时video: true,再看远端 SDP 里有没有m=video行。如果没有,说明 FreeSWITCH 那边没同意视频,检查mod_vp8是否加载,以及 dialplan 里有没有限制video_codec。另外,video标签的autoplay属性要加上,否则浏览器会拦截自动播放。

5.4 WebSocket 连接被浏览器拦截

现象是页面加载后控制台报Mixed Content错误,WebSocket 连不上。原因是页面用 HTTPS 打开,但 WebSocket 地址写的是ws://。浏览器不允许 HTTPS 页面连非加密的 WebSocket。解决方法是把 FreeSWITCH 的wss-binding开起来,config.js里的地址改成wss://,并且证书要能被浏览器信任。本地开发可以用自签证书,但需要在浏览器里手动信任一次。

5.5 挂断后资源没释放

现象是反复呼叫几次后,页面卡顿或者麦克风指示灯一直亮着。原因是RTCSession结束后没有释放媒体流。在ended事件里要手动停掉本地流:

session.on('ended', () => { // 停止本地媒体采集,释放摄像头和麦克风 if (session.connection) { session.connection.getSenders().forEach((sender) => { if (sender.track) { sender.track.stop(); } }); } });

track.stop()会关闭摄像头和麦克风,不调的话浏览器会一直占用设备。

6. 二次开发:把这套 Demo 改成你自己的坐席系统

6.1 从 Demo 到生产环境要补的东西

这个 Demo 是最小可运行版本,直接上生产还缺几块。第一是鉴权,Demo 里密码明文写在config.js,生产环境应该由后端下发临时凭证,或者用 token 鉴权。第二是断线重连的 UI 反馈,WebSocket 断开时用户要知道当前状态,不能傻等。第三是通话质量监控,可以通过session.connection.getStats()拿到 RTT、丢包率这些指标,上报到监控系统。第四是多分机管理,一个坐席可能同时登录多个分机,需要把 UA 实例和分机号做映射。

6.2 改造点:接入后端 API 动态获取配置

把config.js里的硬编码改成从后端拉取,是二次开发的第一步。可以加一个fetchConfig函数:

async function fetchConfig(agentId) { // 从后端获取该坐席的 SIP 配置,避免密码硬编码 const res = await fetch(`/api/sip-config?agentId=${agentId}`); const data = await res.json(); return { socket: data.wsUrl, uri: `sip:${data.extension}@${data.server}`, password: data.password, displayName: data.displayName }; }

这样每个坐席登录后拿到自己的配置,密码不落前端代码,也方便后端统一管理分机。改造时注意fetchConfig要在initUA之前调,拿到配置再创建 UA 实例。

6.3 改造点:增加通话记录与状态同步

生产系统需要记录每通电话的起止时间和结果。可以在RTCSession的事件里埋点:

const callRecord = { direction: 'outbound', startTime: null, endTime: null, status: 'initiated' }; session.on('accepted', () => { callRecord.startTime = Date.now(); callRecord.status = 'answered'; }); session.on('ended', (e) => { callRecord.endTime = Date.now(); callRecord.status = e.cause === 'Canceled' ? 'canceled' : 'completed'; // 上报到后端 reportCallRecord(callRecord); });

e.cause能区分是正常挂断还是对方拒接、忙线,上报时带上这些字段,后端就能生成完整的通话报表。注意ended事件里不要做耗时操作,上报用异步请求,避免阻塞 UI。

6.4 一个我踩过的坑:ICE 重启的时机

有次在弱网环境测试,通话中途网络切换,ICE 连接断了但 SIP 会话还在,双方都以为通话没断。后来在iceconnectionstatechange里加了判断,当状态变成disconnected时主动调session.connection.restartIce(),触发 ICE 重启重新收集候选。但重启不能太频繁,我一般加个 3 秒的防抖,避免网络抖动时反复重启把会话搞崩。从那以后我每次做 WebRTC 项目,都会在iceconnectionstatechange里强制走一遍状态判断和重启逻辑,宁可多写几行,也别让用户遇到“假通话”。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询