基于WebRTC的网页SIP客户端:在Chrome中集成呼叫中心软电话
简介:这是一套基于Chrome浏览器的网页版SIP通信客户端源码包,适用于呼叫中心系统开发、WebRTC音视频通信学习及软电话原型搭建。资源面向前端开发工程师、VoIP技术人员及呼叫中心解决方案开发者,可帮助快速构建免安装的网页通信入口。压缩包共25个文件,以14个JavaScript脚本为核心,承载SIP注册、呼叫控制与界面交互逻辑;辅以HTML入口页面、CSS样式及静态图标资源,用于页面布局与视觉呈现;另含3个WAV音频文件,模拟拨号音、回铃音和振铃反馈。整体包体仅426KB,轻量易部署。已有592人学习下载。开发者可通过SIPml-api.js对接标准SIP服务器,实现注册、拨号、挂断及音频通话,再结合Bootstrap组件定制坐席工作台。资源自带完整目录划分,便于按功能模块二次开发,是研究WebRTC与SIP集成、搭建呼叫中心演示原型的实用素材。
1. 为什么呼叫中心会把 SIP 客户端塞进 Chrome 浏览器
呼叫中心坐席每天要接打几十通电话,传统 IP 话机不仅占桌面空间,还要单独布线、配置 vlan;桌面软电话虽然省了硬件,但受限于操作系统和运行库,Windows 更新一次可能就起不来。把 SIP 客户端跑在 Chrome 里,就变成了一个零安装的网页应用:坐席打开浏览器,输入工号密码,软电话就出现在 CRM 页面旁边。运维不再逐台分发安装包,升级就是刷新网页,兼容性问题也只剩下 "Chrome 能不能跑" 这一类。这个方案不是把 PJSIP 用 WebAssembly 编译一遍那么简单,而是用 SIP over WebSocket 做信令、WebRTC 做媒体,两头都要适配浏览器安全模型。对于呼叫中心,这意味着终端管理成本大幅下降,也是把话务接入工单系统最直接的一条路。
2. 网页版 SIP 的底层逻辑:从 SIP 协议到 WebRTC 的浏览器适配
2.1 SIP over WebSocket 与传统的 UDP/TCP 传输差异
传统 SIP 终端通过 UDP 或 TCP 传输信令,默认端口是 5060。UDP 速度快,但 NAT 穿透困难;TCP 可靠但头阻塞。浏览器既没有原始套接字,也不允许任意端口出流量,唯一稳定的长连接通道是 WebSocket。于是 RFC 7118 定义了 SIP over WebSocket:SIP 消息被当作文本帧或二进制帧,在 WebSocket 内部传输,SIP 的 via、contact、route 头里要声明传输方式为 ws 或 wss 。
服务器端需要单独开启 WebSocket 监听。FreeSWITCH 在 sofia.conf.xml 中启用 wss 监听,默认端口 5066;Asterisk 需要加载 res_http_websocket 和 chan_pjsip ,并在 pjsip.conf 中配置 transport=websocket 。客户端注册时, REGISTER 请求里的 Contact 头会变成类似 <sip:agent@192.168.1.100;transport=ws> 。一个很容易踩的坑是:如果页面是通过 HTTPS 加载的,那么 WebSocket 也必须是 WSS,否则 Chrome 会直接拦截。
验证 WebSocket 通道是否打通,可以在命令行用 websocat 或 wscat 连接:
wscat -c ws://192.168.1.10:5066 -H "Origin: http://localhost:8080"
连接成功后,可以手动发送一个 SIP OPTIONS 消息测试服务器是否响应:
OPTIONS sip:server@192.168.1.10 SIP/2.0
Via: SIP/2.0/WSS testclient;branch=z9hG4bK-12345;rport
From: <sip:test@192.168.1.10>;tag=abc
To: <sip:server@192.168.1.10>
Call-ID: test-123
CSeq: 1 OPTIONS
Contact: <sip:test@192.168.1.10;transport=ws>
Max-Forwards: 70
Content-Length: 0
注意 Via 头里的传输标识必须是 WSS ,因为服务器要根据这个字段决定回包路径。如果发成 UDP ,服务器会试图通过 UDP 回包,而浏览器根本没有监听 UDP 端口,请求就会超时。
2.2 浏览器里没有 RTP 怎么办:WebRTC 的替代方案
SIP 的媒体协商通过 SDP 完成,传统终端在 SDP 里提供自己的 IP、RTP 端口和编解码列表。浏览器没有直接操作 RTP 的 API,但 WebRTC 的 RTCPeerConnection 内部已经实现了 ICE、DTLS 和 SRTP。所以网页版 SIP 的分工是:SIP 只负责会话控制,媒体交给 WebRTC。
当用户发起呼叫时,JsSIP 会创建一个 RTCPeerConnection ,调用 createOffer() 生成 SDP,然后把这个 SDP 放进 SIP INVITE 消息的 body 里。SDP 中会携带 a=fingerprint (DTLS 指纹)和 ICE candidate 信息。服务器需要把 WebRTC 的 SDP 转换成传统 RTP 网关能识别的形式。以 FreeSWITCH 为例,使用 mod_verto 可以直接对接 WebRTC;如果走 mod_sofia ,需要在 dialplan 里设置 transport=wss 并开启 ICE。
这里有一个关键参数: iceServers 。在公网环境下,Chrome 默认使用 host 地址和 srflx 地址,也就是本地 IP 和公网映射地址。如果呼叫中心内网部署,可以用内网 TURN 服务器;如果只跑在同一个局域网, iceServers 可以留空。但要注意,Chrome 从 113 版本开始,对 iceServers 中未加密的 STUN 请求会有限制,所以生产环境务必使用 TURNS 或 STUN over TLS。
2.3 选型:JsSIP 还是 SIP.js,还是自研协议栈?
社区里两个主流库是 JsSIP 和 SIP.js。JsSIP 在 GitHub 上的活跃度更高,API 采用 ES6 类风格, UserAgent 、 Registerer 、 Session 三个核心类层次分明。SIP.js 更贴近 RFC 3261 的状态机,事件名是 sip.invite 、 sip.reg 等,适合从传统 SIP 转过来的开发者。下表是几个关键对比:
| 对比维度 | JsSIP | SIP.js |
|---|---|---|
| 许可证 | BSD-3-Clause | MIT |
| API 风格 | 回调 + Promise | 事件发射器 |
| 会话转移 | 原生支持 | 需要额外实现 |
| 音频设备控制 | 通过 WebRTC API | 封装了部分设备逻辑 |
| 依赖 | 无第三方运行时 | 依赖 some-package |
在呼叫中心场景,我倾向于 JsSIP,因为它的 session.hold() 、 session.transfer() 开箱即用,而且文档里专门有一节讲如何与 React/Vue 集成。自研协议栈只有在需要深度定制鉴权算法、或要嵌入非标 header 时才有必要。用 WebAssembly 移植 PJSIP 看起来很美,但浏览器环境的音频采集和播放由 getUserMedia 接管,PJSIP 的声卡抽象层完全用不上,还得写胶水代码,性价比很低。
3. 在 Chrome 上落地一个最小可用的 SIP 网页客户端
3.1 搭建本地 SIP 测试环境(用 FreeSWITCH 快速起一个服务)
要跑通网页版 SIP,需要一个支持 WebSocket 的 SIP 服务器。我习惯用 FreeSWITCH,因为默认配置里就开着 wss 监听。用 Docker 起一个最省事:
docker run -d --name freeswitch -p 5060:5060/udp -p 5066:5066 -p 7443:7443 -p 8081:8081 signalwire/freeswitch:latest
这条命令映射了 5066(WebSocket)和 7443(WSS)端口, -p 8081:8081 是 HTTP 服务端口,方便后续测试。启动后,需要修改 autoload_configs/sofia.conf.xml ,确认存在:
<param name="wss-binding" value=":7443"/>
然后创建两个测试分机(1001、1002),在 directory/default 下写 XML 文件。实际上,FreeSWITCH 默认就有 1000-1019 分机,密码是 1234。我们只需要确保 WebSocket 可以认证。
接着在浏览器里用 WebSocket 测试连接,可以打开 Chrome DevTools 的 Console 执行:
const ws = new WebSocket('wss://localhost:7443', 'sip');
ws.onopen = () => console.log('connected');
ws.onmessage = (e) => console.log(e.data);
如果看到服务器返回 SIP 401 或者欢迎消息,说明通道正常。
3.2 用 JsSIP 实现注册、拨打、接听的核心代码
创建一个 HTML 文件,引入 JsSIP 的 CDN。这里用 3.x 版本。
<!DOCTYPE html>
<html>
<body>
<input id="callee" placeholder="拨打号码" value="1002">
<button id="call">拨打</button>
<button id="hangup">挂断</button>
<script src="https://unpkg.com/jssip@3.10.1/dist/jssip.min.js"></script>
<script>
// 当前会话句柄,挂断要用
let currentSession = null;
const config = {
sockets: [{
uri: 'wss://localhost:7443',
via_transport: 'wss'
}],
uri: 'sip:1001@localhost',
authorization_user: '1001',
password: '1234',
register: true,
// 关键参数:让 Chrome 自动播放远端的媒体流
media: { constraints: { audio: true } }
};
const ua = new JsSIP.UA(config);
ua.start();
ua.on('registered', () => console.log('分机 1001 注册成功'));
ua.on('newRTCSession', (data) => {
const session = data.session;
currentSession = session;
session.on('confirmed', () => console.log('通话已建立'));
session.on('ended', () => { currentSession = null; });
});
function call() {
if (currentSession) return;
const target = document.getElementById('callee').value;
currentSession = ua.call(target);
}
function hangup() {
if (currentSession) currentSession.terminate();
}
</script>
</body>
</html>
注意:这个例子只实现了主叫。要接听来电,JsSIP 的 newRTCSession 事件会在来电时触发,但需要判断 data.originator === 'remote' 才能区别是呼出还是呼入。实战中还要在触发事件后调用 session.answer() 来接听。另外, media.constraints 里没有显式写入降噪参数,Chrome 默认是开启的,但不同版本可能不同,建议在下面参数详解中补全。
3.3 参数详解:wss、iceServers、registrar 等关键配置
config 对象里每个字段都有讲究:
-
sockets: 指定 WebSocket 地址和传输协议。via_transport必须是wss,因为 HTTPS 页面不能用 ws。如果 Chrome 报 "blocked by Mixed Content",说明这里写成了 ws。 -
uri: 分机标识,格式sip:号码@服务器域名。这里的域名要和服务器证书匹配,否则 Chrome 会拦截 WSS 握手。 -
register: 设为 true 才会上线注册。呼叫中心坐席一般都需要注册,否则只能被叫(通过Contact头携带的临时地址)。 -
register_expires: 注册过期时间,默认 600 秒。如果局域网不稳定,可以调小到 120,让服务器更快回收死连接。但太短会增加注册风暴。 -
iceServers: 如果你的 Chrome 跑在办公网,需要穿透 NAT,要配置 STUN/TURN。比如:
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{ urls: 'turns:turn.example.com:5349', username: 'agent', credential: 'pass' }
]
注意 Chrome 109 以后, iceServers 的 URLs 必须是有效的 TURN 或 STUN 地址,而且 TURN 推荐用 turns 协议,避免 UDP 被防火墙拦截。
-
media.constraints: 指定采集音频参数。呼叫中心常用echoCancellation: true, noiseSuppression: true, autoGainControl: true,这些是 Chrome 的默认值,但显式写出来更稳妥。
下表汇总了常用参数和推荐值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| sockets[0].uri | wss://pbx.example.com:7443 | 必须 wss |
| register | true | 注册到平台 |
| register_expires | 120~600 | 局域网用 600,跨公网用 120 |
| media.constraints | {audio: true, echoCancellation: true} | 可细化降噪 |
4. 呼叫中心场景下的实战配置与浏览器限制
4.1 音频设备选择与回声消除:getUserMedia 的约束参数
呼叫中心坐席通常需要选择耳机还是扬声器。在 Chrome 里,我们可以用 navigator.mediaDevices.getUserMedia 获取音频流,再把流绑定到 JsSIP 的 session:
async function setAudioDevice(deviceId) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true }
});
// 实际替换媒体流的 API 在 JsSIP 3.x 中是 session.setAudioInputDevice()
if (currentSession) {
currentSession.setAudioInputDevice(deviceId);
}
}
注意:JsSIP 3.x 直接暴露了 setAudioInputDevice 和 setAudioOutputDevice 方法,但必须在呼叫前或呼叫建立后调用。如果你在 confirmed 事件之前调用,可能因为 PeerConnection 还没建立而导致异常。
回声消除是呼叫中心最常见的投诉点。Chrome 的 echoCancellation 默认是 true,但在某些 Windows 设备上,如果默认采集设备是麦克风阵列,回声消除可能不工作。建议在代码里显式开启,并且不要和浏览器页面的其他 AudioContext 同时播放声音,否则会干扰 AEC 参考信号。
4.2 Chrome 自动播放策略与用户手势要求
Chrome 从 66 版本开始,自动播放带有声音的媒体会被阻止。网页 SIP 客户端在收到来电时,需要播放铃声。如果用户没有点击过页面, audio.play() 会返回一个 rejected Promise,导致铃声不响。解决方法是:在页面加载后,提示用户点击一次"启用铃声"按钮,并在点击处理函数里预先播放一段静音并立即暂停,来获取播放权限。
document.getElementById('enable-audio').onclick = () => {
const ctx = new AudioContext();
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
ctx.suspend();
};
这个技巧利用 AudioContext 的挂起状态来解锁媒体权限。后续来电时,用 audioContext.resume() 再播放铃声。
另外,Chrome 默认会拦截本地网络的权限,如果你用 localhost 访问页面,WebSocket 可以通,但如果页面部署在内网 HTTP 环境,Chrome 可能阻止不必要的私有网络访问。从 Chrome 94 开始,公开站点不能向本地网络发起请求,所以呼叫中心的网页客户端必须通过 HTTPS 部署,并且证书可以是自签名但需要导入到系统信任区。
4.3 把 SIP 客户端嵌进软电话面板:与呼叫中心 CRM 的集成方式
呼叫中心通常会有 CRM 系统,要求坐席在页面上看到弹屏并直接点击呼叫。集成方式一般是把 JsSIP 的 ua.call(号码) 封装成一个全局对象,然后通过 URL 参数或 postMessage 接收来电号码。
如果 CRM 页面和软电话页面是不同域,可以用 postMessage 通信。比如 CRM 页面:
iframe.contentWindow.postMessage({ type: 'call', number: '1002' }, '*');
软电话页面监听 message。
另外,还要考虑坐席状态(空闲、忙碌、话后处理),SIP 标准里用 SIP Refer 和 X-DND 头实现。JsSIP 提供 ua.set('dnd', true) 方法,但不同服务器支持不同。FreeSWITCH 中有 Presence 的 DND 扩展,需要在 SIP 的 SUBSCRIBE/NOTIFY 消息中携带 OWA-DND: 1 。在实际项目中,我会在呼叫中心平台侧对接接口,而不是依赖 SIP 消息,因为坐席状态还要联动排队策略。
4.4 防火墙 SIP ALG 对网页客户端的实际影响
很多企业防火墙默认开启 SIP ALG,它会把 SIP 报文里的 IP 和端口重写以适配 NAT。但 WebSocket 承载的 SIP 报文在 TLS 加密下,ALG 无法解析,所以一般不会干扰。但有个例外:如果网页端使用 ws:// (未加密),ALG 可能尝试修改 Via 和 Contact 头,导致服务器回包地址错误。所以在部署网页版 SIP 时,我第一件事就是登录防火墙关闭 SIP ALG,或者确认外网端口只放行 443 和 7443。同时,TURN 服务的端口也要开放,否则 Chrome 的 ICE 候选地址只在局域网内有效。
5. 排错清单与进阶技巧:从日志到链路追踪
5.1 常见的 403/486/超时问题对照表
| 现象 | 原因 | 排查方向 |
|---|---|---|
| 403 Forbidden | 注册密码错误或 IP 受限 | 检查 authorization_user 和 password |
| 404 Not Found | 被叫号码不存在 | 检查拨号规则 |
| 486 Busy | 被叫忙 | 检查对方状态 |
| 超时 | WebSocket 断了或路由不通 | 看 Chrome DevTools Network 里的 WS 帧 |
| 音频单通 | ICE 候选不完整 | 用 webrtc-internals 看 iceConnectionState |
| 自动断线 | 注册过期且未重注册 | 检查 register_expires 和重试间隔 |
5.2 用 chrome://webrtc-internals 和 SIP 日志定位问题
Chrome 内置一个 chrome://webrtc-internals 页面,它会记录所有在 Chrome 上运行的 WebRTC 会话的 SDP、ICE 候选和统计信息。当网页 SIP 通话质量出现问题时,打开这个页面,导出一个 .txt 文件,然后重点看两处:
-
iceConnectionState是否变成connected或completed,如果一直checking,说明 STUN 或 TURN 不可达。 -
remotesdp里是否包含服务器返回的媒体端口。如果服务器把媒体端口改成了 0,说明 SIP ALG 干扰了 SDP。
JsSIP 本身也有日志。在 start 之前:
JsSIP.debug.enable('JsSIP:*');
Chrome DevTools 的 Console 会输出详细的收发明细,包括收发 SIP 消息的 header。你可以把 INVITE 和 200 OK 的文本拷贝出来,对照 RFC 3261 检查除 CSeq 外是否缺少必要的头字段。
5.3 一个提高成功率的小技巧:在页面卸载前主动注销
坐席关闭浏览器标签页或刷新页面时,如果不发 UNREGISTER ,服务器要等到注册过期后才会清除绑定,这期间如果呼叫转移到该分机,会找不到可用终端。正确做法是在 pagehide 事件中调用 ua.stop() ,它会发送 REGISTER 且 Expires: 0 :
window.addEventListener('pagehide', () => {
if (ua.isRegistered()) {
ua.stop();
}
});
注意不要用 beforeunload ,因为该事件在移动端 Safari 上不保证触发,而 pagehide 在各浏览器中更可靠。另外,如果你在单页应用(SPA)中切换路由,不要每次都销毁 UA;保持单例,并在组件卸载时只移除事件监听。
更多推荐
所有评论(0)