ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SIP.js与FreeSWITCH实战:网页端电话呼入呼出转移保持全流程

SIP.js与FreeSWITCH实战:网页端电话呼入呼出转移保持全流程

简介:这是一份面向WebRTC语音通信开发者的实战示例资源,聚焦sipjs与FreeSWITCH的集成,解决网页端实现电话呼入、呼出、转移与保持等核心功能的落地问题。适合具备一定前端与SIP协议基础、希望快速验证软电话能力的开发者参考。压缩包共4个文件,包含1个html页面、1个css样式、1个js脚本及1个txt说明,整体约79KB,结构精简,其中html承载界面与交互入口,js负责SIP信令与媒体处理,css控制通话面板样式,txt提供必要的使用说明。在谷歌浏览器下运行后,只需将分机号、密码与服务器地址替换为自己的配置,即可直接进行功能测试,省去从零搭建信令与媒体协商的繁琐过程。目前已有2315人学习下载,可作为理解sipjs调用方式、FreeSWITCH对接流程以及WebRTC通话控制逻辑的参考样例,帮助读者快速定位关键代码并迁移到自身项目中。

1. 从一通打不出去的电话说起:为什么选 SIP.js + FreeSWITCH 做网页端话务

某天下午,A同学在实验室里对着浏览器控制台发愁——他要在网页端实现电话呼入、呼出、转移、保持,后端用 FreeSWITCH,前端用 SIP.js,结果注册上了但一拨号就断,转移更是直接静默失败。这不是个例。很多做企业通信类毕设或课程设计的同学,卡在“信令通了但媒体不通”“单路通话能跑、多路就崩”这类问题上,翻遍文档也找不到一份从注册到转移全流程打通的完整示例。

这套方案要解决的就是这件事:用 SIP.js 在浏览器侧建立 WebRTC 软电话,通过 FreeSWITCH 完成信令路由与媒体桥接,实现网页端电话的呼入、呼出、盲转、 attended 转移和通话保持。适合正在做通信工程、计算机相关毕设或课设的学生,也适合需要快速搭一套 Web 呼叫中心原型的开发者。整套方案围绕“能跑通、能演示、能答辩”来组织,不堆概念,直接落到模块划分和关键代码。

2. 需求拆解与技术选型:为什么是 SIP.js 配 FreeSWITCH

2.1 网页端话务的四个硬需求

先把需求摆清楚,不然后面选型没有依据。网页端电话应用,核心需求通常就四条:

第一,呼出。用户在网页上输入号码或点联系人,发起一路通话,对方振铃、接听、通话、挂断,全流程状态要能在界面上反映出来。

第二,呼入。外部电话打进来,网页端要能振铃、显示主叫号码、接听、通话。这里涉及浏览器音频权限和自动播放策略,是翻车高发区。

第三,转移。通话中把对方转给第三方号码,分盲转和 attended 转移。盲转是直接甩出去,attended 是先和第三方通话确认再转。企业场景里 attended 转移更常用,但实现复杂度也更高。

第四,保持。通话中暂时搁置对方,播放保持音乐或静音,恢复后继续通话。保持期间要保证媒体流不中断、SDP 不重协商失败。

这四条需求决定了技术选型不能只看“能不能注册”,而要看“媒体能不能稳住、状态能不能同步”。

2.2 SIP.js 与 FreeSWITCH 的分工

SIP.js 是浏览器侧的 SIP 协议栈,负责把 SIP 信令封装成 JavaScript API,同时对接 WebRTC 的 RTCPeerConnection 做媒体协商。它不负责媒体转发,只负责“谈”和“控”。

FreeSWITCH 是服务端的软交换机,负责注册鉴权、呼叫路由、媒体桥接、录音、会议、IVR 等。它把浏览器的 WebRTC 媒体流和外部电话的 RTP 流桥接起来,是整套系统的中枢。

