news 2026/10/20 11:31:56

sipjs+FreeSWITCH+WebRTC:网页端电话应用开发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
sipjs+FreeSWITCH+WebRTC:网页端电话应用开发实战

简介:这份资源是面向WebRTC语音通信开发者的实战示例包,聚焦sipjs与FreeSWITCH的整合应用,解决网页端软电话从零搭建的问题。内容覆盖电话呼入、呼出、转移、保持等核心呼叫控制功能,适合具备一定前端与SIP协议基础、希望快速验证WebRTC通话链路的开发者参考。压缩包共4个文件,包含1个html页面、1个css样式、1个js脚本以及1个txt说明文档,整体约79KB,结构精简,其中html承载界面与呼叫逻辑,js负责SIP信令交互,css处理样式,txt提供使用说明。在谷歌浏览器下运行后,只需将分机号、密码与服务器地址替换为自己的配置,即可直接进行功能测试,省去从零摸索信令流程的时间。目前已有2315人学习下载,可作为理解sipjs呼叫控制与FreeSWITCH对接的轻量级参考范例。

1. 浏览器里直接打电话:sipjs+FreeSWITCH+WebRTC 网页端电话应用拆解

你有没有遇到过这种需求:客户在网页上点一个按钮,直接就能拨通客服的座机或手机,不需要装任何插件,也不需要软电话客户端。听起来像是要搞一套复杂的通信系统,但实际拆下来,核心就是 sipjs 负责浏览器端的信令,FreeSWITCH 负责服务端的呼叫控制,WebRTC 负责媒体传输。这三者拼在一起,就是一个完整的网页端电话应用。这份资源给的正是一个能跑通的示例,覆盖了呼入、呼出、转移、保持这四个电话场景里最常用的操作。适合谁?前端想接触实时通信的、后端想了解 FreeSWITCH 对接的、以及需要快速搭一个 Web 呼叫中心的开发者。下面我按实际拆项目的顺序,把这份示例从架构到代码到改造讲一遍。

2. 技术栈与整体架构:sipjs 和 FreeSWITCH 各自扛什么活

2.1 三层结构拆开看

这个项目的架构不复杂,但每一层的职责边界要划清楚,不然后面调试会很难受。

浏览器端跑的是 sipjs,它是一个 JavaScript 写的 SIP 协议栈,负责注册、发起呼叫、接收呼叫、发送 DTMF、处理 hold 和 transfer 的信令。sipjs 不碰媒体,媒体交给浏览器原生的 WebRTC API 处理。也就是说,麦克风采集、编解码、RTP 打包这些事,sipjs 不管,它只管 SIP 消息的收发。

中间层是 FreeSWITCH,它同时扮演 SIP 代理和媒体服务器的角色。浏览器发过来的 SIP 消息由 FreeSWITCH 接收,它根据 dialplan 决定这通电话往哪走——是转到另一个分机、转到外部网关、还是进会议、进 IVR。媒体方面,FreeSWITCH 负责和浏览器协商 SDP,建立 WebRTC 的媒体通道。

最外层就是被叫方,可能是另一个浏览器分机,也可能是传统电话网里的号码。如果被叫也是 WebRTC 终端,那媒体就是端到端的 SRTP;如果被叫是传统电话,FreeSWITCH 会做媒体转码和协议转换。

这个结构的好处是,前端开发者只需要跟 sipjs 的 API 打交道,不需要理解 FreeSWITCH 的 dialplan 细节;后端运维只需要保证 FreeSWITCH 的配置正确,不需要关心浏览器兼容性。

2.2 为什么选 sipjs 而不是其他方案

WebRTC 网页端打电话,常见的方案有 sipjs、JsSIP、以及一些商业 SDK。这份示例选 sipjs 的原因,我拆下来觉得主要是几点。

第一,sipjs 的 API 设计比较现代,基于 Promise 和事件订阅,写起来不像老式回调那么绕。第二,它对 WebRTC 的适配做得比较完整,包括 hold、transfer 这些操作都有对应的 API,不需要自己拼 SIP 消息。第三,它的社区活跃度还可以,遇到问题能搜到一些讨论。

当然也有代价。sipjs 的文档不算特别详细,很多用法得看源码或者示例。另外它对一些 SIP 扩展的支持不如 JsSIP 全面,如果你的场景需要复杂的 SIP header 操作,可能要评估一下。

