简介:jssip音视频demo是一份演示如何利用JSSIP库与FreeSWITCH服务器完成SIP注册、音视频通话及短信收发的Web前端示例工程,适合VoIP开发入门者、前端工程师及对WebRTC/SIP感兴趣的学习者。压缩包共385个文件,大小4.42MB,以html、js、css、less前端代码为主,另含json配置、md说明与图片字体等静态资源,整体呈现完整的前端工程目录结构。已有1438人学习浏览,demo覆盖SIP注册流程、音视频呼叫建立与挂断、媒体协商、INVITE/ACK/BYE等信令交互、WebSocket持久连接、错误处理及FreeSWITCH API对接等关键技术点。对初学者而言,可借此直观理解SIP协议在Web浏览器环境中的落地方式;对有经验的开发者,也可参考其项目组织模式与合理的错误处理机制,为自建Web音视频通信应用提供可复用的基础代码。
1. JSSIP音视频Demo:把浏览器变成SIP话机的最小实现
JsSIP这个库看着挺简单:npm install一下,new一个UA,调一下call,好像就能通话了。但真把它接到FreeSWITCH上做一套内网音视频通话Demo,整套链路里最容易出问题的根本不是JsSIP,而是FreeSWITCH的模块加载、拨号计划、TLS证书和SDP编解码协商。这套Demo提供了一条能直接跑通的完整参考链路:JsSIP负责浏览器侧的信令与媒体采集,FreeSWITCH作为软交换核心完成分机注册、路由和媒体转发。它适合两类人——想在Web端快速跑通SIP通话的前端开发者,以及想弄明白浏览器和FreeSWITCH之间媒体层到底怎么协商的通信实施工程师。下文按“环境 → 客户端 → 媒体 → 排错 → 封装”的顺序拆开讲,代码都是可直接抄进项目改的。
2. 环境搭建:FreeSWITCH模块、账号与拨号计划的前置配置
2.1 模块加载与WebSocket监听端口
JsSIP不能直接走UDP或TCP的SIP信令,它必须通过WebSocket把SIP消息送给FreeSWITCH。FreeSWITCH侧对应的模块是WebSocket接入模块(不同发行版叫法略有差异,常见为mod_websocket),它负责在WS/WSS端口上收SIP over WebSocket消息,再转交mod_sofia处理。所以在配置分机之前,先确认这两个模块都在运行。
# 进入 fs_cli 后执行 show modules | grep -E 'sofia|websocket' # 如果列表里没有对应模块,手动加载 load module mod_sofia load module mod_websocket逻辑说明:show modules列出当前加载的动态模块,grep 过滤出 sofia 和 websocket 相关条目;load module是 FreeSWITCH 控制台的动态加载指令,适合在不想重启服务时临时启用模块。需要注意的是,动态加载只在当前进程生效,如果希望开机自动加载,必须确认模块配置在modules.conf.xml的启动列表中,否则机器一重启,浏览器侧所有注册请求都会直接失败。
参数说明:WebSocket监听端口在各发行版里有差异,常见的默认值是 5066(WS)和 7443(WSS)。如果改过端口,JsSIP侧构造wss://服务器IP:端口时必须保持一致,端口不一致时浏览器控制台会出现WebSocket connection failed,而且FreeSWITCH日志里看不到任何SIP消息到达。端口是否被防火墙放行也要一并确认,很多新手在这里折腾半天,最后发现是安全组没放行。
2.2 SIP账号:在directory里加一个能注册的分机
FreeSWITCH的用户目录在conf/directory/default.xml。JsSIP向FreeSWITCH注册时,实质就是向sofia profile发起SIP REGISTER,FreeSWITCH拿URI里的用户名去directory里查账号和密码,匹配上才返回200 OK。下面是最小化的分机配置。
<user id="1001"> <params> <param name="password" value="123456"/> <param name="vm-enabled" value="false"/> </params> <variables> <variable name="user_context" value="default"/> </variables> </user>逻辑说明:id="1001"是分机号,也是SIP URI里的user部分;password是注册密码,JsSIP的UA配置里必须和它一致;user_context指定了这个分机默认走哪个拨号计划上下文,一般保持default即可。配置完成后在控制台执行reloadxml生效,不用重启FreeSWITCH。
参数说明:如果Demo里要同时注册多部分机,就复制多个<user>节点,每个id和password独立设置。实际项目里密码不建议用纯数字,JsSIP的密码参数在Web端是明文可见的,只适合内网测试环境。如果是生产环境,至少要在网关层做TLS和访问控制,不能把密码直接暴露给不可信网络。
2.3 拨号计划:让1001能拨通1002
注册成功后,分机互拨依赖拨号计划做路由。FreeSWITCH收到INVITE时,根据Request-URI里的被叫号码匹配dialplan中对应context下的extension,匹配后执行里面的动作。下面是一个等价于内置示例规则的显式extension,便于看清路由逻辑。
<extension name="jssip-internal"> <condition field="destination_number" expression="^10(\d{2})$"> <action application="bridge" data="user/${destination_number}@${domain_name}"/> </condition> </extension>逻辑说明:condition里的destination_number就是被叫号码,正则^10(\d{2})$匹配1000到1099;匹配成功后就执行bridge,把呼叫桥接到user/1002@domain_name。这里的user/是FreeSWITCH的显式路由语法,它会在用户目录里找到1002这个分机并路由到其注册地址。配置完成后同样执行reloadxml。
参数说明:${destination_number}和${domain_name}都是channel变量,前者是原始被叫号码,后者是当前呼叫的SIP domain。如果JsSIP呼出时用的URI是sip:1002@192.168.1.10,那么domain_name会自动取192.168.1.10,bridge时就能正确找到用户。这里最容易翻车的点是JsSIP里定义的domain和FreeSWITCH配的domain不一致,比如URI写sip:1002@test.local,而FreeSWITCH里sofia profile的domain是192.168.1.10,就会在拨号计划匹配阶段出现意想不到的路由结果。
2.4 WSS证书:浏览器侧绕不过去的门槛
浏览器对非加密WebSocket有严格限制:在https页面或localhost下才允许发起ws://连接,生产环境页面大概率是https,因此FreeSWITCH必须提供wss://。自签名证书在Chrome里会被拦,控制台报错通常是ERR_CERT_AUTHORITY_INVALID。解决思路有两种:内网环境把自签名证书导入系统信任区,或者干脆用内网CA签一张域名证书。如果只是想快速验证Demo,可以把页面也放在localhost下,JsSIP的WebSocket地址用ws://localhost:5066,这样能绕过证书问题,但这也意味着手机等外部设备无法接入——这个取舍要提前想清楚。
3. 客户端初始化:UA参数、注册事件与一次完整呼出
3.1 安装与UA配置:三个最容易写错的字段
JsSIP通过npm分发,项目里执行npm install jssip即可。它本质上是一个SIP UA(User Agent)库,内部封装了SIP事务层、会话管理和与RTCPeerConnection的对接。初始化UA时,下面的代码是最简配置。
import JsSIP from 'jssip'; const socket = new JsSIP.WebSocketInterface('wss://192.168.1.10:7443'); const ua = new JsSIP.UA({ sockets: [socket], uri: 'sip:1001@192.168.1.10', password: '123456', register: true }); ua.start();逻辑说明:WebSocketInterface是JsSIP的信令传输层,它负责把SIP消息封装成WebSocket帧发给FreeSWITCH;uri是这部分的AOR(Address of Record),相当于分机在SIP世界的身份标识,user部分必须和FreeSWITCH directory里的id一致;register=true表示UA启动后立即发起REGISTER;password对应directory里的密码。ua.start()之后,JsSIP才开始真正工作。
参数说明:sockets是一个数组,可以传多个WebSocket地址做故障切换,但Demo里通常只配一个。最容易写错的三个地方是:uri里忘了带sip:前缀、domain写成了页面域名而不是FreeSWITCH的IP、password里有特殊字符但没有做URI编码。这三个错的表现都是注册失败,但报错信息各不相同,排查时先核对这三项。
3.2 注册与呼出:从registered事件到session建立
注册结果通过UA事件对外暴露。JsSIP使用事件订阅模式,ua.on(...)注册回调。下面这段代码同时处理了注册成功、注册失败和来电事件,并在发起呼出时建立会话。
ua.on('registered', () => { console.log('分机注册成功,当前在线'); }); ua.on('registrationFailed', (event) => { console.error('注册失败,原因:', event.cause); }); ua.on('newRTCSession', (data) => { const session = data.session; if (data.originator === 'remote') { // 来电:手动应答 session.answer({ mediaConstraints: { audio: true, video: true } }); } session.on('confirmed', () => { console.log('通话已接通,媒体流已建立'); }); session.on('ended', () => { console.log('通话已结束'); }); }); // 主动呼出 1002 const session = ua.call('sip:1002@192.168.1.10', { mediaConstraints: { audio: true, video: true }, pcConfig: { iceServers: [{ urls: 'stun:stun.example.org:3478' }] } });逻辑说明:newRTCSession是UA层面最重要的会话事件,呼入和呼出都会触发,通过data.originator判断是本地发起还是远端来电。呼入时调用session.answer()接听;呼出时ua.call()直接创建会话。confirmed事件表示SIP dialog进入Confirmed状态,此时媒体通道(RTP/RTCP)已经在浏览器和FreeSWITCH之间跑起来了。ended表示会话结束,无论是对方挂断、自己挂断还是网络异常,这个事件都会触发。
参数说明:pcConfig.iceServers对应RTCPeerConnection的RTCConfiguration,生产环境建议配置自己的STUN和TURN服务;纯内网Demo可以不配,但跨网段呼叫时没有STUN基本会单通。mediaConstraints里的audio和video是布尔值,也可以传约束对象,比如指定视频分辨率。
3.3 本地预览:先采集还是先呼叫的顺序问题
很多Demo会先调用getUserMedia把本地摄像头画面显示在页面上,再发起呼叫。注意JsSIP的行为:如果call的选项里没有传localMediaStream,它会按mediaConstraints自己重新采集一次媒体流,这意味着页面上预览的画面和实际通话发送的画面是两路流。视觉表现就是预览画面卡了一下,或者通话中本地画面和预览画面方向不一致。
async function makeCallWithPreview() { // 先手动采集并绑定到本地video标签 const localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 480 } }); document.getElementById('localVideo').srcObject = localStream; // 再把同一路流交给JsSIP,避免重复采集 const session = ua.call('sip:1002@192.168.1.10', { mediaConstraints: { audio: true, video: true }, localMediaStream: localStream }); return session; }逻辑说明:getUserMedia返回的MediaStream被赋值给video元素的srcObject后,本地预览立即生效;随后通过localMediaStream把同一路流传入ua.call,JsSIP检测到该参数后不会重新采集,直接把现有流封装进RTCPeerConnection发送。这样预览与通话使用同一路视频流,避免画面跳变。
参数说明:mediaConstraints和localMediaStream同时存在时,JsSIP以localMediaStream为准作为发送流,但mediaConstraints仍会影响SDP里的编解码声明。如果只做纯语音,把audio: true, video: false即可;需要在通话中临时关闭摄像头时,操作的是session内部RTCPeerConnection的track,而不是重新调用ua.call。
4. 媒体协商:SDP与编解码在浏览器和FreeSWITCH之间的匹配
4.1 SDP协商过程:offer、answer与ICE候选的传递
浏览器侧SIP通话的信令层面只负责交换SDP:JsSIP通过INVITE携带offer,FreeSWITCH的媒体层生成answer后通过SIP 200 OK返回,浏览器收到answer后开始ICE连通性检测,最后RTP按协商好的IP和端口传输。这个过程对上层是透明的,但排查问题时必须先理清这一链路——如果SIP层已经confirmed,说明offer/answer交换完整且ICE检测通过,问题出在RTP传输或编解码负载上。
session.on('confirmed', (event) => { // 打印协商后的SDP媒体行,便于核对编解码和媒体方向 const sdp = event.originator.sessionDescription.sdp; const mediaLines = sdp .split('\n') .filter((line) => line.startsWith('a=') || line.startsWith('m=')); console.log(mediaLines.join('\n')); });逻辑说明:originator.sessionDescription是协商后最终生效的SDP描述。从打印结果里能看到m行里携带的codec列表、a=sendrecv/recvonly/sendonly以及a=ice-ufrag等信息。如果远端方向是a=recvonly,说明对端媒体层没有把自己这侧的媒体流发回来,这通常是FreeSWITCH的RTP地址配置有问题。
4.2 编解码优先级:opus、PCMU与VP8/H.264的选择
FreeSWITCH的SIP profile里通过inbound-codec-prefs和outbound-codec-prefs控制编解码偏好,JsSIP则依赖浏览器本身的编解码能力。Chrome对opus和VP8支持最稳,PCMU/PCMA因为是必选编解码,兼容性也极好;H.264视频在某些浏览器的实现里需要额外开关,不是默认就协商成功的。
<param name="inbound-codec-prefs" value="opus,PCMU,PCMA,VP8,H264"/> <param name="outbound-codec-prefs" value="OPUS,PCMU,PCMA,VP8,H264"/>参数说明:编解码字符串不区分大小写,但顺序代表优先级,排前面的优先协商。常见误区是只加opus而漏了PCMU,结果用PJSIP软电话测试正常,换成不支持opus的旧设备就翻车。Demo里建议至少保留opus,PCMU两个音频编解码,视频按浏览器能力选择VP8或H264。
4.3 双向媒体绑定:localStream和remoteStream的映射
页面里通常有两个video标签:一个显示本地预览,一个显示远端画面。远端画面的来源不是手动获取的,而是JsSIP内部的RTCPeerConnection在协商完成后触发track事件时提供的。
session.on('peerconnection', (event) => { const pc = event.peerconnection; pc.ontrack = (trackEvent) => { const [remoteStream] = trackEvent.streams; document.getElementById('remoteVideo').srcObject = remoteStream; }; });逻辑说明:peerconnection事件在JsSIP创建RTCPeerConnection时触发,把ontrack挂在这里,就能在远端track到达时拿到MediaStream并绑定到video元素。远端画面的延迟取决于ontrack触发时机,必须等到confirmed之后才会稳定显示。
参数说明:如果页面里没有把remoteVideo的srcObject赋值,通话虽然建立但画面一直是黑的。常见错误是把src直接设成对象URL字符串,srcObject和src在视频元素上是两个不同机制,用srcObject接收MediaStream是最标准的做法。另外,如果同时处理多路通话,ontrack回调里要按会话区分远端Stream,不能所有session共用同一个video元素。
5. 避坑与排查:注册失败、单通和掉线的五种典型故障
5.1 注册一直401:密码与认证算法不一致
现象:JsSIP控制台打印registrationFailed,FreeSWITCH日志里出现ERR级别的401响应,分机死活注册不上。
原因:绝大多数情况是directory里的密码和JsSIP的password参数不一致。JsSIP默认使用Digest MD5认证,如果FreeSWITCH的SIP profile被改成其它认证算法,也会导致挑战响应不匹配。
解决:先检查directory里<param name="password" value="..."/>是否和JsSIP里完全一致,注意大小写和空格;然后在fs_cli里执行sofia status profile internal确认auth-calls参数没有被改成false。如果密码确认无误仍然401,把FreeSWITCH日志级别调到DEBUG,查看挑战参数里的realm是否和JsSIP发送的认证realm一致——有些场景需要显式在UA里配置authorizationUser。
5.2 注册连接直接失败:WebSocket握手被拒
现象:浏览器控制台出现WebSocket connection to 'wss://...' failed,UA的disconnected事件触发,FreeSWITCH日志里完全没有SIP消息记录。
原因:域名或端口不通、证书不被信任,或者FreeSWITCH的WebSocket监听绑定在回环地址上。这是最常见的配置反复出问题的地方,从头到尾不像是SIP层错误。
解决:先用命令行工具直接测WebSocket端口连通性,比如用curl -k -i向https://IP:7443发一个带升级头的请求,看有没有101 Switching Protocols响应。如果没有,检查监听地址是不是0.0.0.0而不是127.0.0.1。证书问题则用openssl s_client -connect IP:7443确认证书链是否完整。本地验证时,用ws://localhost:5066能临时避开证书环节。
5.3 呼出提示号码不存在:拨号计划没匹配上
现象:分机注册正常,但1001呼叫1002时对方无响铃,Fs_CLI里看到404 Not Found,或者呼叫直接被转到默认提示音。
原因:JsSIP构造的Request-URI里的domain和FreeSWITCH拨号计划匹配的context不一致。例如JsSIP里URI是sip:1002@192.168.1.10,但sofia profile的domain配置是sip.example.com,FreeSWITCH在解析dialplan时找不到对应domain的上下文。
解决:在fs_cli里执行dialplan trace,然后再次发起呼叫,观察匹配过程。trace会显示候选extension和每一步的字段值,能直接看出destination_number和domain_name到底是什么。修正方法是让JsSIP的URI domain和sofia profile的domain保持一致,或者在拨号计划里增加针对IP域名的条件匹配分支。
5.4 音频单通:一方听不到声音
现象:通话建立成功,页面也显示confirmed,但只有一方能听到声音,另一方完全静音,或说话声音断断续续。
原因:RTP单向传输。常见于NAT环境,FreeSWITCH返回的SDP里携带了内网IP,浏览器无法直接向该IP发送RTP;或者是ICE候选收集不全,客户端侧只有host候选,没有srflx候选。
解决:FreeSWITCH侧检查SIP profile里ext-rtp-ip和ext-sip-ip参数,在NAT环境下应设置为公网IP或$${local_ip_v4}自动探测;客户端侧给pcConfig.iceServers配置可靠的STUN服务器。排查时在浏览器里打印pc.getStats,查看remote-candidate是不是0.0.0.0,如果是说明对端没有返回有效媒体地址。
5.5 通话中途掉线:注册周期与超时机制不匹配
现象:通话前几分钟正常,过一段时间后突然无法收到来电,重新刷新页面又恢复。
原因:JsSIP默认注册有效期和FreeSWITCH的session超时时间设置不一致。浏览器在后台标签页被降频时,WebSocket心跳可能延迟,FreeSWITCH判定注册过期后主动踢掉分机。
解决:把JsSIP的registrationExpires参数以及socket层的keep-alive间隔调短,再配合FreeSWITCH的max-profiles检查和sofia profile里的session-timeout参数一起看。前端侧可以监听window.visibilitychange事件,页面从后台恢复时主动调用ua.register()重新注册,避免长时间挂机后失联。
6. 进阶:把Demo封装成可复用组件并验证稳定性
6.1 封装调用层:暴露最小API
把上面所有逻辑收敛成一个类,对外只提供connect、call、hangup、on四个方法,业务侧不需要关心JsSIP内部细节。
class CallClient { constructor(server, user, password) { this.server = server; this.user = user; this.password = password; this.ua = null; this.session = null; this.listeners = {}; } connect() { const socket = new JsSIP.WebSocketInterface(this.server); this.ua = new JsSIP.UA({ sockets: [socket], uri: `sip:${this.user}@${new URL(this.server).host}`, password: this.password, register: true }); this.ua.on('registered', () => this._emit('registered')); this.ua.on('newRTCSession', (data) => { if (data.originator === 'remote') { data.session.answer(); } this.session = data.session; }); this.ua.start(); } call(callee) { if (!this.ua) return; const host = new URL(this.server).host; this.session = this.ua.call(`sip:${callee}@${host}`); } hangup() { if (this.session) this.session.terminate(); } on(event, fn) { this.listeners[event] = fn; } _emit(event, ...args) { if (this.listeners[event]) this.listeners[event](...args); } }逻辑说明:connect负责创建WebSocket连接和UA注册;call从this.server提取host拼装被叫URI;hangup调用session.terminate()结束会话。外部通过on订阅registered、incoming等事件,事件机制解耦了UI层与呼叫逻辑。
参数说明:这段代码有个隐藏前提——构建URI时假定server的host就是FreeSWITCH的SIP domain。如果你的部署中二者不同,需要单独传一个domain参数,不要从WebSocket地址里猜,否则又回到上一条坑里。新项目我在封装时都会把这个参数独立出来,避免换环境就翻车。
6.2 状态机与UI联动
通话状态适合用一个枚举值对外暴露:idle → registering → registered → calling → connected → ended。每个状态变化都派发一个stateChange事件,UI侧根据状态切换按钮的可用状态:idle可拨号,connected显示挂断。这套状态机在长时间测试里特别有用,能第一时间发现状态卡死的问题。
6.3 稳定性验证清单
| 验证项 | 通过标准 | 常见失败 |
|---|---|---|
| 重复注册 | 刷新页面10次,每次都能注册成功 | 频繁刷新导致老socket未释放,端口耗尽 |
| 双向音频 | 1001与1002互拨,双向语音连续无断裂 | 单通、回声或播放延迟 |
| 视频协商 | 接通后5秒内显示远端画面 | 编解码不支持,黑屏但音频正常 |
| 长时间通话 | 连续30分钟不自动掉线 | 注册超时,心跳丢失 |
| 网络切换 | 拔网线后恢复,10秒内重新注册 | 掉线后UA未重连,需手动刷新 |
验证时我习惯把FreeSWITCH的日志级别调到INFO,同时打开浏览器控制台,两边对照看SIP消息。哪边先报错,问题就大概率在那边。从那以后我每次部署这类浏览器话机Demo,都会强制走一遍上表里的清单再交付,发现“注册能过、通话也通,但一换网络就掉”的问题比想象中多得多。希望这份笔记能帮你省下排错的半天时间。
本文还有配套的精品资源,点击获取