两者配合的逻辑是:SIP.js 在浏览器里注册到 FreeSWITCH 的 WSS 端口,呼出时发 INVITE,FreeSWITCH 根据 dialplan 路由到目标;呼入时 FreeSWITCH 收到外部呼叫,查路由后向已注册的 SIP.js 分机发 INVITE;转移和保持通过 SIP 的 REFER 和 re-INVITE 实现,FreeSWITCH 负责执行和状态回传。

选这套组合而不是纯 WebSocket 自研信令,原因是 SIP 协议成熟、FreeSWITCH 的 dialplan 灵活、SIP.js 对 WebRTC 的适配已经处理了大部分浏览器兼容问题。自己造轮子,光 ICE 穿透和 SDP 协商就能耗掉整个毕设周期。

2.3 环境与依赖版本

环境这块,通常的做法是:

  • FreeSWITCH 1.10.x,编译时开启--enable-opus和 WebRTC 相关模块
  • 浏览器端用 SIP.js 0.20.x 或 0.21.x,这两个版本 API 相对稳定
  • 前端框架不限,Vue 或 React 都行,核心是 SIP.js 的封装
  • 证书必须用 HTTPS/WSS,浏览器不允许非安全上下文访问麦克风

注意:FreeSWITCH 的 WSS 端口默认是 7443,SIP.js 连接地址格式为wss://your-host:7443,不是sip:开头。

3. 总体架构设计:三层结构与五个核心模块

3.1 三层架构的职责划分

整套系统分三层:

接入层是浏览器里的 SIP.js 实例,负责注册、呼叫控制、媒体采集与播放。它通过 WebSocket Secure 连到 FreeSWITCH 的 WSS 端口,媒体走 DTLS-SRTP。

交换层是 FreeSWITCH,负责分机注册、dialplan 路由、媒体桥接、转移执行、保持音乐播放。它同时对接外部 SIP 中继或网关,实现网页端和外部电话的互通。

业务层是前端应用逻辑,负责联系人管理、通话记录、状态展示、按钮交互。这一层不碰 SIP 协议细节,只调用封装好的通话控制接口。

三层之间通过明确的接口通信:接入层和交换层之间是 SIP over WSS,交换层和业务层之间通过 FreeSWITCH 的 Event Socket 或 REST API 同步状态。

3.2 五个核心模块

模块一:注册与心跳模块。SIP.js 的UserAgent负责注册,需要处理注册失败重试、网络断开重连、心跳保活。注册状态要暴露给 UI,让用户知道当前是否在线。

模块二:呼出模块。封装invite()方法,处理拨号、振铃、接通、挂断的状态流转。关键是要在Session的progress、accepted、terminated事件里更新 UI。

模块三:呼入模块。监听UserAgent的invite事件,收到后创建Invitation对象,展示来电界面,用户接听则accept(),拒接则reject()。

模块四:转移模块。盲转用refer(),attended 转移需要先invite()第三方,建立通话后再refer()原通话。FreeSWITCH 侧要配置好refer的 dialplan 处理。

模块五:保持模块。通过invite()发送 re-INVITE,把媒体方向设为sendonly或inactive,FreeSWITCH 侧播放保持音乐。恢复时再发一次 re-INVITE 把方向改回sendrecv。

3.3 信令与媒体流走向

呼出时:浏览器 SIP.js 发 INVITE → FreeSWITCH 收到后查 dialplan → 路由到目标分机或外部网关 → 目标振铃 → 接听后 FreeSWITCH 回 200 OK → SIP.js 收到后建立 RTCPeerConnection → 媒体流经 FreeSWITCH 桥接。

呼入时:外部呼叫到达 FreeSWITCH → dialplan 匹配后向已注册的 SIP.js 分机发 INVITE → 浏览器振铃 → 用户接听 → 200 OK → 媒体建立。

转移时:SIP.js 发 REFER → FreeSWITCH 收到后执行转移逻辑 → 向目标发 INVITE → 目标接听后原通话挂断 → 转移完成。