2.3 FreeSWITCH 侧的配置要点

FreeSWITCH 这边,核心是让 WebSocket 方式的 SIP 注册能正常工作。默认的 SIP profile 走的是 UDP/TCP,WebRTC 需要走 WebSocket,所以要在sofia.conf.xml里确认有一个 profile 监听了ws-binding和wss-binding。

常见做法是单独建一个 internal 的 profile,专门给 WebRTC 终端用,端口比如 5066 走 ws,7443 走 wss。然后在 dialplan 里给这些分机配好路由,呼出的时候走网关,呼入的时候转到对应分机。

还有一个容易忽略的点:WebRTC 强制要求媒体加密,所以 FreeSWITCH 的 profile 里要开rtp-secure-media,并且证书要配好。如果是自签名证书,浏览器那边会拦,开发阶段可以用wss加自签,但生产环境必须用受信任的证书。

<!-- 片段:sofia profile 中 WebRTC 相关配置 --> <param name="ws-binding" value=":5066"/> <param name="wss-binding" value=":7443"/> <param name="rtp-secure-media" value="mandatory"/> <param name="apply-candidate-acl" value="wan_v4.auto"/>

这段配置的意思是:ws 监听 5066,wss 监听 7443,媒体加密强制开启,候选地址 ACL 允许外网地址。apply-candidate-acl这个参数在 NAT 环境下特别关键,不配的话经常出现单通或者媒体建立不起来。

3. 核心模块拆解:从注册到通话的完整链路

3.1 初始化与注册:UA 和 Registerer 的配合

sipjs 的入口是UserAgent,它代表一个 SIP 终端。创建 UA 的时候要传入 WebSocket 的地址、认证信息、以及一些媒体配置。

// 创建 UserAgent 实例 const ua = new SIP.UserAgent({ uri: SIP.UserAgent.makeURI(`sip:${username}@${domain}`), transportOptions: { server: `wss://${domain}:7443/ws`, // FreeSWITCH 的 wss 地址 }, authorizationUsername: username, authorizationPassword: password, delegate: { onInvite: (invitation) => { // 收到呼入时触发,后面 3.3 节展开 handleIncomingCall(invitation); }, }, }); // 启动 UA 并注册 await ua.start(); const registerer = new SIP.Registerer(ua); await registerer.register();

这段代码里,transportOptions.server指向 FreeSWITCH 的 WebSocket 地址,注意协议是wss不是https。authorizationUsername和authorizationPassword对应 FreeSWITCH 里配的分机账号密码。delegate.onInvite是呼入的入口,后面会单独讲。

注册成功后,registerer会定期刷新注册状态,默认过期时间是 600 秒。如果网络断开,sipjs 会自动尝试重连,但重连后需要重新注册,这个逻辑在示例里是通过监听transport的状态变化来处理的。

3.2 呼出流程:Inviter 的创建与媒体协商

呼出用Inviter类,传入目标 URI 和媒体选项。

// 发起呼叫 const target = SIP.UserAgent.makeURI(`sip:1002@${domain}`); const inviter = new SIP.Inviter(ua, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false, // 示例只做音频 }, }, }); // 监听呼叫状态 inviter.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: console.log('正在振铃...'); break; case SIP.SessionState.Established: console.log('通话已建立'); attachMedia(inviter.sessionDescriptionHandler); break; case SIP.SessionState.Terminated: console.log('通话结束'); break; } }); await inviter.invite();

sessionDescriptionHandlerOptions.constraints控制媒体类型,这里只开音频。inviter.invite()发出 INVITE 请求,FreeSWITCH 收到后根据 dialplan 路由到被叫。

媒体建立的关键在attachMedia这个函数里。sipjs 的sessionDescriptionHandler会暴露一个peerConnection对象,需要把远端媒体流挂到 audio 标签上。

function attachMedia(sdh) { const pc = sdh.peerConnection; const remoteStream = new MediaStream(); pc.getReceivers().forEach((receiver) => { if (receiver.track) { remoteStream.addTrack(receiver.track); } }); const audioElement = document.getElementById('remoteAudio'); audioElement.srcObject = remoteStream; audioElement.play(); }

