基于SIP.js与FreeSWITCH的浏览器WebRTC电话开发实战

发布时间:2026/10/15 7:19:38
基于SIP.js与FreeSWITCH的浏览器WebRTC电话开发实战 简介这是一份基于sip.js与FreeSWITCH的WebRTC电话通信网页端示例面向需要在浏览器中实现语音通话、呼叫中心及软电话功能的前端或通信开发者。压缩包共4个文件包含1个HTML页面、1个CSS样式、1个核心JavaScript逻辑内含sip-0.7.8.js以及1个README说明整体仅79KB结构精简便于快速定位关键代码。目前已有2315人学习下载。示例在谷歌浏览器下可直接运行只需参照README修改自身分机号、密码与FreeSWITCH服务器地址即可验证呼入、呼出、转移、保持等电话操作。代码结合sipjs.com官方参考能帮助理解SIP注册、通话控制、WebRTC媒体传输等核心流程同时为二次开发提供基础框架适合希望低成本搭建网页端电话应用的中级开发者学习与复用。1. 为什么在浏览器里打电话我先选了 sipjsFreeSWITCH早年做网页端呼叫中心最让人头疼的不是业务逻辑而是“怎么让浏览器里的坐席真正把电话打出去”。装软电话插件会卡在权限管控用传统 SIP 协议又过不了浏览器的 TCP/UDP 限制最后我把目标锁定在 sipjsFreeSWITCH 这套组合上FreeSWITCH 负责处理 SIP 信令和媒体桥接SIP.js 在浏览器里扮演 SIP UA靠 WebSocket 传递信令靠 WebRTC 传输音频整套链路不需要任何额外插件。这个示例资源把呼入、呼出、呼叫转移、呼叫保持四个高频动作都封装成了可运行的前端 Demo适合两类人一类是从零做 WebRTC 话务台、想少走弯路的开发者另一类是已经在用 FreeSWITCH 做电话线路、想在浏览器侧快速接出一套坐席工作台的从业者。2. 对接与注册先让浏览器在 FreeSWITCH 里“活起来”2.1 为什么 WebRTC 信令必须走 WSS而不是裸 WS先讲一个容易踩晕的选择题SIP.js 的 transport 为什么通常配wss://而不是ws://。浏览器安全策略给 WebRTC 划了一条硬线getUserMedia和 WebSocket 都属于受管控能力。页面是 HTTPS 时浏览器默认不允许混用非加密的 WebSocket也就是说如果你的业务页面是https://那 SIP 信令就只能是wss://否则控制台会直接报 Mixed Content 错误。这也是资源示例里用 WSS 的根本原因。FreeSWITCH 侧对应的是mod_sofia里的 WebSocket 绑定。常见配置是在sip_profiles/internal.xml里看到这两行param namews-binding value:5066/ param namewss-binding value:7443/5066是明文 WebSocket 端口7443是加密 WebSocket 端口。生产环境我会建议直接忽略前者只留7443并且把证书路径指到 FreeSWITCH 的 TLS 配置上。自己本地调试时可以临时用明文但一旦页面上了 HTTPS就得切回 WSS。TLS 证书还有一个容易被忽略的点WebRTC 媒体面本身用 DTLS-SRTP 加密但信令面的 WSS 证书依然是浏览器校验的“门禁”。自签证书在本地开发时能凑合部署到公网就必须换成受浏览器信任的证书否则 Chrome 会在握手阶段直接拒绝连接。2.2 初始化 SIP.js UA注册参数不该只抄四个选项拿到这个项目资源之后我建议先看它的初始化文件。SIP.js 的UA对象是整条呼叫链路的根注册参数直接影响后续注册、心跳、重连行为。下面这段是示例资源里最核心的注册逻辑我加了注释// 依赖 sip.js 0.20.x 版本按 UMD 方式引入 const socket new SIP.WebSocket(wss://pbx.example.com:7443); const config { // uri 是坐席号码domain 部分必须和 FreeSWITCH 上网关域名一致 uri: sip:1001pbx.example.com, displayName: 坐席1001, password: 1234, // 同一个 UA 一般绑定一个 socket多账号注册建议各自建 UA sockets: [socket], transportOptions: { maxReconnectionAttempts: 4, connectionTimeout: 15 }, // 启动后自动注册而不是手动调用 ua.register() register: true, // NAT 穿透后防止映射老化建议设置心跳 keepAliveInterval: 30 }; const ua new SIP.UA(config); ua.start(); ua.on(registered, () { console.log([UA] registered, aor, config.uri); }); ua.on(registrationFailed, (response) { console.error([UA] registration failed, response.status_code, response.reason_phrase); });这段代码里有几个参数经常被新人改错。uri里的分机号必须跟 FreeSWITCH 的directory配置对应上不是随便写一个号码就行。password指 SIP 注册密码和 Web 登录密码没关系。transportOptions.maxReconnectionAttempts控制网络抖动时的自动重连次数设成0会导致断网后 UA 一直停在半死状态。keepAliveInterval是保活时间单位秒默认不开启但生产环境建议显式设置防止 NAT 映射超时后媒体单通。register: true配合ua.start()的意思是UA 启动后立刻走 REGISTER 流程。如果你在某些旧项目里看到ua.register()被单独调用那也是合法的但要注意避免和自动注册重复触发否则会出现一个 UA 发出两个 REGISTER 请求的情况。2.3 注册失败时先看状态码再去翻 FreeSWITCH 日志注册链路如果没通最常见的不是 UA 配置错而是 FreeSWITCH 侧网关字段不匹配。下面这张表可以帮你在排错时按图索骥SIP 状态码含义优先检查项401 / 407需要认证分机号、密码、网关 domain 是否一致403被 ACL 拒绝internal profile 的apply-inbound-acl是否允许当前来源 IP423注册间隔太短expires是否被设成极小值404号码不存在FreeSWITCH 里是否建了对应 endpoint408请求超时WSS 端口是否通防火墙是否拦截 7443登录 FreeSWITCH 后用fs_cli看注册实时日志也很方便fs_cli -x sofia status profile internal fs_cli -x sofia loglevel all 9sofia loglevel all 9会打印详细的 SIP 报文能直接看到 FREE SWITCH 收到 REGISTER 后回的是哪条报错。这个习惯比在浏览器端猜半天要高效得多。2.4 FreeSWITCH 侧最小改动不用动拨号计划也能注册很多初次接触这个组合的人以为要在 FreeSWITCH 里大改 XML其实跑通一个注册只需要三步确认 internal profile 开了ws-binding/wss-binding在 directory 里建好分机号再配置好 TLS 证书路径。拨号计划是呼叫阶段的事注册阶段不涉及。项目资源里如果附带了 FreeSWITCH 配置片段优先对比这三处别去乱抄网上那些动不动往vars.xml塞一堆全局变量的配置。3. 呼入呼出、保持、转移把四个高频动作落进代码3.1 呼出invite 一个会话媒体流要挂在 trackAdded 上网页端外呼看起来只是调一个按钮但实际要处理的是“创建会话、捕获本地媒体、协商 SDP、绑定远端音频”这一串连锁反应。下面这段是示例资源里的外呼核心代码// 发起外呼到分机 2002 function makeCall (targetNumber) { const target sip: targetNumber pbx.example.com; const session ua.invite(target, { media: { constraints: { audio: true, video: false } } }); // 会话进入振铃前适合更新 UI 为“呼出中” session.on(connecting, () { setUIState(dialing); }); // 收到 180 Ringing 或 183 Session Progress session.on(progress, () { setUIState(ringing); }); // 对方接听后把远端音轨挂到页面的 audio 元素上 session.on(trackAdded, () { const stream session.sessionDescriptionHandler.remoteStream; attachMediaStream(audioEl, stream); }); session.on(accepted, () { setUIState(active); }); // 失败时要读 cause 字段常见值是 DENIED、NO_ANSWER、BUSY session.on(failed, (e) { console.error([call] failed cause, e.cause); setUIState(idle); }); session.on(ended, () { setUIState(idle); }); }这里有个细节media.constraints里的audio: true, video: false告诉getUserMedia只采集麦克风。有些同事习惯把video: true一起传结果浏览器弹起摄像头授权框座席一脸茫然。trackAdded是音频绑定的正确时机如果挂在accepted之前监听远端的MediaStreamTrack还没有就绪拿不到流。如果本地要试听自己的声音可以在发起呼叫前用navigator.mediaDevices.getUserMedia拿一个本地流单独设置本地音频元素。别把本地音频直接塞进session的媒体会话描述处理器里那会让对方听到回声。3.2 呼入收到 invite 事件后不要把 accept 写成同步操作呼入和呼出的触发位置不同呼出是主动ua.invite呼入是监听ua.on(invite)。示例如下ua.on(invite, (session) { // 先展示来电弹窗由坐席决定接听还是拒接 setIncomingUI(session); // 设定 30 秒超时超过后自动拒接 const rejectTimer setTimeout(() { session.terminate(); }, 30000); // 坐席点击“接听”时调用 window.acceptIncomingCall () { clearTimeout(rejectTimer); session.on(trackAdded, () { const stream session.sessionDescriptionHandler.remoteStream; attachMediaStream(audioEl, stream); }); session.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); }; });session.accept()在 SIP.js 内部会走一轮 200 OK ACK 的握手不是“点完立刻接通”。因此别在accept()之后立刻去读remoteStream一定要等trackAdded事件触发后再绑定音频。还有一个容易踩的坑如果坐席在来电弹窗期间切走了 Tabsession对象会被 JS 垃圾回收吗不会。session是独立对象只要还持有引用就没事。真正危险的是你在ua.on(invite)回调里写了let session却忘了把它存到全局状态中心后续接听函数拿不到这个对象。3.3 保持与取回hold 不是一个布尔开关呼叫保持的底层是重新协商 SDP把媒体方向从sendrecv改成sendonly对远端来说则是recvonly。SIP.js 封装了session.hold()和session.unhold()用法朴素function holdCall (session) { session.hold(); setUIState(held); } function unholdCall (session) { session.unhold(); setUIState(active); } // 对方把他的腿保持住时UA 会触发 hold 事件 session.on(hold, () { setUIState(remoteHeld); });但资源里如果只有这两行是不够上生产的。保持时 FreeSWITCH 会向被保持方播放保持音乐前提是拨号计划里配置了moh相关的返回动作。常见做法是在 FreeSWITCH 的拨号计划里用一个 execute_on_hold 变量指定播放声音或者让hold_music参数指向local_stream://moh。浏览器端不必手动播放任何本地音乐那会造成回声和管理混乱。恢复通话时还有个微妙点如果对方在你hold期间已经挂断FreeSWITCH 会向 UA 发 BYE。此刻前端如果只监听了accepted和ended可能会漏掉“从 held 回到 active 时发现没人了”的边界。建议在unhold()之后补一个确认动作扫描 session 的state若已经是terminated就立刻清理 UI。这种边界示例资源里如果没有建议你自己补上。3.4 转移盲转和协商转要选对 Replaces 头网页端坐席最常见的转移需求是“当前通话 A 转给分机 B”。SIP.js 里转移基于 REFER 方法分两种盲转直接把 A 与会话的 dialog 丢给 B坐席不做咨询马上挂断。代码最简function blindTransfer (session, targetNumber) { session.refer(sip: targetNumber pbx.example.com); }协商转则是坐席先和 B 建立一通新的咨询通话再把原通话 A 用 Replaces 头替换到 B 上。这个场景下需要把咨询会话传进去function attendedTransfer (sessionA, consultSession, targetNumber) { sessionA.refer(sip: targetNumber pbx.example.com, { replaces: consultSession }); }这里的replaces参数会生成形如Replaces: call-id;to-tag;from-tag的头部FreeSWITCH 收到后会把原来的通话腿替换到新目的地。如果只用了session.refer(targetNumber)而没带replaces即使你先打通了咨询电话最终行为也只是向 B 发了一个全新 INVITE原来的 A 还挂在坐席身上变成“三方各聊各的”的鬼状态。挑项目资源时我会专门看它演示的是哪种转移。很多网页端的“转移”按钮只做了盲转代码里根本没有replaces处理。如果你的业务要求先咨询再转接只有盲转的示例是不完整的。4. 呼叫状态机不把 session 生命周期管住UI 会先疯掉4.1 用一个状态中心收拢 session 生命周期浏览器同时只有一个电话在桌面上时很多人会随手写几个零散的 state 变量isCalling、isRinging、isOnHold。结果一通呼叫里三四个布尔变量互相组合状态还没翻完 UI 已经乱了。我一般在项目里让这个资源示例背靠一个最小的状态中心const callState { current: idle, // idle | dialing | ringing | incoming | active | held | transferring | ended session: null, transferTarget: , set (next) { this.current next; renderToolbar(); // 根据状态重新渲染通话按钮组 } }; // 每次拿到 session 时同步存进来 ua.on(invite, (session) { callState.session session; callState.set(incoming); });这个callState.set()是唯一修改 UI 状态的地方。好处是无论事件从哪个方向进来最终 UI 都以callState.current为准而不是去猜一组布尔值的组合状态。session对象也要在状态中心里保持引用。否则外呼后把 session 交给某个局部变量等unhold事件回来时你已经找不到该调用session.unhold()的实例了。4.2 关键事件与状态转移表以及两个兜底动作下面的表是实际操作中最常用的转移骨架事件来源都是 SIP.js 的Session和UA事件当前状态下一个状态备注用户点击外呼idledialingua.invite已发出progress / progressdialingringing有 180 或 183 回来acceptedringingactive媒体流可用绑定 audioended / failed任何非 idleidle呼叫释放清理音轨session.on(hold)activeremoteHeld对方按住用户点击保持activeheld本地发起 re-INVITE用户点击转移按钮activetransferring发送 REFER对方 BYEheld / activeidle无论如何都回 idle兜底动作有两类必须写死。一是通话结束时强制释放媒体资源session.on(ended, () { stopTracks(stream); // 遍历流内的 track 并 track.stop() callState.set(idle); });二是failed事件里读取e.cause如果是DENIED要给坐席提示“对方拒绝接听”而不只是默默清零。示例资源如果只贴了事件名没写 cause 处理建议自己补。4.3 状态机不管“传输层断开”的坑状态机只能管 SIP 会话管不了 WebSocket 本身。网络切换时UA 可能已经和 FreeSWITCH 断开但浏览器侧session仍停留在active这会造成“电话没挂但谁也听不见”的假活状态。要补一手传输层监听ua.transport.on(disconnected, () { if (callState.current active || callState.current held) { console.warn([transport] disconnected during active call); } callState.set(idle); });注意不要在这里直接调用session.terminate()因为 transport 断开时别确定 SIP 消息还能否送达。正确做法是先置 UI 为 idle然后让 UA 自己走重连流程重连成功后由上层业务决定要不要恢复呼叫。5. 避坑WebRTC 网页电话的五个常见翻车点5.1 WSS 一直连不上控制台报证书无效现象本地把 FreeSWITCH 跑在wss://192.168.x.x:7443用自签证书Chrome 打开页面后 UA 永远停在connecting控制台出现ERR_CERT_AUTHORITY_INVALID。原因浏览器的 WebSocket 和 WebRTC 共用一套证书信任链自签证书不被信任时信令握手直接失败注册都走不到。解决本机调试改用明文 WebSocket开发页面同时改成http://localhost。一定要上 HTTPS 环境联调时把 FreeSWITCH 的 WSS 绑到一台有受信任证书的反向代理后面或者在 FreeSWITCH 里正确配置tls-cert-dir。我从那以后调试阶段一律先跑明文只有排到媒体问题才回切 WSS。5.2 iPhone 上点了外呼按钮却没有任何声音现象坐席用 iPhone Safari 打开话务台点外呼按钮后页面有接通提示但就是听不到对方说话对方也听不到坐席。原因iOS 的getUserMedia强制要求用户手势触发外呼逻辑如果写在某个回调或自动任务里麦克风权限拿不到媒体流就无法建立。解决外呼入口必须绑定在 onclick、touchend 这类用户手势事件里同时给 audio 元素加上playsinline属性避免 iOS 把它当全屏播放器弹出来。audio idremoteAudio autoplay playsinline/audio这种问题不是 SIP.js 的 bug是移动端浏览器故意限制的。示例资源如果在移动端表现不佳优先查这一条。5.3 协商转之后转出去的客服还在通话中现象坐席点完协商转系统提示“转接成功”但坐席端仍显示 active被转方那边也有嘟声但不进入正常通话。原因很多网页端示例只发了 REFER没有生成带Replaces头的替换请求。FreeSWITCH 收到裸 REFER 后无法把它和原通话 dialog 对上于是走了“再建一路新呼叫”的逻辑。解决按上面 3.4 节的方式在session.refer(target, { replaces: consultSession })里传咨询会话对象。如果你的 SIP.js 版本较老可能需要手动拼Replaces: call-id;to-tagto-tag;from-tagfrom-tag头并塞进extraHeaders。5.4 坐席空闲 5 分钟后外呼能接通但声音单向现象浏览器保持话务台不动隔十分钟再外呼对方接听后只听到坐席声音坐席听不到对方。原因NAT 映射超时。浏览器和 FreeSWITCH 之间的 RTP 地址是 DTLS-SRTP 协商出来的空闲久了 UDP 映射老化SIP 层还在媒体层已经不通。解决给 UA 开启keepAliveInterval同时确保 FreeSWITCH 侧rtp-ip和sip-ip不是内网环回地址见第 6 章的配置片段。还有一个排查技巧声音单通时立刻在 FreeSWITCH 里输入sofia status profile internal看媒体 IP 是否还是客户端出口 IP如果不是那多半是 NAT 映射变了。5.5 保持恢复后UI 状态和实际通话脱节现象点恢复按钮后通话恢复但 UI 仍显示“已保持”或者恢复后对方直接挂断UI 却卡在 active。原因session.unhold()是异步的恢复流程要重新协商 SDPEM 完成前对方可能已经 BYE。前端只监听hold/unhold事件而没监听ended状态被留在半路。解决把unhold()放到一个 Promise 包装里在ended和failed中都强行回写callState.set(idle)不要相信“下一次点击按钮会修正状态”。示例资源若在这方面没处理我会建议你把这个兜底逻辑当成第一个改造点。6. 上生产前先把媒体 IP、证书、心跳和日志这四件事压稳很多网页端呼叫示例能在本地跑通一上公网就翻车原因往往不在 SIP.js 本身而在 FreeSWITCH 的媒体地址配置。!-- 替换成真正的公网出口 IP不要用内网地址 -- param namertp-ip value203.0.113.10/ param namesip-ip value203.0.113.10/如果你部署在一台 NAT 后面的服务器上rtp-ip必须写公网 IP 或配置正确的 STUN 地址否则浏览器协商完 SDP 后媒体包会发给一个内网 IP表现就是信令正常、注册正常、一接电话就双双无声。这个坑我至今记得某次在生产机上忘了改rtp-ip现场排查了两小时。证书也要放进发布清单。WSS 证书有效期、反向代理证书、WebRTC 域名白名单这三样建议单独做检测项每个月跑一遍。心跳策略是最后一道保险。生产环境我会把keepAliveInterval设在 25 到 30 秒之间同时在前端做两层保活一层是 SIP OPTIONS 或 Ping 消息另一层是 WebSocket 应用层心跳两者独立。如果只有 SIP 层心跳FreeSWITCH 和浏览器之间的一段网络还是可能出现“假在线、真断流”的情况。日志方面不要全依赖console.log。我会让每个呼叫动作带着call-id和当前状态打印成一行结构化日志收到failed或ended时单独记录cause。之后再遇到“用户说打不通”的反馈直接在日志里按 call-id 检索一分钟就能定位是 FreeSWITCH 拒接还是浏览器端拿不到媒体流。这套示例资源下载后我建议的跑通顺序是先改第 2 章的注册参数确认 FreeSWITCH 日志里能看到 REGISTER 成功再把第 3 章的外呼和呼入 Demo 跑起来最后把第 4 章的状态中心表和转移代码一起放进业务代码。四个动作里协商转移最容易在真实场景里暴露问题务必按 5.3 节的因果去验证。上面提到的那次 RTP 事故之后我每次改完 FreeSWITCH 配置都会固定执行一遍fs_cli -x sofia status profile internal检查媒体 IP 是否指向预期地址这个习惯一直留到现在。希望这些细节能帮你把 WebRTC 通话链路跑得比预料中顺利。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询