保持时:SIP.js 发 re-INVITE,SDP 中a=sendonly→ FreeSWITCH 收到后播放保持音乐 → 恢复时发 re-INVITE,a=sendrecv→ 媒体恢复。

4. 关键模块实现:从注册到转移的代码落地

4.1 SIP.js 注册与 UserAgent 配置

注册是第一步,也是最容易出问题的一步。下面是一个可用的配置示例:

// SIP.js 0.21.x 注册配置 const config = { uri: 'sip:1001@your-fs-host', // 分机 URI transportOptions: { server: 'wss://your-fs-host:7443', // FreeSWITCH WSS 地址 traceSip: true // 调试时开启,生产关闭 }, authorizationUsername: '1001', // 鉴权用户名 authorizationPassword: 'your-pass', // 鉴权密码 displayName: 'WebPhone-1001', sessionDescriptionHandlerFactoryOptions: { iceGatheringTimeout: 3000, // ICE 收集超时,单位毫秒 peerConnectionConfiguration: { iceServers: [{ urls: 'stun:stun.example.com:3478' }] } } }; const userAgent = new SIP.UserAgent(config); // 注册状态监听 userAgent.delegate = { onConnect: () => console.log('WSS 已连接'), onDisconnect: (error) => console.log('WSS 断开', error), onInvite: (invitation) => handleIncomingCall(invitation) // 呼入入口 }; userAgent.start().then(() => { const registerer = new SIP.Registerer(userAgent); registerer.register({ expires: 300, // 注册有效期,秒 requestDelegate: { onAccept: () => console.log('注册成功'), onReject: (response) => console.log('注册失败', response.message.statusCode) } }); });

这段代码里,uri和authorizationUsername必须和 FreeSWITCH 里配置的分机一致。iceGatheringTimeout设 3000 毫秒是经验值,太短会导致 ICE 候选收集不全,太长会让呼叫建立变慢。onInvite是呼入的总入口,所有外部来电都从这里进来。

提示:调试阶段把traceSip设为true,控制台会打印完整的 SIP 信令,排查注册和呼叫问题非常有用。生产环境记得关掉,否则日志量很大。

4.2 呼出与呼入的状态机

呼出的核心是invite()和事件监听:

// 发起呼出 function makeCall(targetNumber) { const target = `sip:${targetNumber}@your-fs-host`; const inviter = new SIP.Inviter(userAgent, target, { sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } // 只要音频 } }); inviter.stateChange.addListener((state) => { switch (state) { case SIP.SessionState.Establishing: updateUI('正在呼叫...'); break; case SIP.SessionState.Established: updateUI('通话中'); attachMedia(inviter.sessionDescriptionHandler); // 绑定音频 break; case SIP.SessionState.Terminated: updateUI('通话结束'); break; } }); inviter.invite(); return inviter; }

呼入的处理逻辑类似,但方向相反:

// 处理呼入 function handleIncomingCall(invitation) { // 展示来电界面,显示主叫号码 showIncomingUI(invitation.remoteIdentity.uri.toString()); // 用户点击接听 document.getElementById('answer-btn').onclick = () => { invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } }); }; // 用户点击拒接 document.getElementById('reject-btn').onclick = () => { invitation.reject(); }; // 监听状态变化 invitation.stateChange.addListener((state) => { if (state === SIP.SessionState.Established) { attachMedia(invitation.sessionDescriptionHandler); } }); }

attachMedia的作用是把远端媒体流绑定到<audio>元素上,这一步不做,通话就是静音的。通常的做法是遍历sessionDescriptionHandler.peerConnection.getReceivers(),找到音频 track,创建MediaStream后赋给 audio 元素的srcObject。

4.3 转移与保持的实现细节

盲转最简单,直接调refer():

// 盲转:把当前通话转给 targetNumber function blindTransfer(session, targetNumber) { session.refer(`sip:${targetNumber}@your-fs-host`, { requestDelegate: { onAccept: () => console.log('转移请求已接受'), onReject: (response) => console.log('转移失败', response.message.statusCode) } }); }

