资讯详情

JSSIP+FreeSWITCH+WebRTC浏览器视频通话Demo拆解

📅 2026/10/26 17:45:53 | 华诺云谱 👁 阅读
JSSIP+FreeSWITCH+WebRTC浏览器视频通话Demo拆解
简介面向Web VoIP开发者与SIP协议学习者的JSSIP音视频Demo围绕浏览器端JSSIP与FreeSWITCH的交互展开覆盖SIP注册、音视频呼叫接听挂断、媒体协商、信令流程、文本消息以及WebSocket长连接等关键环节可帮助读者理解无插件Web通信应用的实现方式与错误处理思路。压缩包共385个文件约4.42MB以html页面、less样式源码、js逻辑脚本和css样式为主辅以jpg/png/svg图片、yml配置、json/map等资源前端工程结构相对完整便于按模块浏览和二次改造。已有1441人学习下载适合作为FreeSWITCH与JSSIP联调的入门示例。读者可据此观察注册状态、呼叫生命周期、媒体参数协商与异常回调也能提取可复用的消息收发和音视频通信模块。1. 浏览器点一下就能视频通话这个 jssip 音视频 demo 到底解决什么问题上周有人问我能不能在不装任何客户端的前提下让两个浏览器互相视频通话后端还得是自己可控的软交换。这个 jssip 音视频 demo 就是干这件事的最小闭环JSSIP 负责在浏览器里说 SIP 协议FreeSWITCH 负责接听、转接和媒体转发中间用 WebSocket 承载信令用 WebRTC 承载音视频流。它不追求功能齐全只把“注册—呼叫—接通—看到对方画面—挂断”这五步走通代码量压得很低方便你在此基础上二次改造。适合谁需要在 Web 端接入电话系统、坐席系统、客服工作台的前端开发者想把 FreeSWITCH 从“只闻其声”推进到“看得见人”的运维和通信开发以及那些被 SIP 协议栈折腾过、想找个能跑通的最小样例对照着改的人。2. 技术栈与工程结构三条通道决定整个 demo 的形态先把一句话说清这个 demo 里没有“黑匣子”式的封装浏览器端全部逻辑都在几个 JS 文件里服务端只有 FreeSWITCH 的配置和静态资源托管。看懂它对技术栈的要求比看懂代码更重要。2.1 JSSIP 在浏览器里到底扮演什么角色很多人第一次接触会误以为 JSSIP 是 WebRTC 的封装库其实不是。WebRTC 只解决媒体采集、编码、传输和渲染它根本不知道“呼叫”是什么概念——谁发起、谁接听、对方在线吗、响铃多久超时这些都需要信令层。JSSIP 就补这一块它把 SIP 协议REGISTER、INVITE、ACK、BYE实现成纯 JavaScript运行在浏览器里信令走 WebSocket 而不是传统 UDP 5060。这个分工带来一个很实际的结论你调试时要分清是信令问题还是媒体问题。信令出问题现象通常是注册失败、呼叫直接报错、对方压根不响媒体出问题现象是接通了但没有声音、没有画面、单向。两者排查手段完全不同混在一起查最容易浪费时间。在 FreeSWITCH 侧对应的是mod_sofia的 WebSocket 绑定能力。从 FreeSWITCH 1.6 之后 sofia 就支持 ws/wss配置里给它单独开一个端口浏览器连上来之后FreeSWITCH 会把它当成一个普通的 SIP 终端对待——分机、拨号计划、编码协商全部沿用原来的机制。这也是这个 demo 的价值所在它没有引入任何私有协议所有东西都是标准 SIP你可以用任何 SIP 客户端和浏览器互通。技术栈摊开看是这样层次具体组件作用信令协议栈JSSIP浏览器内实现 SIP UA、注册、会话状态机信令传输WebSocketws/wss承载 SIP 报文绕过浏览器对 UDP 的限制媒体传输WebRTCRTCPeerConnection音视频采集、编解码、NAT 穿透、渲染服务端软交换FreeSWITCH分机鉴权、呼叫路由、媒体中转、录音前端宿主任意静态服务器Nginx 等托管页面提供 HTTPS 环境2.2 目录结构与文件职责哪些文件是必须动的这类 demo 的工程结构通常都很扁平我按常见做法给一份接近实际的目录你拿到源码后对照着看心里就有数了jssip-video-demo/ ├── web/ # 前端静态资源根目录 │ ├── index.html # 拨号界面账号、密码、被叫号码、接通按钮 │ ├── css/ │ │ └── main.css # 布局样式本地/远端视频两个 video 容器 │ ├── js/ │ │ ├── config.js # UA 配置wss 地址、realm、iceServers │ │ ├── ua.js # 注册、重连、来电监听 │ │ ├── call.js # 呼叫、挂断、SDP 处理、媒体轨绑定 │ │ └── ui.js # DOM 事件与状态渲染 │ └── lib/ │ └── jssip.min.js # JSSIP 发行包 ├── freeswitch/ │ ├── sip_profiles/ │ │ └── internal.xml # ws-binding / wss-binding 绑定端口 │ ├── directory/ │ │ └── 1000.xml # 分机账号与密码 │ └── dialplan/ │ └── default.xml # 呼入呼出路由、视频编码字符串 └── nginx.conf # 静态托管 HTTPS 证书配置真正需要你改的只有四个点config.js里的 WebSocket 地址和 ICE 服务器、1000.xml里的分机密码、internal.xml里的绑定端口和证书路径、dialplan里的编码字符串。其余都是可复用的外壳。新手最容易在这四个点里的任意一个写错一位字符然后卡半天——这是典型的血泪经验建议改之前先用diff备份。2.3 为什么不自己拼 WebRTC 而要引入 JSSIP有人会问既然媒体是 WebRTC 做的信令我自己用 WebSocket 发 JSON 不就行了技术上完全可以但你会立刻遇到三个问题SIP 报文的分段与状态机要自己维护、分机和鉴权要自己实现、和现成的 SIP 终端话机、网关、运营商中继无法互通。JSSIP 把第一个问题吃掉了FreeSWITCH 把后两个问题吃掉了。代价是你得接受 SIP 那套稍显啰嗦的语法以及对 FreeSWITCH 配置的依赖。对于“页面里要能打电话”这个需求这笔交易是划算的你省下的是几个月的协议实现工作量付出的是几天的配置调试时间。3. 核心链路拆解一次视频呼叫从注册到看到画面这一章按调用顺序把代码走一遍。源码里的代码量不大但顺序很关键——先注册拿到鉴权再建立 UA才能发起呼叫这个顺序一旦搞反会出那种“代码没报错但就是不响”的玄学问题。3.1 UA 配置与注册连接的第一道关卡配置对象是整条链路的起点sockets决定信令往哪发uri决定你是谁password参与 Digest 鉴权。// config.js —— UA 基础配置 const socketUrl wss://your-host:7443; // FreeSWITCH wss-binding 端口 const uaConfig { sockets: [new JsSIP.WebSocketInterface(socketUrl)], uri: sip:1000your-host, // 分机号 realm password: 1234, // 与 directory/1000.xml 一致 register: true, // UA 启动时自动注册 session_timers: false, // 浏览器端建议关闭避免会话超时误挂断 pcConfig: { iceServers: [ { urls: stun:stun.example.internal:3478 }, // 跨 NAT 时必须有 TURN否则只成功局域网 { urls: turn:turn.example.internal:3478, username: u, credential: p } ], iceTransportPolicy: all } }; const ua new JsSIP.UA(uaConfig); ua.on(registered, () console.log(SIP 注册成功)); ua.on(registrationFailed, (e) console.warn(注册失败, e.cause)); ua.on(disconnected, () console.warn(WebSocket 断开)); ua.start();uri的写法必须和 FreeSWITCH 里的 domain 对得上your-host既要能解析到服务端也要和directory中分机的 domain 一致否则 FreeSWITCH 会返回 404 而不是 401——这是排查时判断“是账号错还是域错”的关键线索。session_timers在浏览器场景下经常导致莫名其妙的断线重连成功重连后媒体中断我一般直接关掉把保活交给 WebSocket 心跳。sockets里用wss而非ws是因为页面一旦跑在 HTTPS 下浏览器会直接拦截混合内容控制台报错很清楚但新手往往忽略这一条以为是服务端没起来。3.2 发起呼叫与 SDP 协商视频能不能出来就看这一步呼叫时最关键的是mediaConstraints它决定了本地采集音频还是视频而 FreeSWITCH 侧能否接受视频取决于拨号计划里的编码字符串。// call.js —— 发起视频呼叫 const target sip:1001your-host; const session ua.call(target, { mediaConstraints: { audio: true, video: true }, // 关键video 必须为 true rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true }, pcConfig: uaConfig.pcConfig }); // 会话状态机progress 表示对方振铃(180)confirmed 表示已接通(200 OK ACK) session.on(progress, (e) { console.log(正在振铃, e.originator); }); session.on(confirmed, () { console.log(通话已建立); }); session.on(failed, (e) { // cause 是排查的入口Busy / Not Found / Unavailable / Canceled console.warn(呼叫失败, e.cause); }); session.on(ended, () { console.log(通话结束); });mediaConstraints.video为false时会发起纯音频呼叫FreeSWITCH 的 SDP 回应里就不会出现视频 m 行后面再怎么绑ontrack也拿不到画面。这个点很反直觉很多人以为是渲染代码写错了其实是采集阶段就没开。pcConfig我在这里重复传了一次是因为 JSSIP 允许按会话覆盖 UA 级配置做不同网络环境下的策略切换时会用上比如内网通话走relay强制中转。3.3 远端流绑定与渲染把 MediaStream 塞进 video 标签接通之后媒体并不会自动出现在页面上JSSIP 给你的是RTCPeerConnection需要自己监听track事件。// call.js —— 媒体轨绑定 const remoteVideo document.getElementById(remoteVideo); const localVideo document.getElementById(localVideo); const remoteStream new MediaStream(); session.connection.addEventListener(track, (event) { // 逐条加入音视频轨都会从这里来 remoteStream.addTrack(event.track); remoteVideo.srcObject remoteStream; remoteVideo.play().catch((err) console.warn(自动播放被拦截, err)); }); // 本地画面预览从 PeerConnection 的 sender 里取 session.connection.getSenders().forEach((sender) { if (sender.track sender.track.kind video) { localVideo.srcObject new MediaStream([sender.track]); localVideo.muted true; // 必须静音否则本地回声 localVideo.play(); } });remoteStream对象要放在闭包外层复用因为浏览器对同一个MediaStream反复赋值给srcObject是安全的但每收到一条轨道就new一个新的再赋值会导致一条轨道覆盖另一条表现出来就是“有画面没声音”或反之。play()返回的 Promise 一定要catch。浏览器自动播放策略会拦截没有用户手势的播放拨号按钮就是那个手势正常情况下没问题但如果通话是对方主动呼入、页面刚加载就接通play()会被拒绝控制台只给一句很含蓄的警告现象就是“接通了但两边都黑屏”。3.4 挂断、重连与状态一致性// call.js —— 挂断与清理 function hangup() { if (session session.isEstablished()) { session.terminate(); // 主动发 BYE } else if (session) { session.terminate({ status_code: 486 }); // 未接通时取消 } remoteStream.getTracks().forEach((t) t.stop()); } ua.on(newRTCSession, (data) { // 统一接管来电与去电避免两套逻辑 if (data.originator remote) { data.session.answer({ mediaConstraints: { audio: true, video: true } }); } });getTracks().forEach(t t.stop())这一句不能省。不停止采集的话摄像头指示灯会一直亮着用户会怀疑你在偷偷录像——这类细节在演示给客户看的时候特别致命。挂断后如果还要重新呼出记得把session置空并重建MediaStream否则第二通电话的轨道会绑到旧对象上。4. 把 demo 跑起来FreeSWITCH 配置到浏览器验证的完整步骤代码看懂了接下来是让它真的通。这一章按部署顺序走先改 FreeSWITCH再改前端配置最后在浏览器里验证。顺序颠倒的话你会在一个连不上的 WebSocket 上反复折腾。4.1 FreeSWITCH 侧开启 WebSocket 绑定默认安装的 FreeSWITCH 只监听 5060WebSocket 需要显式打开。改sip_profiles/internal.xml!-- freeswitch/sip_profiles/internal.xml -- profile nameinternal !-- 原有 SIP 监听保留 -- param namesip-port value5060/ !-- 新增WebSocket 与 WSS 绑定 -- param namews-binding value:5066/ param namewss-binding value:7443/ !-- 视频通话的关键让媒体经过 FreeSWITCH -- param nameinbound-proxy-media valuetrue/ param nameinbound-late-negotiation valuetrue/ !-- 证书路径wss 必需 -- param nametls-cert-dir value/etc/freeswitch/tls/ /profileinbound-proxy-media是视频能不能通的开关。FreeSWITCH 默认倾向于 bypass media媒体直接点对点音频下这种模式问题不大但视频的 SDP 协商经常因为绕过而丢 m 行表现为“接通了对方只听到声音看不到人”。改成 proxy 之后媒体全走服务端带宽压力上来了但链路稳定得多。这也是我建议先在 demo 阶段用 proxy、上线后再评估要不要切回 bypass 的原因。inbound-late-negotiation打开后FreeSWITCH 会等浏览器把完整 SDP 发过来再回应避免早期协商阶段把视频编码裁掉。改完必须重启 profile# 重载 sofia 配置不中断已有通话 fs_cli -x sofia profile internal restart # 确认端口已监听 ss -lntp | grep -E 5066|74434.2 分机与拨号计划让 1000 能呼到 1001分机文件决定鉴权两个分机都要建!-- freeswitch/directory/1000.xml1001 同理改 id 和密码 -- include user id1000 params param namepassword value1234/ /params variables variable nameeffective_caller_id_number value1000/ /variables /user /include拨号计划里最关键的是编码字符串视频编码必须显式指定否则 FreeSWITCH 会按默认只保留音频!-- freeswitch/dialplan/default.xml -- extension namelocal_extension condition fielddestination_number expression^(1001)$ action applicationset dataabsolute_codec_stringVP8,H264,PCMU,PCMA/ action applicationexport dataabsolute_codec_stringVP8,H264,PCMU,PCMA/ action applicationbridge datauser/1001/ /condition /extensionexport那行经常被漏掉。它决定的是腿的编码集合只set当前腿的话被叫那一侧协商时看不到视频编码结果就是主叫有画面、被叫没有。这属于典型的“一半能通”现象排查时优先怀疑这里。4.3 前端启动与 HTTPS 硬要求# 本地快速验证起个静态服务 cd web python3 -m http.server 8080 # 生产/联调Nginx 托管并挂 HTTPSserver { listen 443 ssl; server_name your-host; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; root /var/www/jssip-video-demo/web; index index.html; location / { try_files $uri $uri/ /index.html; } }localhost和127.0.0.1是浏览器允许的例外除此之外getUserMedia和wss都必须跑在 HTTPS 下自签证书记得在浏览器里手动信任一次否则 WebSocket 握手会被静默拒绝控制台只有一行WebSocket connection failed。4.4 三条命令确认链路是否真的通了不要一上来就点呼叫按顺序验证更省时间# 1. 端口通不通 curl -k -i -N -H Connection: Upgrade -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ https://your-host:7443/ # 2. 分机注册上了没 fs_cli -x sofia status profile internal reg # 3. 通话时看媒体是否在走 fs_cli -x show channels第二条命令返回列表里能看到 1000 的 contact 和 User-Agent会是类似 JsSIP 的标识说明注册成功看不到就是账号密码或 domain 的问题。第三条在通话建立后执行read_codec/write_codec两列会显示当前用的编码如果只有 PCMU 没有 VP8说明编码字符串没生效回去检查 4.2 里的export。5. 常见问题排查五个把我卡过半天的坑这一章是按“现象→原因→解决”组织的实录。视频通话的故障率比纯音频高一个量级因为信令、媒体、编解码三条链路的失败现象经常长得一样。5.1 现象注册成功一点呼叫就 488原因通常是 SDP 协商失败。浏览器的 offer 里带了 VP8、H264、RTX、FEC 一堆负载类型FreeSWITCH 侧如果没有对应的编码模块就会回 488 Not Acceptable Here。解决先在fs_cli里执行show codec确认mod_vpxVP8和mod_h26x已加载再检查拨号计划里的absolute_codec_string是否包含浏览器支持的编码。浏览器对 H264 的实现各家不同稳妥做法是 VP8 打头。改完用reloadxml重载配置不需要重启整个服务。5.2 现象接通了对方能听见我我看不到对方画面原因多是媒体走了 bypass 或者被叫侧没收到视频 m 行。前者在sofia global siptrace on抓包时能看到 SDP 里mvideo 0后者则表现为被叫分机的 SDP 里根本没有视频段。解决先按 4.1 把inbound-proxy-media打开再确认拨号计划里export那行存在最后检查被叫分机是不是用纯音频客户端注册的——同一个分机号被一个只支持音频的 SIP 话机注册走视频自然给不到它。给视频demo单独分配 1000/1001 两个号别和其他终端混用。5.3 现象本地能看到自己对端全黑控制台无报错原因是ontrack绑定时机不对或者remoteStream被重复创建。我见过一版代码在每次track事件里都new MediaStream()结果第二条轨道进来时把第一条冲掉了音频轨先到、视频轨后到最终只剩视频没有音频或者反过来。解决MediaStream在会话建立时创建一次track事件里只做addTracksrcObject只赋值一次。另外确认autoplay允许video标签加playsinline属性避免移动端全屏劫持。5.4 现象内网一切正常一换到外网就没声音原因是 ICE 打洞失败缺少 TURN 中继。STUN 只能告诉你公网地址遇到对称型 NAT 就穿不过去音频单向或完全没有。解决部署一台 TURN 服务在pcConfig.iceServers里补上带 username/credential 的 turn 条目或者在 FreeSWITCH 侧把媒体完全 proxy 化让两端都和服务器建连绕开点对点。后者配置更简单代价是服务器带宽。调试时可打开chrome://webrtc-internals看 candidate pair 的状态一直是in-progress就说明没打通。5.5 现象通话 30 秒后自动断开重连后媒体中断原因是 SIP 会话定时器在浏览器环境下的兼容问题或者 WebSocket 心跳缺失导致中间代理把连接掐了。表现是session_timers触发 UPDATE 刷新失败FreeSWITCH 侧直接 BYE。解决UA 配置里设session_timers: false同时在 Nginx 上给 WebSocket 加长连接配置proxy_read_timeout 3600s并转发Upgrade头。如果一定要保活用 JSSIP 的 socket 的onclose事件做指数退避重连重连成功后重新ua.register()。6. 二次开发改造在 demo 上长出屏幕共享与多路通话拿到能跑通的版本之后改动的方向基本集中在三个地方多加一路视频轨、把单路会话扩展成多会话管理、给媒体加质量兜底。屏幕共享是最容易上手也最有实用价值的一个改造因为它复用现有的 PeerConnection只需要换掉发送端的轨道。// call.js —— 通话中切换为屏幕共享 async function shareScreen(session) { const stream await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 15 }, // 共享场景 15 帧够用降带宽 audio: false }); const screenTrack stream.getVideoTracks()[0]; const sender session.connection .getSenders() .find((s) s.track s.track.kind video); // replaceTrack 不触发重新协商用户体验平滑 await sender.replaceTrack(screenTrack); // 用户点浏览器原生的“停止共享”时回切摄像头 screenTrack.onended async () { const cam await navigator.mediaDevices.getUserMedia({ video: true }); await sender.replaceTrack(cam.getVideoTracks()[0]); }; }这里的关键是replaceTrack而不是removeTrackaddTrack。后者会触发 SDP 重新协商FreeSWITCH 侧要处理 re-INVITE链路稍有不稳就直接翻车前者是纯粹的发送端替换对端无感知。这个技巧同样适用于“前后摄像头切换”。多路通话的改造思路则是把session从单变量改成一个以session.id为键的 MapUI 上做多标签每个标签绑一个独立的MediaStream和video容器。改动量不大但要注意浏览器的并发连接数和麦克风独占问题——多路同时开麦会互相抢占设备常见做法是只让当前激活的那一路保持track.enabled true其余静音。验证改造是否成功我的习惯是固定跑三遍同一局域网双向呼叫、跨公网双向呼叫、通话中切换共享再切回。三遍都过才算真的稳。最后说个教训。早年我改这类 demo总是先改代码再配服务端结果每次都被“到底是配置错还是代码错”折腾半天。从那以后我每次动 FreeSWITCH 之前都强制先走一遍 4.4 那三条命令确认底层链路没坏再碰业务代码省下的时间比写代码多得多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