简介:这是一套基于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 文件,然后重点看两处:

  1. iceConnectionState 是否变成 connected 或 completed ,如果一直 checking ,说明 STUN 或 TURN 不可达。
  2. 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;保持单例,并在组件卸载时只移除事件监听。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