简介:这份JSSIP音视频demo面向希望用浏览器端技术接入FreeSWITCH的Web开发者与VoIP初学者,围绕SIP注册、音视频呼叫发起与接听、媒体协商及INVITE、BYE等信令流程展开,演示在无需插件的情况下完成实时音视频通信,并兼顾MESSAGE类文本消息的收发场景。资源包共385个文件,以html页面、js脚本、less与css样式为主要构成,辅以jpg、png示意图、svg图标、json与yml配置及woff2等字体资源,压缩后约4.42MB,结构完整,便于部署到静态服务器后直接试跑。目前已有1441人学习下载,可作为入门参考。代码按页面、组件与样式分层组织,方便对照梳理注册和呼叫两条链路;示例中关于错误处理、连接保持的写法,也有助于理解WebSocket传输与FreeSWITCH接口交互的配合方式,读者可据此改造成自己的Web通信项目。
1. 一个音视频 demo 的价值,在于它把信令链路跑通了
第一次跑这个 jssip 音视频 demo,我把分机号拨出去,听筒里安静得可疑——控制台的信令日志明明已经打出 200 OK,通话状态也进了通话中。问题既不在 JsSIP,也不在浏览器权限,而是 FreeSWITCH 回的那条 SDP 里塞满了内网地址,媒体包压根回不来。这个 demo 真正的价值就在这儿:它把「浏览器 WebSocket 进 FreeSWITCH,再出到另一台浏览器」这条链路完整铺开,让你能亲眼看见信令和媒体是两条彼此独立的通道。适合已经懂一点前端、想给后台系统加个坐席呼叫按钮,或者要给内部工具补一个视频对讲能力的开发者;也适合刚接触软交换、想找个能跑通的最小样本对照着看的人。
2. 架构拆解:JsSIP 只搬信令,FreeSWITCH 才是那根扁担
很多人第一眼会把这套东西理解成 WebRTC 点对点通话,以为两个浏览器直接握手就完事。真跑起来你会发现完全不是——两台浏览器的 RTCPeerConnection 各自只跟 FreeSWITCH 建了一条 DTLS-SRTP 通道,中间那一段是 FreeSWITCH 解封装再重新封装转发。理解这一点,后面所有"为什么没声音""为什么延迟高""为什么 CPU 打满"的问题才有解。
2.1 三方各管一段,谁都不越界
把职责切开看会清晰很多。JsSIP 是一个纯 JavaScript 的 SIP 用户代理,它负责 REGISTER 注册、INVITE 呼叫、ACK 确认、BYE 挂断,以及处理 401/407 的 Digest 认证。它自己不采集音频、不编码、不发送任何 RTP 包,媒体那一摊全交给浏览器原生的 WebRTC 栈。传输层用的是 SIP over WebSocket,也就是 RFC 7118 描述的那套,把 SIP 消息塞进 WebSocket 的文本帧里发出去。
FreeSWITCH 在这个 demo 里同时扮演三个角色:注册服务器(registrar,记住 1000 分机当前挂在哪条 WebSocket 上)、B2BUA(把一通呼叫拆成两个独立的腿,主叫腿和被叫腿)、媒体中继。它不是简单的信令转发器,这是关键差别。因为要中继媒体,两端的编码协商就由它来撮合,一端用 OPUS、另一端用 PCMU 它就得转码,转码吃 CPU,这就是后面调优的抓手。
浏览器那一侧负责 getUserMedia 采集、ICE 打洞、DTLS 握手、SRTP 加密,以及把远端轨道渲染到<video>上。三方拼起来,一条完整通话的信令路径是这样:
| 阶段 | 发起方 | 消息 | FreeSWITCH 的动作 |
|---|---|---|---|
| 注册 | 浏览器 | REGISTER | 无认证信息则回 401 |
| 注册 | 浏览器 | REGISTER + Authorization | 校验目录里的 password,回 200 |
| 呼叫 | 主叫 | INVITE(含 SDP offer) | 回 100 Trying,查 dialplan |
| 振铃 | 被叫 | 180 Ringing | 透传给主叫 |
| 应答 | 被叫 | 200 OK(含 SDP answer) | 与主叫腿重新协商,回 200 给主叫 |
| 确认 | 主叫 | ACK | 建立媒体桥 |
| 媒体 | 双方 | DTLS-SRTP | 双向转发 |
| 挂断 | 任一方 | BYE | 拆掉另一条腿 |
表格里最容易被忽略的是"应答"那一行。FreeSWITCH 拿到被叫的 SDP answer 之后不一定原样透传,它会根据自己的 profile 配置重新生成一份 SDP 发给主叫,把媒体地址换成自己的 RTP 端口。这一步正是 NAT 场景下最容易出问题的地方——如果 profile 里没配 ext-rtp-ip,生成的 SDP 里就是服务器内网网卡地址,浏览器拿到之后往一个不可达的 IP 发 SRTP,表现就是"通了但没声音"。
2.2 demo 的目录结构,我一般这么拆
这份 demo 体量不大,但结构如果一开始就摊成一坨,后面接业务会非常难受。我在拆解时看到的分层大致是这样,也是我处理同类项目时习惯的切法:
jssip-demo/ ├── index.html # 拨号盘 + 本地/远端两个 video ├── css/ │ └── app.css ├── js/ │ ├── config.js # ws 地址、域、分机、ICE 服务器 │ ├── ua.js # JsSIP.UA 单例,注册与全局事件 │ ├── call.js # 呼叫生命周期,session 的创建与销毁 │ ├── media.js # 本地采集、远端轨道挂载、设备切换 │ └── ui.js # DOM 绑定,状态到按钮的映射 ├── lib/ │ └── jssip.min.js # 官方发行版,不做二次打包 └── server/ └── nginx.conf # 静态托管 + wss 反代示例config.js单独抽出来是有必要的。本地开发、测试环境、生产环境的 SIP 域和 WebSocket 地址必然不同,写死在ua.js里每次部署都要改代码,出一次线上事故就长记性了。media.js和call.js分开,是因为媒体设备的枚举和切换(换麦克风、换摄像头、开关视频)跟呼叫状态机不是一回事,混在一起写到最后必然是几百行的巨型回调。
2.3 从点击拨号到听见声音,中间发生了什么
按时间顺序把这串动作串一遍,方便你后面看代码时对得上号。用户点"呼叫"按钮,前端先调ua.call(),JsSIP 内部向 FreeSWITCH 发 INVITE,SDP 里带上m=audio和m=video两行。FreeSWITCH 收到后查 dialplan,命中本地分机规则,往被叫的 WebSocket 上发 INVITE。
被叫浏览器弹出接听框,用户点接听后执行session.answer(),浏览器开始 getUserMedia 并生成 SDP answer。FreeSWITCH 收到 answer 后开始与主叫腿协商,同时回 200 给主叫。主叫收到 200 后内部处理 ACK,JsSIP 触发confirmed事件。
两边 ICB 候选交换完、DTLS 握手成功,SRTP 流开始跑。此时主叫侧peerconnection事件里注册的track监听被触发,把远端轨道塞进MediaStream,赋给<video>.srcObject,画面出来。挂断时session.terminate()发 BYE,FreeSWITCH 拆另一条腿,双方收到ended事件。
整条链路上,信令是可靠的(WebSocket 是 TCP),媒体是不可靠的(SRTP 跑在 UDP 上)。所以"注册成功但通话失败"和"通话建立但没声音"这两类故障,排查方向完全相反:前者看 SIP 响应码和认证,后者看 SDP 里的 IP 和防火墙。
3. FreeSWITCH 侧配置:让 5066 端口的 WebSocket 通道真正打开
前端代码写得再漂亮,服务器这头没配好就是白搭。这一步是整个 demo 里最容易卡住新人的地方,因为 FreeSWITCH 的配置文件分散在好几个目录,改错一个位置就是"改完没生效"。
3.1 确认 internal profile 的 ws-binding
FreeSWITCH 的 SIP 监听配置在conf/sip_profiles/internal.xml里。较新的发行包默认已经带了 WebSocket 监听,但版本差异比较大,先确认再说。用fs_cli进去执行:
# 进入控制台 fs_cli # 查看 internal profile 是否已经监听 ws 端口 sofia status profile internal输出里如果有WS-Binding: ws:mod_sofia:5066这样的行,说明已经开着。没有的话就编辑 profile:
<!-- conf/sip_profiles/internal.xml --> <profile name="internal"> <!-- 普通 SIP 的 UDP/TCP 端口,软电话和网关走这里 --> <param name="sip-port" value="5060"/> <!-- 浏览器 JsSIP 走这两个:ws 明文,wss 加密 --> <param name="ws-binding" value=":5066"/> <param name="wss-binding" value=":7443"/> <!-- 关键:让 FreeSWITCH 在 SDP 里填公网地址而不是内网网卡 --> <param name="ext-rtp-ip" value="auto-nat"/> <param name="ext-sip-ip" value="auto-nat"/> <!-- 允许 ICE 候选来自局域网,内网测试环境需要 --> <param name="apply-candidate-acl" value="localnet.auto"/> <!-- WebRTC 端通常不支持 late offer,打开这个更稳 --> <param name="inbound-late-negotiation" value="true"/> <!-- 关闭媒体代理以外的多余动作,demo 阶段保持默认即可 --> <param name="rtp-timeout-sec" value="300"/> </profile>改完必须reloadxml再sofia profile internal restart,只做前者不重启 profile,新端口不会起来。这两条命令的顺序别搞反:
fs_cli -x "reloadxml" fs_cli -x "sofia profile internal restart" fs_cli -x "sofia status profile internal" # 再确认一次参数含义上,ext-rtp-ip决定 SDP 里c=行填哪个 IP,ext-sip-ip决定 Via 和 Contact 头里填哪个 IP。auto-nat会自动探测外网地址,在没有公网 IP 的纯内网环境里反而可能探测错,这时候直接写死成服务器对外的那个 IP 更保险。
3.2 分机目录与拨号计划,两个地方都要动
FreeSWITCH 的分机定义在conf/directory/default/下,默认带了 1000 到 1019 共 20 个。每个分机一个 XML 文件,注册密码写在<param name="password">里,demo 场景一般是 1234。想加一个 2000 分机,复制1000.xml改文件名和<user id="2000">就行。
但这里有个很多人都栽过的坑:只建文件不够,还得让 directory 知道它的存在。conf/directory/default.xml里有一个<group name="default">,下面用<user id="1000"/>到<user id="1019"/>逐个列出。不在这份名单里的分机,即使文件存在也不会被加载,表现就是 REGISTER 一直返回 404 Not Found。加一行就好:
<!-- conf/directory/default.xml --> <group name="default"> <users> <user id="1000"/> <user id="1001"/> <!-- 新增的分机必须在这里登记,否则 404 --> <user id="2000"/> </users> </group>拨号计划在conf/dialplan/default.xml,默认有一条Local_Extension规则,条件是destination_number匹配^(10[0-1][0-9])$,动作是bridge到user/${destination_number}@${domain_name}。如果你加了 2000 这种不在区间内的分机,这条正则匹配不上,呼叫会直接走到public或者挂掉。要么改正则把 2000 也放进去,要么单独写一条 extension:
<!-- conf/dialplan/default.xml,加在 Local_Extension 附近 --> <extension name="local_extension_2xxx"> <condition field="destination_number" expression="^(20\d{2})$"> <!-- 直接桥接到该分机注册的 contact 地址 --> <action application="bridge" data="user/${destination_number}@${domain_name}"/> <!-- 分机不在线时播放提示音,而不是静默超时 --> <action application="playback" data="ivr/ivr-call_rejected.wav"/> </condition> </extension>改完 dialplan 只需要reloadxml,不用重启 profile。验证是否命中,看fs_cli的日志里有没有Dialplan: ... parsing [default->local_extension_2xxx]这一行,命中的规则会打印出来,非常直观。
3.3 媒体相关的几个必看开关
WebRTC 端送上来的是 DTLS-SRTP 加密流,FreeSWITCH 得能正确识别。默认情况下 sofia 会根据 SDP 里的a=fingerprint自动把这条腿标记为 secure media,一般不用手改。但有两个点值得单独确认。
第一个是 ICE。FreeSWITCH 支持 ICE,但默认可能没开全。如果浏览器在公网、服务器在内网做了端口映射,需要让 FreeSWITCH 也发候选,在 profile 里加:
<!-- 让 sofia 在 SDP 里带上 ICE 候选 --> <param name="ice-binding" value=":3478"/>同时防火墙要放行 UDP 3478 以及 RTP 端口段(默认 16384-32768,conf/autoload_configs/switch.conf.xml里的rtp-start-port/rtp-end-port)。端口段开太大不安全,我一般会收窄到 16384-16484 这一百个端口,够几十路并发用,防火墙规则也好写。
第二个是视频编码。FreeSWITCH 默认的switch.conf.xml里codec-prefs可能把 VP8/VP9/H264 排在很后面,如果两端都只支持 VP8 而服务器优先选 H264,就会走一次转码甚至协商失败。demo 阶段最省事的做法是在 profile 里把视频编码统一往前提:
<!-- conf/sip_profiles/internal.xml --> <param name="inbound-codec-prefs" value="OPUS,PCMU,PCMA,VP8,H264"/> <param name="outbound-codec-prefs" value="OPUS,PCMU,PCMA,VP8,H264"/> <param name="inbound-codec-negotiation" value="generous"/>generous这个值的意思是让 FreeSWITCH 在编码协商上更宽容,能接受对端只给出有限编码集的情况。它是 WebRTC 互操作里非常实用的一招,比死磕strict省事得多。
配完这套之后,先在fs_cli里跑一遍完整验证再打开浏览器:
# 1. profile 是否 UP,ws 端口是否在监听 sofia status profile internal # 2. 是否有分机注册上来(浏览器打开页面后执行) show registrations # 3. 打开 SIP 信令跟踪,方便看完整消息流 sofia global siptrace on # 4. 看 RTP 统计,确认媒体真的在跑 show channelsshow registrations的输出里,如果能看到1000对应的contact是一串wss://...地址,说明注册链路通了。这一步没通,前端怎么改都是白费。
4. 前端核心代码:注册、拨号、挂载远端流的三段式
前端代码量不大,但有几个地方必须按顺序来,顺序错了就会出现"事件绑太晚,第一通电话没有画面"这种玄学问题。我按调用顺序拆三段讲。
4.1 UA 初始化:单例、配置、注册事件
UA 对象全局只能有一个,重复创建会往同一条 WebSocket 上叠加多套注册,服务端看到的是同一个分机反复上下线。所以第一件事是把它做成模块级单例:
// js/ua.js // 注意:ws 地址必须是 wss,页面是 https 时 浏览器会拦掉明文 ws const socket = new JsSIP.WebSocketInterface(SIP_CONFIG.wsUrl); const ua = new JsSIP.UA({ sockets: [socket], uri: `sip:${SIP_CONFIG.user}@${SIP_CONFIG.domain}`, password: SIP_CONFIG.password, display_name: SIP_CONFIG.displayName, register: true, // 创建后自动注册 session_timers: false, // 关掉 Session-Timer,避免 90 秒被踢 register_expires: 300, // 注册有效期,内网可以设长一点 connection_recovery_min_interval: 2, connection_recovery_max_interval: 30, }); // 注册成功:这里才是真正可以拨号的时机 ua.on('registered', () => { setUiState('registered'); }); // 注册失败:区分 403(密码错)和 404(分机不存在) ua.on('registrationFailed', (e) => { console.error('注册失败', e.cause); setUiState(e.cause === JsSIP.C.causes.REJECTED ? 'auth_failed' : 'offline'); }); // WebSocket 断了,JsSIP 会自己按退避重连,这里只做 UI 提示 ua.on('disconnected', () => setUiState('offline')); ua.start();session_timers: false是这段里最值得单说的一行。SIP 的 Session Timer 机制要求通话双方定时发UPDATE或re-INVITE刷新会话,浏览器端 JsSIP 的实现跟部分 FreeSWITCH 版本的刷新节奏对不齐,表现就是通话到 90 秒左右毫无征兆地断掉,日志里只有一条BYE。demo 阶段直接关掉,生产环境如果要开,记得把 FreeSWITCH 侧的enable-session-timer一起调,两边都对齐。
connection_recovery那两个参数控制断线重连的退避区间,内网环境设小一点,用户感知快;公网环境设大一点,避免网络抖动时疯狂重连把服务端打满。
4.2 发起呼叫:媒体约束和 SDP 里那两个 m 行
ua.call()的第二个参数里,mediaConstraints决定了浏览器要采集什么,也间接决定了 SDP 里会出现几个m=行。这里写video: true,SDP 才会有m=video,对端才可能回视频;写成video: false就是纯音频通话,再想加视频只能重协商。
// js/call.js function startCall(targetNumber, localVideoEl) { const target = `sip:${targetNumber}@${SIP_CONFIG.domain}`; const session = ua.call(target, { // 采集约束:分辨率别贪大,demo 阶段 640x480 足够,太大编码扛不住 mediaConstraints: { audio: true, video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 } }, }, pcConfig: { iceServers: SIP_CONFIG.iceServers, // [{ urls: 'stun:<stun-host>:3478' }] iceTransportPolicy: 'all', }, // 主叫侧声明自己愿意收音频和视频,影响 offer 的 m 行方向 rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: true }, }); // 本地预览:peerconnection 一建好就把本地流挂上,别等接通 session.on('peerconnection', (data) => { const pc = data.peerconnection; pc.addEventListener('track', (ev) => attachRemoteTrack(ev, 'remote-video')); const localStream = session.connection ? session.connection.getLocalStreams()[0] : null; if (localStream && localVideoEl) { localVideoEl.srcObject = localStream; localVideoEl.muted = true; // 本地预览必须静音,否则回声 } }); // 振铃中/已接通/结束,三个状态分别驱动 UI session.on('progress', () => setUiState('ringing')); session.on('confirmed', () => setUiState('talking')); session.on('ended', () => { setUiState('registered'); disposeSession(session); }); session.on('failed', (e) => { console.error('呼叫失败', e.cause); setUiState('registered'); disposeSession(session); }); return session; }localVideoEl.muted = true这一行如果不加,本地预览会把麦克风采集的声音从扬声器放出来,直接啸叫。这是本地预览的铁律,跟通话质量无关,纯粹是物理回声。
rtcOfferConstraints在老版本浏览器上有兼容性差异,新版 JsSIP 对它的处理是转成RTCOfferOptions传给createOffer。如果发现某些浏览器上视频协商不稳定,把这行去掉,靠mediaConstraints控制 offer 方向也能工作。
还有一个容易被忽略的点:getUserMedia失败时ua.call()不会抛异常,而是在 session 上触发getusermediafailed事件。用户拒绝了摄像头授权,页面看起来什么都没发生,其实是这个事件没人监听:
session.on('getusermediafailed', (e) => { console.warn('设备采集失败', e.error); // 常见原因:权限被拒、设备被其它标签页占用、非 https 环境 setUiState('media_error'); });4.3 接听与远端流挂载:track 事件才是正确入口
接听方的逻辑走newRTCSession这个 UA 级事件。注意必须判断originator,否则自己发起的呼叫也会进这个回调,导致重复绑定事件。
// js/ua.js ua.on('newRTCSession', (data) => { // originator === 'remote' 表示来电,本地发起的不会走这里 if (data.originator !== 'remote') return; const session = data.session; session.on('peerconnection', (pcData) => { const pc = pcData.peerconnection; // 远端轨道到达时统一处理,音视频都从 track 里拿 pc.addEventListener('track', (ev) => attachRemoteTrack(ev, 'remote-video')); }); session.on('ended', () => setUiState('registered')); // 保存起来,等用户点"接听" window.__incomingSession = session; showIncomingDialog(data.request.from.uri.user); }); // 用户点接听 function answerCall() { const session = window.__incomingSession; if (!session) return; session.answer({ mediaConstraints: { audio: true, video: true }, pcConfig: { iceServers: SIP_CONFIG.iceServers }, }); session.on('confirmed', () => setUiState('talking')); }远端轨道挂载单独抽一个函数,因为一个MediaStream里可能先来音频轨、再来视频轨,每次都新建MediaStream会导致画面闪烁:
// js/media.js const remoteStreams = new Map(); // 每个 video 元素维护一条 MediaStream function attachRemoteTrack(ev, videoElId) { const el = document.getElementById(videoElId); if (!el) return; if (!remoteStreams.has(videoElId)) { remoteStreams.set(videoElId, new MediaStream()); } const stream = remoteStreams.get(videoElId); stream.addTrack(ev.track); // 只在第一次赋值,后续 addTrack 会自动反映到已绑定的 srcObject if (el.srcObject !== stream) { el.srcObject = stream; } // 自动播放策略兜底:被拦截时静音重试一次 el.play().catch(() => { el.muted = true; el.play().catch(() => console.warn('需要用户手势才能播放')); }); }el.play()的兜底是必须的。Chrome 的自动播放策略会拦掉没有用户手势的有声播放,表现是视频画面上来了但一点声音没有,控制台一行DOMException: play() failed藏在角落里。静音重试是通用解法,用户在页面上随便点一下之后再把muted关掉即可。
4.4 挂断之后必须做的事:资源回收
挂断不只是调terminate()。不回收的后果是摄像头指示灯一直亮着、麦克风被占用导致下一通电话打不出去,这个坑几乎所有新手都踩过:
// js/call.js function disposeSession(session) { try { if (session && session.isEstablished && session.isEstablished()) { session.terminate(); } } catch (e) { console.warn('终止会话异常', e); } // 停掉本地采集轨道,摄像头灯才会灭 if (session && session.connection) { session.connection.getSenders().forEach((sender) => { if (sender.track) sender.track.stop(); }); session.connection.close(); } // 清掉 video 元素上的流引用,避免内存泄漏 document.querySelectorAll('video').forEach((el) => { if (el.id === 'local-video') { el.srcObject = null; } }); remoteStreams.clear(); }session.connection是 JsSIP 暴露的 RTCPeerConnection 引用,3.x 版本里通过它拿到 sender 列表。track.stop()这一步不做,浏览器会认为页面仍在使用摄像头,标签页上的红点一直亮,用户会以为程序在偷偷录像。
5. 避坑与排查:注册通了却没声音的五种翻车
这一类问题的共同点是有明确的排查路径,只是新人不知道从哪下手。下面五条是我在调试这套 demo 时按出现频率排的顺序。
5.1 五种典型现象,逐条对号入座
现象一:页面在 https 下,浏览器控制台报 Mixed Content,WebSocket 直接连不上。原因是浏览器安全策略禁止 https 页面加载明文ws://资源。解决办法是把 FreeSWITCH 的wss-binding打开,用 7443 端口,并给它配一张证书。自签证书在测试环境可以临时接受一次,生产环境必须用正规签发机构签的。前端地址从ws://host:5066改成wss://host:7443,同时确认证书的 CN/SAN 跟访问域名一致,否则浏览器会在握手阶段直接断掉。
现象二:注册成功、200 OK、振铃、接听,一切正常,就是双方都没声音。九成是 SDP 里填了内网 IP。抓一次 SIP 消息就能确认:sofia global siptrace on之后看 200 OK 里的c=IN IP4 x.x.x.x,如果这个 IP 是192.168.x.x或者10.x.x.x而浏览器在另一网段,包就发丢了。解决是在 profile 里设ext-rtp-ip和ext-sip-ip,值填公网 IP,或者内网测试时填服务器对外的那个网卡地址。改完sofia profile internal restart,一定要重启 profile,reloadxml不够。
现象三:音频正常,视频黑屏,远端<video>尺寸是 0。先看 SDP 里有没有m=video这一行。如果只有m=audio,说明主叫的mediaConstraints里video是 false,或者被叫的answer()没带视频约束。两边的采集约束都得允许视频。如果有m=video但仍是黑屏,看chrome://webrtc-internals里 inbound-rtp 的framesReceived是不是 0;为 0 说明视频包没到,通常是带宽协商或是编码不匹配(一端 VP8 一端只出 H264)。在 profile 里把inbound-codec-prefs统一成OPUS,PCMU,VP8,H264能规避大半。
现象四:新增的 2000 分机怎么都注册不上,日志里是 404 Not Found。分机 XML 文件建了但没在conf/directory/default.xml的<group name="default">里登记,directory 压根没加载它。加上<user id="2000"/>再reloadxml。如果返回的是 403 Forbidden,那是密码不对,检查分机文件里的<param name="password">和前端填的是否一致;如果返回 401 但带WWW-Authenticate,那只是正常的首次挑战,浏览器会自动带 Authorization 重发。
现象五:通话 90 秒左右准时断线,日志里只有一条 BYE,没有任何错误。这是 Session Timer 在作怪。前端session_timers: true而 FreeSWITCH 侧的刷新间隔配置不一致,或者用户代理不支持UPDATE方法,刷新失败后按协议要求主动挂断。要么前端关掉session_timers,要么把 FreeSWITCH profile 里的enable-session-timer设为false统一节奏。两边的值必须对齐,只改一边等于没改。
5.2 排查手段:从服务端日志到浏览器内部页
排查顺序建议从服务端往客户端走,因为服务端的日志更完整。
第一步,打开 SIP 跟踪,把所有消息落到控制台:
fs_cli -x "sofia global siptrace on" fs_cli -x "console loglevel debug"第二步,看分机注册状态和活动通道:
fs_cli -x "show registrations" fs_cli -x "show channels" fs_cli -x "sofia status profile internal"第三步,如果通道建起来了但没媒体,抓一次完整的通话详情:
# 先拿到 uuid,再 dump 整条通道的所有变量和 RTP 统计 fs_cli -x "uuid_dump <uuid>"uuid_dump的输出里重点看read_codec、write_codec、rtp_audio_in_packet_count、rtp_audio_out_packet_count。如果 in 有计数、out 是 0,说明只收不发,通常是另一条腿的 SDP 地址有问题;如果两边都是 0,媒体压根没建立起。
第四步,转到浏览器。新开一个标签页输入chrome://webrtc-internals,找到当前那条 PeerConnection,看inbound-rtp里的packetsReceived、bytesReceived、framesDecoded。这三个数能直接告诉你媒体有没有到浏览器:packetsReceived涨但framesDecoded不涨是解码问题,两个都不涨是网络问题。ICE 状态在iceConnectionState里,卡在checking就是候选交换失败。
提示:抓 SIP 日志的时候控制台输出会非常多,把
console loglevel临时调到debug,用完记得调回notice,不然日志盘很快就被写满。
6. 二次开发:把 demo 变成一个真正能用的呼叫面板
demo 里通常只有"拨号""接听""挂断"三个按钮,真接到业务系统里,第一个撞上的问题是状态管理。JsSIP 的事件是异步且可能乱序的,UI 全靠setUiState散落各处,加到第四个按钮就会开始互相覆盖。
6.1 用一个显式状态机管住所有事件
我一般会把会话状态收敛成一个枚举,所有事件只允许改状态,不允许直接操作 DOM:
| 状态 | 触发事件 | 面板表现 |
|---|---|---|
| idle | 页面加载完成 | 拨号按钮可点,其余置灰 |
| registering | ua.start()之后 | 显示"连接中",禁止拨号 |
| registered | ua.on('registered') | 可拨号,可接收来电 |
| calling | 点击拨号 | 显示"呼出中",挂断可点 |
| ringing | session.on('progress') | 播放回铃音,挂断可点 |
| incoming | newRTCSession | 弹接听框,静音、拒接可点 |
| talking | session.on('confirmed') | 显示计时器,静音、挂断可点 |
| ended | ended/failed | 回到 registered,清理资源 |
对应的实现就是把所有 UI 更新集中到一个 reducer 里:
// js/ui.js const StateActions = { idle: { dial: false, hangup: false, answer: false, mute: false }, registering: { dial: false, hangup: false, answer: false, mute: false }, registered: { dial: true, hangup: false, answer: false, mute: false }, calling: { dial: false, hangup: true, answer: false, mute: false }, ringing: { dial: false, hangup: true, answer: false, mute: false }, incoming: { dial: false, hangup: true, answer: true, mute: false }, talking: { dial: false, hangup: true, answer: false, mute: true }, }; function setUiState(next) { const actions = StateActions[next]; if (!actions) return; document.getElementById('btn-dial').disabled = !actions.dial; document.getElementById('btn-hangup').disabled = !actions.hangup; document.getElementById('btn-answer').disabled = !actions.answer; document.getElementById('btn-mute').disabled = !actions.mute; document.getElementById('state-label').textContent = next; currentState = next; }这样做的好处是状态可测。把StateActions单独导出,写几个断言就能覆盖所有非法状态跳转,不用开浏览器点。后面加通话计时、加静音、加转接,都只是往表里加一列。
6.2 会话池与多路通话
呼叫面板迟早要做"保持""切换""多方"。这一步的关键是别用全局变量存 session,用 Map 按 call id 管理:
const sessionPool = new Map(); // key: session.id, value: { session, target, pc } function registerSession(session, target) { sessionPool.set(session.id, { session, target, pc: null, startedAt: null }); session.on('peerconnection', (data) => { sessionPool.get(session.id).pc = data.peerconnection; }); session.on('confirmed', () => { sessionPool.get(session.id).startedAt = Date.now(); }); session.on('ended', () => { const item = sessionPool.get(session.id); if (item) { item.pc && item.pc.close(); sessionPool.delete(session.id); } }); } function hangupAll() { [...sessionPool.values()].forEach(({ session }) => { try { session.terminate(); } catch (e) {} }); sessionPool.clear(); }会话池带来的直接收益是页面关闭前能统一清理。window.addEventListener('beforeunload')里调一次hangupAll(),用户误关标签页时服务端不会留下僵尸通道,FreeSWITCH 的show channels不会一堆幽灵会话挂着占端口。
多方通话不建议在前端硬拼 Mesh(每多一路就多一条上行,三路以上上行带宽就炸),交给 FreeSWITCH 的 conference 模块更实际。在 dialplan 里加一条规则把带conference前缀的号码转到会议室,前端拨3000就能进同一间房:
<!-- conf/dialplan/default.xml --> <extension name="local_conference"> <condition field="destination_number" expression="^3(\d{3})$"> <action application="answer"/> <action application="conference" data="$1@default+wideband"/> </condition> </extension>前端侧几乎不用改,只需要把视频栈从"两路对讲"理解成"多路混流",track监听照旧,只是会收到多次。音频由 conference 模块混音后下发,每一路收到的是一份混合流,前端不需要自己处理混音。
6.3 录制与调试开关
内测阶段给每通电话留一份录音,出了问题能回听,比对着日志猜快十倍。FreeSWITCH 的uuid_record能在通话中随时开关:
# 通话建立后执行,开始录制,写到指定目录 fs_cli -x "uuid_record <uuid> start /var/lib/freeswitch/recordings/<uuid>.wav both" # 挂断前手动停止,或者设置 profile 里的 record-path 自动录 fs_cli -x "uuid_record <uuid> stop all"更省事的做法是在 dialplan 的呼叫规则里加set指令,在 bridge 之前自动开录:
<action application="set" data="RECORD_STEREO=true"/> <action application="set" data="record_path=/var/lib/freeswitch/recordings/"/> <action application="set" data="record_name=${strftime(%Y%m%d-%H%M%S)}-${caller_id_number}.wav"/> <action application="record_session" data="${record_path}${record_name}"/>注意记录盘的容量。一路 OPUS 转 WAV 的双声道通话大约几分钟就上兆,几十路并发跑一天,几 G 就没了。我一般会加一条定时清理任务,只保留最近三天的录音。
最后说个血泪经验。这套 demo 我在三个不同网络环境下跑过,每次都以为是代码问题,结果两次是防火墙没放行 UDP 的 RTP 端口段,一次是 profile 改完忘记sofia profile internal restart。从那以后我每次改完 FreeSWITCH 配置,都强制走一遍「重启 profile → show registrations → 打一通 10 秒的测试电话 → uuid_dump 看 RTP 计数」这个四步流程,确认媒体真的有收发再往下写前端。看着啰嗦,但比在浏览器里对着黑屏猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取