这里有个坑:getReceivers()拿到的 track 在部分浏览器里需要等ontrack事件触发后才完整。示例里是在Established状态之后才调用attachMedia,这时候 track 已经就绪了。

3.3 呼入处理:Invitation 的接受与拒绝

呼入通过delegate.onInvite回调进来,参数是一个Invitation对象。

function handleIncomingCall(invitation) { // 在界面上显示来电信息 showIncomingUI(invitation.remoteIdentity.uri.toString()); // 用户点击接听 document.getElementById('answerBtn').onclick = async () => { await invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false }, }, }); attachMedia(invitation.sessionDescriptionHandler); }; // 用户点击拒接 document.getElementById('rejectBtn').onclick = () => { invitation.reject(); }; }

invitation.accept()会发出 200 OK,完成 SDP 协商。invitation.reject()发出 486 或 603,具体取决于 FreeSWITCH 的配置。

呼入场景里,来电显示的信息在invitation.remoteIdentity里,可以拿到 URI 和 display name。如果 FreeSWITCH 那边配了 caller ID,这里就能显示出来。

3.4 保持与转移:Refer 和 Re-INVITE 的实际用法

保持操作在 sipjs 里是通过sessionDescriptionHandler的 hold 相关方法实现的,本质上是发一个 re-INVITE,把 SDP 里的媒体方向改成sendonly或inactive。

// 保持通话 async function holdSession(session) { const sdh = session.sessionDescriptionHandler; await sdh.hold(); console.log('通话已保持'); } // 恢复通话 async function unholdSession(session) { const sdh = session.sessionDescriptionHandler; await sdh.unhold(); console.log('通话已恢复'); }

转移分两种:盲转和 attended transfer。盲转是直接发 REFER,让 FreeSWITCH 把被叫转到第三方。

// 盲转:把当前通话转到 1003 async function blindTransfer(session, targetUri) { const target = SIP.UserAgent.makeURI(`sip:${targetUri}@${domain}`); await session.refer(target); console.log('盲转已发起'); }

Attended transfer 要复杂一些,需要先建立一个新通话,然后把两个 session 关联起来。示例里只做了盲转,因为 attended transfer 在 FreeSWITCH 侧的 dialplan 配置要求更高,容易翻车。

注意:转移功能依赖 FreeSWITCH 的transfer相关 dialplan 配置,如果 REFER 发出去没反应,先检查 dialplan 里有没有对应的 extension。

4. 跑起来之前:环境搭建与配置清单

4.1 FreeSWITCH 最小配置

要让这个示例跑起来,FreeSWITCH 这边至少要做三件事:配一个支持 WebSocket 的 SIP profile、建两个测试分机、配一条呼出 dialplan。

分机配置在directory/default/下,每个分机一个 XML 文件。关键字段是id、password、以及user/params里的vm-password。

<!-- 分机 1001 的配置片段 --> <user id="1001"> <params> <param name="password" value="1234"/> </params> <variables> <variable name="user_context" value="default"/> </variables> </user>

dialplan 里加一条测试路由,让 1001 拨 1002 能通:

<extension name="local_extension"> <condition field="destination_number" expression="^(100[0-9])$"> <action application="bridge" data="user/$1"/> </condition> </extension>

这条 dialplan 的意思是:如果被叫号码匹配 1000 到 1009,就 bridge 到对应的 user。bridge是 FreeSWITCH 里最常用的应用,负责把两条腿接起来。

4.2 前端项目的启动方式

示例的前端部分是一个静态页面加几个 JS 文件,不需要构建工具,直接用浏览器打开就行。但因为 WebRTC 要求安全上下文,file://协议下麦克风权限拿不到,所以要用一个本地 HTTP 服务。

常见做法是用npx serve或者python -m http.server起一个静态服务。然后浏览器访问https://localhost:端口或者http://localhost:端口。注意,如果是http协议,Chrome 对localhost是放行的,但其他 IP 不行。

前端代码里要改的配置就几个:FreeSWITCH 的域名或 IP、WebSocket 端口、分机账号密码。这些在config.js里集中管理。

// config.js const CONFIG = { domain: '192.168.1.100', // FreeSWITCH 地址 wsPort: 5066, // ws 端口 wssPort: 7443, // wss 端口 username: '1001', password: '1234', };

4.3 验证步骤:从注册到通话的检查点

跑起来之后,按这个顺序验证:

第一步,打开浏览器控制台,看有没有WebSocket connected的日志。如果没有,检查 FreeSWITCH 的 ws-binding 是否监听正确,防火墙是否放行。

第二步,看注册是否成功。sipjs 注册成功后会打印Registered或者类似日志。如果失败,看 401 还是 403,401 一般是密码错,403 可能是 ACL 拦截。

第三步,拨一个分机号,看能不能振铃。如果振铃但接起来没声音,多半是媒体协商问题,检查 SDP 里的 candidate 地址是不是内网地址。

第四步,测试保持和转移。保持的时候看 FreeSWITCH 的日志里有没有 re-INVITE,转移的时候看 REFER 有没有被正确处理。

5. 避坑与排查:那些让我加班到凌晨的问题

5.1 注册成功但拨号没反应

现象:浏览器控制台显示注册成功,但点拨号按钮后没有任何反应,FreeSWITCH 日志里也看不到 INVITE。

原因:sipjs 的Inviter创建时,目标 URI 的 domain 部分和 UA 的 domain 不一致,导致 SIP 消息发到了错误的方向。或者invite()方法没有被正确 await。

解决:检查makeURI里的 domain 是否和 UA 的 domain 完全一致,包括端口。另外确认inviter.invite()是在 UA 启动之后调用的。

5.2 能振铃但接起来没声音

现象:主叫能听到回铃音,被叫也能振铃,但接起来之后双方都听不到声音。

原因:WebRTC 的 ICE candidate 收集到了内网地址,而 FreeSWITCH 在 NAT 后面,导致媒体流走不通。这是最典型的单通问题。

解决:在 FreeSWITCH 的 profile 里配apply-candidate-acl和ext-rtp-ip,把公网地址告诉浏览器。如果是在本地测试,确认浏览器和 FreeSWITCH 在同一个网段,或者用host模式的 candidate。

5.3 保持之后恢复不了

现象:点保持之后通话正常挂起,但点恢复时卡住,最后超时断开。

原因:sipjs 的hold()和unhold()在部分版本里对 SDP 的处理有 bug,特别是当 FreeSWITCH 返回的 SDP 里媒体方向是inactive而不是sendonly时。

解决:升级 sipjs 到最新版本,或者在unhold之前先检查sessionDescriptionHandler的状态。如果还是不行,可以手动构造 re-INVITE,把媒体方向改回sendrecv。

5.4 转移之后原通话没挂断

现象:盲转发起后,第三方开始振铃,但原通话没有自动挂断,导致两条腿都占着。

原因:REFER 发出后,FreeSWITCH 需要根据Refer-Sub和NOTIFY来确认转移结果。如果 dialplan 里没有配transfer相关的处理,FreeSWITCH 可能不会主动挂断原腿。

解决:在 dialplan 里加transfer的处理逻辑,或者在 sipjs 侧监听refer的结果,收到成功 NOTIFY 后手动terminate原 session。

5.5 浏览器控制台报证书错误

现象:用wss连接时,浏览器报net::ERR_CERT_AUTHORITY_INVALID,WebSocket 连不上。

原因:FreeSWITCH 用的是自签名证书,浏览器不信任。

解决:开发阶段可以在浏览器里手动信任自签证书,或者用ws代替wss(但生产环境必须用wss)。生产环境要换成受信任的 CA 证书,Let's Encrypt 是常见选择。

6. 二次开发:从示例到可用的网页呼叫中心

这份示例给的是一个最小可跑通的骨架,要变成实际可用的东西,还有几个方向可以改。

第一个方向是加通话状态管理。示例里状态是散落在各个回调里的,实际项目里我会用一个状态机来统一管理,比如idle、registering、ringing、active、held、transferring。这样 UI 的按钮状态和通话状态能严格对应,不会出现“明明在通话中但按钮显示可拨号”这种问题。

第二个方向是加通话记录和录音。FreeSWITCH 侧可以通过mod_callcenter或者record_session来做录音,前端只需要在通话结束时拉取记录列表。通话记录可以用 FreeSWITCH 的 CDR 模块,写到数据库或者文件里,前端通过一个简单的 REST 接口去查。

第三个方向是加多路通话支持。sipjs 本身支持多个 session 并存,但 UI 上要处理切换和保持的逻辑。常见做法是维护一个 session 列表,当前活跃的 session 显示在主区域,其他的折叠在侧边栏。

第四个方向是加视频通话。示例里只开了音频,要加视频的话,把constraints里的video改成true,然后在attachMedia里同时处理 video track。但要注意,视频对带宽和 FreeSWITCH 的转码能力要求更高,测试的时候先用小分辨率。

// 视频通话的 constraints 示例 const constraints = { audio: true, video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 }, }, };

这里我一般会把分辨率限制在 640x480,帧率 15,因为网页端视频通话不需要太高清,带宽和 CPU 都省。如果 FreeSWITCH 那边开了转码,还要注意 codec 的协商顺序,VP8 和 H.264 的优先级要在 profile 里配好。

第五个方向是加断线重连。sipjs 的 transport 断开后,UA 需要重新 start 和 register。示例里没有处理这个,实际项目里要监听transport的disconnected事件,然后做指数退避重连。重连成功后,如果之前有活跃通话,要提示用户通话已中断。

最后一个方向是加权限控制。示例里分机账号是硬编码在前端的,实际项目里肯定不能这么干。常见做法是后端发一个短时效的 token,前端拿 token 去 FreeSWITCH 换 SIP 凭证,或者用 FreeSWITCH 的mod_xml_curl动态生成分机配置。

提示:改造的时候,建议先把 FreeSWITCH 的日志级别调到debug,这样 SIP 消息的每一步都能看到,排查问题会快很多。上线前再调回notice或warning。

从那以后我每次对接 WebRTC 项目,都会先把 FreeSWITCH 的sofia status和sofia profile internal siptrace on打开,确认信令链路通了再动前端代码。这个习惯帮我省了很多来回猜的时间。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 11:31:00

Windows Server 2012 .NET Framework 3.5 离线安装实战指南

简介&#xff1a;本资源是专为Windows Server 2012 R2&#xff08;含云服务器环境&#xff09;用户设计的.NET Framework 3.5离线安装解决方案包&#xff0c;直击系统提示‘找不到源文件’或要求手动指定‘源路径’等典型安装失败问题&#xff0c;适用于运维工程师、系统管理员…

作者头像 李华
网站建设 2026/10/20 11:29:42

查重和AI检测为什么很难一起过?论文重复率和AI率一起降的办法!

查重和AI检测为什么很难一起过&#xff1f;论文重复率和AI率一起降的办法&#xff01; 查重和AI检测很难一起过&#xff0c;根本原因在于两者判断的依据完全不一样&#xff1a;查重拿论文去和文献库比&#xff0c;AI检测看的是文字本身有没有机器规律。我们为了过其中一关做的…

作者头像 李华
网站建设 2026/10/20 11:27:00

DeepSeek本地部署全攻略:从硬件匹配到Ollama与Chatbox实战

简介&#xff1a;这份PDF文档面向对AI技术感兴趣的开发者、初学者及具备一定硬件基础的专业人士&#xff0c;系统讲解DeepSeek模型本地部署的完整流程与优化策略。内容围绕低、中、高三档硬件配置展开&#xff0c;分别给出DeepSeek-R1-1.5B、R1-7B、R1-32B的选型依据&#xff0…

作者头像 李华
网站建设 2026/10/20 11:26:06

网页字体如何随屏幕变化?从两个端点推导 CSS clamp 计算器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 11:24:48

从零手写C语言子集编译器:词法、语法到求值引擎全解析

很多程序员第一次听到“编译器”三个字&#xff0c;都会下意识把它归入“很厉害但我这辈子大概碰不到”的领域。我完全理解这种感受——两年前我也是这么想的。直到我认真花了一个月&#xff0c;从零开始手写了一个能解析并执行C语言子集的编译器&#xff0c;才切身体会到&…

作者头像 李华
网站建设 2026/10/20 11:23:31

源码销售系统设计:支付回调、自动发货与订单管理实战

简介&#xff1a;这是一套面向站长、独立开发者及电商创业者的源码类虚拟物品销售系统&#xff0c;基于PHP与MySQL打造&#xff0c;内置文章收费、资源下载收费、游客购买、免签收款、三级分销、实名认证、佣金提现等机制&#xff0c;且除核心授权文件外全部开源&#xff0c;利…

作者头像 李华