ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

jssip音视频demo:基于WebRTC与SIP的浏览器软电话实现指南

jssip音视频demo:基于WebRTC与SIP的浏览器软电话实现指南 简介这是一份面向Web开发者及VoIP初学者的JSSIP音视频通信示例工程通过JSSIP库与FreeSWITCH服务器协作演示了浏览器环境下的音视频通话与短信收发。资源共385个文件压缩包约4.42MB主体为html、js、css、less等前端代码辅以jpg、png、svg图片素材与少量字体、配置文件目录层级清晰便于按模块查阅。示例覆盖SIP注册、呼叫发起与接听、媒体协商、信令处理、WebSocket长连接、异常处理及FreeSWITCH API扩展等核心环节代码结构与事件回调组织规范能够直观展示SIP协议在Web端的完整工作流程。此外项目还提供了短信收发示例和基本错误处理思路可作为浏览器VoIP功能开发的起点。目前已有1435人学习适合需要搭建浏览器VoIP应用、理解JSSIP集成方式或基于FreeSWITCH进行二次开发的开发者参考。1. jssip音视频demo给浏览器装一套 SIP 电话3 分钟跑通的最小闭环在浏览器里做音视频通话选型绕不开 WebRTC但 WebRTC 只解决了媒体传输没有解决「谁拨给谁、怎么响铃、怎么挂断」这套信令问题。jssip音视频demo 解决的就是这件事它是 JsSIP 库在前端跑通 SIP 注册与音视频通话的最小实例让网页像一台软电话一样输入账号、密码、SIP 服务器地址就能拨打、接听、挂断。适合三类人一是评估 JsSIP 能否承接现有 PBX 业务的前端工程师二是要给 FreeSWITCH / Asterisk 补一个网页客服入口的全栈开发者三是刚入行想搞清楚 SIP over WebSocket 到底怎么工作的新手——照着 demo 改一改比读十篇协议文档都快。2. 先把地基说清楚WebRTC、SIP over WebSocket 和 JsSIP 的分工2.1 为什么浏览器里打电话需要三层配合传统 SIP 电话走 UDP 5060 端口浏览器不认这个协议也没法直接暴露本机端口。JsSIP 的思路是转换传输层把 SIP 信令包进 WebSocket 发给服务器媒体流则走 WebRTC 的 RTP/RTCP 通道。搭建 jssip音视频demo 时我一般把架构拆成三层看信令层浏览器里的 JsSIP 按 SIP 协议发 INVITE、ACK、BYE通过 WebSocket 传给 SIP 服务器。媒体层浏览器用 getUserMedia 采集麦克风与摄像头经 RTCPeerConnection 编解码、协商、传输音视频流。业务层处理注册、拨号、响铃、通话时长统计、通话结束后清理资源。理解了这个分层排错时才能快速判断问题出在哪一层。注册失败多半是信令层的问题能注册但没声音八成是媒体协商或 ICE 没通能听到对方但自己说话对方听不到是设备采集成环状态。2.2 JsSIP 到底做了什么不做什么JsSIP 是一个纯 JavaScript 实现的 SIP 客户端跑在浏览器里核心职责是维护 SIP 注册状态、构造并解析 SIP 消息、把 WebRTC 媒体协商的 SDP 塞进 SIP 消息体里。它不负责摄像头采集也不负责音频编解码——这些是 WebRTC 的能力边界。做 jssip音视频demo 之前必须分清这个边界否则配置参数时容易「指点」错对象。写代码时对应关系是JsSIP.UA 负责注册与呼叫控制对应new JsSIP.UA(configuration)。JsSIP.UA.call() 方法负责发起呼叫内部会创建 RTCSession。媒体流由浏览器原生 getUserMedia 创建JsSIP 拿到的是 MediaStream 对象而不是代为采集。2.3 选型考量为什么 JsSIP 而不是原生 WebRTC 或别的 SIP 库原生 WebRTC 只提供了 RTCPeerConnection呼叫流程里的注册、鉴权、响铃、挂断逻辑全得自己造轮子。而 SIP 是成熟协议栈企业内网多数部署着 FreeSWITCH、Asterisk 这类软交换接一个 SIP 客户端比自研信令经济得多。和同类库比较JsSIP 的优点是支持 TypeScript 类型提示、社区问答案例多、协议实现完整缺点是依赖库较重且浏览器兼容性受 WebRTC 限制。选型标准我一般看三条一要支持 WSS 加密传输二要支持多路并发呼叫管理三要能在 React / Vue 里正常销毁重建实例避免内存泄漏——JsSIP 三条都占住了。3. 跑通一个 jssip音视频demo从 UA 注册到呼叫的完整代码3.1 最小工程结构与依赖安装常见做法是直接用 Vite 建一个 vanilla TypeScript 项目依赖只装 JsSIP 一个包。先初始化工程npm create vitelatest jssip-demo -- --template vanilla-ts cd jssip-demo npm install jssip npm run dev说明Vite 模板自带 TypeScript 和开发服务器配置不需要额外装 webpack。JsSIP 安装后会带完整类型声明IDE 里写配置时有提示不容易漏字段。3.2 初始化 SIP 客户端配置注册参数新建src/sip.ts先创建 UA 实例并完成注册。这是整个 jssip音视频demo 的地基注册都过不去后面所有步骤都不用谈import { UA } from jssip; export const socket new JsSIP.WebSocketInterface(wss://sip.example.com:8089/ws); // 注意实际部署时把 wss:// 地址换成你自己的 SIP 服务器地址 export const ua new UA({ sockets: [socket], uri: sip:1001sip.example.com, password: your_password, register: true, session_timers: false, display_name: 前台坐席, ice_servers: [ { urls: stun:stun.l.google.com:19302 }, ], }); ua.on(registered, () { console.log(SIP 注册成功可以开始呼叫); }); ua.on(registrationFailed, (data) { console.error(注册失败原因是, data.cause); }); ua.start();参数说明sockets必须传数组因为 JsSIP 支持多个 WebSocket 服务器做容灾切换uri是 SIP 账号的完整 AOR格式必须是sip:号码域名register: true表示启动时自动发送 REGISTER 请求session_timers关掉是为了避免和部分老版本 FreeSWITCH 的会话刷新机制冲突。ice_servers里如果只做局域网 demo配一个 STUN 即可公网部署则要自建 TURN 服务器不然 NAT 对称型网络里媒体流会黑屏或没声音。3.3 发起呼叫携带音视频流并监听会话事件注册成功后核心动作是ua.call()。这个方法的入参里有mediaConstraints和rtcOfferConstraints是真正控制音视频启停的地方import type { RTCSession } from jssip; const session: RTCSession ua.call(sip:1002sip.example.com, { mediaConstraints: { audio: true, video: true, }, rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true, }, pcConfig: { iceServers: [{ urls: stun:stun.l.google.com:19302 }], }, }); session.on(accepted, () { console.log(对方已接听通话开始); }); session.on(peerconnection, (data) { const pc data.pc; pc.oniceconnectionstatechange () { console.log(ICE 连接状态, pc.iceConnectionState); }; }); session.on(failed, (data) { console.error(呼叫失败, data.cause); }); session.on(ended, () { console.log(通话结束清理本地视频元素); });参数含义mediaConstraints决定本地采集哪些轨设成{ audio: true, video: false }时只送音频不送视频offerToReceiveAudio / offerToReceiveVideo决定是否接收对方的音视频流两端必须匹配否则会出现单向画面pcConfig会在这次呼叫里覆盖掉 UA 初始化时的 ICE 配置。3.4 接听来电处理振铃与应答被叫端的逻辑是监听newRTCSession事件。这个事件比较特殊UA 上收到了 INVITE 就会触发需要在回调里判断data.originator remote来区分来电和去话避免重复处理自己发起的呼叫ua.on(newRTCSession, (data) { if (data.originator remote) { const incomingSession data.session; incomingSession.on(confirmed, () { console.log(通话已建立); }); // 页面上的「接听」按钮绑定到这段逻辑 window.answerCall () { incomingSession.answer({ mediaConstraints: { audio: true, video: true, }, rtcAnswerConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true, }, }); }; // 页面上的「挂断」按钮绑定到这段逻辑 window.rejectCall () { incomingSession.terminate(); }; } });接听逻辑里最常翻车的点是answer()要在用户手势里调用。浏览器自动播放策略要求媒体播放前必须有用户点击动作所以接听按钮的 click 事件里调 answer 是合理的如果在newRTCSession回调里直接自动 answer大概率拿到一个黑屏或无声的通话。3.5 渲染本地与远端视频流通了之后要显示画面。JsSIP 把远端媒体流挂在 session 的remoteStream上本地流则在localStream上。要在通话建立后动态创建 video 元素塞进去function attachStream(stream: MediaStream, videoElementId: string) { const video document.getElementById(videoElementId) as HTMLVideoElement; video.srcObject stream; video.play().catch((e) console.warn(自动播放被拦截等待用户交互, e)); } session.on(confirmed, () { if (session.localStream) { attachStream(session.localStream, local-video); } if (session.remoteStream) { attachStream(session.remoteStream, remote-video); } });3.6 挂断与实例销毁的完整清理挂断不能只调terminate()要同时清理媒体轨和事件监听否则第二次呼叫会有诡异行为function hangup(session: RTCSession) { session.terminate(); if (session.localStream) { session.localStream.getTracks().forEach((track) track.stop()); } if (session.remoteStream) { session.remoteStream.getTracks().forEach((track) track.stop()); } session.removeAllListeners(confirmed); session.removeAllListeners(ended); }终止通话时频繁出现的坑是只调了terminate()没有把本地麦克风轨道停掉导致下次拨打时浏览器右上角一直挂着摄像头占用标识严重的会产生回声俗称「设备被占坑」。把每一个 track 都 stop 掉是规范动作别省略。4. 对接 SIP 服务器FreeSWITCH 配置与 WebSocket 穿透的三处关键设置4.1 服务器端要让出 WebSocket 端口FreeSWITCH 需要启用 mod_sofia 的 WebSocket 监听核心配置在sofia.conf.xml里的 profiles按常见配置做法需要确保加载了 wss 绑定。Asterisk 则要把 chan_pjsip 的传输设为transportwss。无论用哪个关键点都是一个SIP 服务器除了 UDP 5060 之外必须额外监听一个面向 WebSocket 的端口常见取值是 8089 或 8443。浏览器环境里尽量用 WSS不要用裸 WS原因是getUserMedia在 HTTPS 或 localhost 下才能正常工作而 WSS 会让整个页面处于安全上下文WS 则不会。页面是 HTTPS 但 WebSocket 是 WS这属于混用浏览器会拦截。4.2 鉴权配置里要有 digest 密码而非明文SIP 注册鉴权走的是 Digest 认证不是 Basic。前台传来的password字段在 JsSIP 内部参与计算不会在 WebSocket 里裸奔。FreeSWITCH 侧创建用户时给的是param namepassword value/如果外部 PBX 对接时用户是第三方系统创建的要确认密码是明文存储且可被 Digest 算法使用的无法用于 Digest 认证的值会导致registrationFailed且原因码是 401 或 403。4.3 NAT 穿透参数要显式声明FreeSWITCH profile 里有两个关键参数直接影响 jssip音视频demo 的媒体连通性apply-nat-acl和aggressive-nat-traversal。常见配置是param nameapply-nat-acl valuenat.auto/ param nameaggressive-nat-traversal valuetrue/说明浏览器所在的 NAT 类型不定服务器如果不做穿透辅助ICE 协商出来的 candidate 可能指向内网地址远端无法访问。开启 aggressive-nat-traversal 后FreeSWITCH 会尊重 ICE 协商结果而不是强制替换 SDP 里的地址。4.4 Nginx 反代 WebSocket 时的超时保护如果 SIP 服务器与浏览器之间隔着一层 Nginx必须配Upgrade头否则 WebSocket 握手直接失败location /ws { proxy_pass https://sip-server-backend:7443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout这里要特别大因为一次通话可能持续很久Nginx 默认 60 秒无数据传输就会断开连接导致通话进行到一半突然挂断。这个配置的坑很多人是等到生产环境才发现demo 阶段注意别踩。5. jssip音视频demo 避坑笔记注册失败、无声音、呼叫挂断等 5 个常见问题5.1 注册报 403密码看着没错就是过不去现象registrationFailed事件触发cause 是Forbidden控制台打印出的响应码是 403。原因多数情况下不是密码错而是 SIP URI 的域名和服务器认证 realm 不一致。比如服务器 realm 是pbx.internalURI 里写的却是sip.example.comDigest 计算时密码被错误组合鉴权失败。解决把uri里的域名改成服务器实际 realm。不确定的话登录 FreeSWITCH CLI 执行sofia status profile internal查看realm字段保证两边完全一致。这里还有个手段抓包看 WebSocket 里的WWW-Authenticate响应头里面直接写着 realm。5.2 注册成功但拨出后对方听不到声音现象呼叫建立、双向视频画面正常但对方听不到本地声音本地麦克风电平也无波动。原因mediaConstraints里audio: true是异步 getUserMedia在用户还没授权麦克风时 JsSIP 已经把 INVITE 发出去了SDP 里媒体轨是 disabled对方拿到静音流。解决先通过navigator.mediaDevices.getUserMedia({ audio: true })手动请求授权拿到 MediaStream 后再传入ua.call()的mediaStream字段不要依赖 JsSIP 内部自动采集。这个坑在 Chrome 上偶现、在 Safari 上几乎必现。5.3 内网互通良好公网通话只要一跨网就黑屏现象同一局域网里双方视频流畅一方移到 4G 网络后画面冻结或只有音频没有视频。原因ICE 协商最终选中的 candidate 是 host 类型媒体流在内网地址间直接传输跨网后这个地址不可达。STUN 没能映射出正确的公网地址。解决在ice_servers里加入 TURN 服务器。常见开源方案是 coturn部署后配置ice_servers: [{ urls: turn:your-turn.example.com:3478, username: test, credential: test }]。不能只配 STUN对称型 NAT 底下 STUN 拿到的地址不对TURN 会走中继兜底。5.4 通话到 30 秒准时断线控制台无任何报错现象通话建立后计时到约 30 秒自动结束JsSIP 没有任何异常输出。原因会话定时器。如果 SIP 服务器启用了session-timers且 JsSIP 配置里没有正确响应Session-Expires头服务器认为会话超时就要拆线。解决把初始化时的session_timers: false保持住同时在 FreeSWITCH 侧检查param namesession-timers valuefalse/。两边强制关掉问题不会出现。有人会想用刷新机制去适配但浏览器端的定时器在标签页休眠时会被系统冻结不是可靠方案。5.5 页面频繁切换路由后再次呼叫摄像头灯不亮现象在 Vue/React 里多次进入和离开通话页后第三次拨打会出现NotReadableError摄像头灯压根不亮。原因组件卸载时没有把localStream的 track stop 掉设备被锁占用浏览器拒绝再次打开。解决组件卸载的生命周期里把「挂断清理」逻辑完整执行一遍重点是把 track 的 stop 放在 session terminate 之前。为了不重复踩这个坑我写了一个规范terminate()→stopTracks()→removeAllListeners()顺序永远不改。6. 进阶验证把 demo 变成可用产品前要补的四块能力6.1 呼叫保持与转移需要手动处理 DTMF 与 REFERJsSIP 的 demo 只覆盖了基础呼叫实际客服场景里大概率需要保持、转接。保持的常见做法是session.hold()转接则是发 REFER 请求。JsSIP 对 REFER 的原生支持不如 FreeSWITCH 的 transfer API 顺手我一般直接让 SIP 服务器处理转接前端只负责上报目标号码。在 demo 里预留一个按钮给session.hold()用表格对比三种转接方式的差别方案实现难度适用场景前端发 REFER高要处理 202 与 NOTIFY 状态机纯 WebRTC 端之间的转移后端 HTTP API 转接低FreeSWITCH 的 originate 即可坐席工作台SIP 服务器侧 Blind Transfer中要维护呼叫 ID 映射话务量大的生产环境6.2 通话质量指标的采集WebRTC Stats 不能省demo 能通只是第一步生产环境要能排障至少要采集packetsLost、jitter、roundTripTime。在peerconnection事件里拿到的 RTCPeerConnection 对象可以用getStats()周期性拉数据session.on(peerconnection, (data) { const pc data.pc; // RTCPeerConnection setInterval(async () { const stats await pc.getStats(); stats.forEach((report) { if (report.type inbound-rtp) { console.log(丢包率, report.packetsLost, 抖动, report.jitter); } if (report.type candidate-pair) { console.log(往返时延, report.currentRoundTripTime); } }); }, 5000); });这套采集埋点要在每次通话结束前停掉不然会积压定时器。我会把定时器 id 存在 session 级变量里在ended事件里 clearInterval。6.3 通话录音的正确姿势听筒采集不如服务器录制有人想在浏览器端用MediaRecorder录通话听起来简单实际翻车点很多双轨录音要混音、远端流的静音检测要自己写、浏览器切后台会触发录音暂停。常见生产方案是把媒体流旁路到 FreeSWITCH 的recording参数由服务器录制双轨文件前端只需要在呼叫发起时带上录制标识。6.4 多设备抢占账号与多标签页冲突同一个 SIP 账号在两个标签页同时注册第二轮注册会把前面的会话顶掉。血泪经验是全局只初始化一个 UA 实例用单例模式管理页面刷新前主动注销。结尾说一句我做这套 demo 的教训能用session_timers: false就不用 true能依赖服务器转接就不自己写 REFER能关闭的设备轨必须关闭。jssip音视频demo 能跑通只是起点后续稳定性的功夫全在这些边界处理上。希望帮到你。本文还有配套的精品资源点击获取
返回列表