Attended 转移要复杂一些,需要先建立一路新通话,确认后再转:

// Attended 转移:先呼叫第三方,接通后再转 async function attendedTransfer(originalSession, targetNumber) { // 第一步:保持原通话 await holdSession(originalSession); // 第二步:呼叫第三方 const consultSession = makeCall(targetNumber); // 第三步:第三方接通后,用户确认转移 consultSession.stateChange.addListener(async (state) => { if (state === SIP.SessionState.Established) { // 用户点击“完成转移” document.getElementById('complete-transfer').onclick = () => { originalSession.refer(consultSession, { requestDelegate: { onAccept: () => { consultSession.bye(); // 挂断咨询通话 console.log('Attended 转移完成'); } } }); }; } }); }

保持的实现依赖 re-INVITE:

// 保持通话 function holdSession(session) { const options = { sessionDescriptionHandlerModifiers: [ SIP.Web.SessionDescriptionHandlerModifiers.hold // SIP.js 内置的保持修饰符 ] }; return session.invite(options); } // 恢复通话 function unholdSession(session) { const options = { sessionDescriptionHandlerModifiers: [ SIP.Web.SessionDescriptionHandlerModifiers.unhold ] }; return session.invite(options); }

SIP.js 内置了hold和unhold修饰符,它们会修改 SDP 中的媒体方向属性。FreeSWITCH 收到sendonly的 SDP 后,会自动播放保持音乐。恢复时发sendrecv,媒体流恢复。

注意:保持和恢复之间要有状态锁,防止用户快速连点导致 re-INVITE 冲突。通常的做法是在发起 re-INVITE 后禁用按钮,收到 200 OK 后再启用。

4.4 FreeSWITCH dialplan 配置

FreeSWITCH 侧的 dialplan 决定了呼叫怎么路由。下面是一个支持呼入、呼出、转移的最小配置:

<!-- /etc/freeswitch/dialplan/default.xml 片段 --> <extension name="webphone-outbound"> <condition field="destination_number" expression="^(\d{4,})$"> <action application="bridge" data="user/$1"/> </condition> </extension> <extension name="webphone-inbound"> <condition field="destination_number" expression="^1001$"> <action application="bridge" data="user/1001"/> </condition> </extension> <extension name="transfer-handler"> <condition field="destination_number" expression="^transfer-(\d+)$"> <action application="transfer" data="$1 XML default"/> </condition> </extension>

webphone-outbound处理呼出,目标号码是 4 位以上数字时,直接 bridge 到对应分机。webphone-inbound处理呼入,匹配 1001 分机后 bridge 过去。transfer-handler处理转移,匹配transfer-前缀后执行 transfer。

分机注册配置在directory/default/下,每个分机一个 XML 文件,关键字段是password和user_context。WebRTC 分机需要额外配置rtp_secure_media和enable_3pcc。

5. 避坑与常见问题排查

5.1 注册成功但拨号无声

现象:SIP.js 显示注册成功,拨号后对方振铃,接听后双方都听不到声音。

原因:通常是 ICE 候选收集不全或 SDP 协商失败。浏览器在 HTTPS 下才能访问麦克风,如果页面是 HTTP,getUserMedia会静默失败,导致 SDP 里没有媒体描述。

解决:确认页面是 HTTPS,检查iceGatheringTimeout是否太短,在 FreeSWITCH 侧开启rtp_debug看 RTP 包是否到达。通常把iceGatheringTimeout调到 3000 以上能解决大部分问题。

5.2 呼入时浏览器不振铃

现象:外部电话打进来,FreeSWITCH 日志显示已向分机发 INVITE,但浏览器没有任何反应。

原因:SIP.js 的onInvite回调没有正确绑定,或者UserAgent的 delegate 在注册后被覆盖。

解决:检查userAgent.delegate.onInvite是否在start()之前设置。如果用了框架,注意组件销毁重建时 delegate 是否被重新赋值。通常的做法是把 delegate 设置放在start()之前,且只设置一次。

5.3 转移后原通话不挂断

现象:盲转或 attended 转移后,目标接通了,但原通话仍然保持,出现两路通话。

原因:FreeSWITCH 的 dialplan 没有正确处理 REFER,或者refer()的requestDelegate没有在onAccept里挂断原 session。

解决:在refer()的onAccept回调里显式调用session.bye()。同时检查 FreeSWITCH 的transfer配置,确保refer后执行了transfer而不是bridge。

5.4 保持后恢复失败

现象:保持正常,但恢复时 re-INVITE 被拒绝,通话直接挂断。

原因:SDP 中的媒体方向属性不匹配,或者 FreeSWITCH 的hold音乐播放与 re-INVITE 冲突。

解决:确认hold和unhold修饰符成对使用,不要在保持状态下直接发unhold。在 FreeSWITCH 侧检查hold_music配置,确保保持音乐文件存在且格式正确。

5.5 多路通话时媒体串流

现象:同时有两路通话时,声音串到另一路,或者一路挂断另一路也断。

原因:attachMedia时没有区分 session,所有音频流绑到了同一个 audio 元素。

解决:每个 session 创建独立的 audio 元素,用 session id 做 key 管理。通常的做法是维护一个Map<sessionId, HTMLAudioElement>,session 结束时销毁对应的 audio 元素。

6. 验收与答辩:怎么证明这套方案真的能跑

验收这件事,很多同学栽在“功能都写了但演示时翻车”。我一般会按下面的清单走一遍,确保答辩时不出意外。

功能验收清单:

验收项操作步骤预期结果常见失败原因
注册打开页面,输入分机密码状态显示“在线”WSS 地址错误、证书不受信任
呼出输入分机号,点击呼叫对方振铃,接听后双向通话麦克风权限未授权、ICE 超时
呼入用另一分机呼叫当前分机页面振铃,显示主叫号码onInvite 未绑定、自动播放被拦截
盲转通话中点击盲转,输入目标号原通话挂断,目标振铃REFER 未处理、dialplan 未匹配
Attended 转移通话中点击咨询,接通后完成转移原通话转给第三方保持状态未同步、re-INVITE 冲突
保持/恢复通话中点击保持,再点击恢复保持时听音乐,恢复后正常通话SDP 方向属性错误、按钮未加锁

答辩演示技巧:

第一,提前把 FreeSWITCH 和浏览器都重启一遍,清掉残留注册。SIP 注册有有效期,残留注册会导致新注册被拒。

第二,准备两个浏览器窗口或两个分机,一个呼出一个呼入,现场演示双向通话。不要只演示单向,评委通常会问“呼入呢”。

第三,转移演示时,先演示盲转,再演示 attended 转移。Attended 转移的步骤多,提前把第三方号码写在纸上,避免现场输入错误。

第四,保持演示时,让评委听到保持音乐。如果 FreeSWITCH 没配保持音乐,保持就是静音,评委可能以为通话断了。

第五,准备一份 FreeSWITCH 的日志截图,显示 INVITE、200 OK、REFER、re-INVITE 的完整信令流程。评委问“底层怎么实现的”时,直接拿日志说话。

代码层面的验收点:

  • UserAgent的 delegate 是否在start()前设置
  • attachMedia是否按 session 区分 audio 元素
  • refer()的onAccept是否挂断原 session
  • hold和unhold是否成对且加了状态锁
  • FreeSWITCH dialplan 是否覆盖了呼出、呼入、转移三条路由

从那以后我每次做通信类项目,都会在答辩前把这份清单走一遍,尤其是“两个浏览器窗口互拨”这一步,能提前暴露 80% 的媒体问题。希望这套方案能帮你把网页端电话的呼入、呼出、转移、保持一次跑通,答辩时不用再对着控制台发呆。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进