网页SIP客户端实战:用SIPml和WebRTC打造呼叫中心软电话
简介:这是一套基于WebRTC与SIP协议的浏览器端呼叫中心客户端源码包,面向VoIP开发者和需要快速搭建网页软电话的工程师。压缩包内共25个文件,以JavaScript逻辑文件为主(14个js),包含SIP通信接口库、页面交互脚本以及UI组件;另有HTML入口页面、CSS样式、PNG图标和wav提示音,整体约426KB,结构精简,便于直接部署调试。已有592人学习下载。通过解析入口页面和核心JS的调用方式,可以掌握SIP注册、呼叫、挂断等核心流程;配套的样式资源和通话提示音则展示了如何构建友好界面与完整通话反馈。对于希望从零理解浏览器SIP通信或扩展呼叫中心功能的开发者,这份资源能提供清晰的参考原型,也能作为后续二次开发的基础。
1. 网页 SIP 客户端不是玄学:先看这个 rar 里装了什么
一个装着网页 SIP 客户端源码的 rar 压缩包,40 多 MB,解压出来没有安装包、没有可执行文件,只有一个 index.html 、一个 SIPml-api.js 、一套 Bootstrap 2 的样式和三段 wav 铃声。它看起来不像软件,却能变成跑在 Chrome 浏览器里的呼叫中心软电话:客服不必再装 X-Lite 或 MicroSIP,打开页面、输入账号密码、点开拨号盘就能接打电话。
想做网页软电话原型的开发者、要给坐席换浏览器的运维、以及想搞明白 SIP 和 WebRTC 怎么在浏览器里握手的产品同学,都值得把这套源码拆开看一遍。它不多不少,恰好是“SIP 信令 + WebRTC 媒体 + 呼叫中心交互”的最小可行组合。下面的章节按解包、接线、排错的顺序,把这套组合一层层拆开。
2. SIP 进浏览器的关键:WebSocket 走信令,WebRTC 走媒体
2.1 浏览器没有 5060 端口,SIP 只能换一条路
传统话机的流程大家都熟:话机开机,把 REGISTER 报文扔到 UDP 5060,服务器回 401 要求认证,话机再带摘要认证重发一次,注册就完成了。浏览器没有监听 UDP 端口的能力,也拿不到本地 Socket,这条路被彻底堵死。RFC 7118 把 SIP 报文放进 WebSocket 帧里:浏览器建立一条 wss://pbx.example.com:8089/ws 长连接,SIP 依然只做信令控制——注册、振铃、接听、挂断、转接——但传输层换成了浏览器原生支持的 WebSocket。
这里要划一条明确的边界:SIP 信令不携带语音,语音走的是 WebRTC。网页客户端注册成功后发起 INVITE,双方通过 SDP 协商媒体参数, RTCPeerConnection 建立 DTLS-SRTP 加密的 RTP 通道,这才有了能听到的声音。所以浏览器里的 SIP 客户端实际上是双通道:一条 WSS 承载信令,一条 UDP 高位端口承载媒体。后面排错时只要记住“信令通了但没声音,问题在媒体通道”,就能少绕很多弯。
2.2 为什么这份包里选择 SIPml-api.js
目前浏览器里做 SIP 注册,能选的库主要是 JsSIP、SIPml 和 SIP.js。JsSIP 的社区活跃、模块化程度高;SIPml 是 2012 年前后第一批把 SIP 搬进浏览器的项目,API 设计贴近“会话”模型,学习成本最低。这份 rar 自带的 SIPml-api.js 是编译好的单文件,不需要 npm install,解压到静态目录就能跑。对呼叫中心这种“注册、拨号、接听、挂断”四件套需求,SIPml 完全够用,而且自带的 demo 页面把拨号盘、状态栏、铃声都做好了。
选型要看到它的边界:SIPml 近年维护节奏慢,在最新 Chrome 上偶尔要打兼容补丁(后面 4.3 会说);JsSIP 对需要深度定制的中大型项目更友好。判断标准很简单——团队是想三天出一个原型,还是要长期迭代一套软电话产品。前者选 SIPml,后者建议直接评估 SIP.js。这份包里既然已经锁定了 SIPml,我们就围绕它的参数和事件机制往下走。
2.3 一通电话从 PSTN 到浏览器要过几道门
把视角拉到呼叫中心整体架构。坐席在 Chrome 里注册成功;外部电话从 PSTN 进来,先到交换机或语音网关,变成 SIP 信令进入 PBX(Asterisk、FreeSWITCH 都行);PBX 找到一个已注册的坐席,通过 WebSocket 连接把 INVITE 推给浏览器;坐席点击接听后,媒体流开始协商,最终走 ICE 选出的路径——可能是 P2P 直达,也可能经 TURN 中继穿越复杂的办公网。
这个链路里有两个经常被忽略的点。其一,浏览器的媒体端口完全随机,服务器侧的 RTP 端口范围必须收窄,Asterisk 在 rtp.conf 里配置 rtpstart=10000; rtpend=20000 ,FreeSWITCH 在 switch.conf.xml 里配置 rtp-start-port ,防火墙只需放行这段 UDP。其二,坐席浏览器和 PBX 之间通常还有一层 nginx 转发层,WSS 在转发层终结,信令再以普通 TCP 转给 PBX,这层把证书管理和 SIP 服务解耦,运维压力小很多。
3. 解包、改参数、跑起来:把静态页面注册到 PBX
3.1 压缩包里每一样东西是干什么的
解压后的文件按用途分三组:核心库、UI 与资源、声音反馈。逐一对应如下:
| 文件 | 作用 |
|---|---|
SIPml-api.js | 唯一的通信库,封装 WebSocket 信令与 WebRTC 媒体逻辑 |
index.html | 页面入口,拨号盘、对端号码输入框、接听/挂断按钮都在这里 |
assets/css/bootstrap.css 、 bootstrap-responsive.css | UI 样式,Bootstrap 2.x 版本,控制整体布局 |
assets/img/glyphicons-halflings.png | 图标雪碧图,按钮和状态栏的图形资源 |
assets/js/*.js | Bootstrap 组件脚本,弹窗、折叠等交互效果依赖它 |
sounds/ringtone.wav | 来电铃声,收到 INVITE 时循环播放 |
sounds/ringbacktone.wav | 回铃音,主叫等待对方接听时播放 |
sounds/dtmf.wav | 按键音,拨号或通话中按键盘时播放 |
images/sipml-34x39.png | 页面标题栏图标 |
真正需要改的只有两个文件: index.html 里的账号配置,以及部署服务器上的证书和 nginx 配置。Bootstrap 和铃声文件一般不用动。
3.2 注册配置:改四个字段就能对上你的 PBX
打开 index.html ,找到 SIPml.Stack 的初始化块,这是整个客户端的起点:
var stack = new SIPml.Stack({
realm: 'pbx.example.com',
impi: '1001',
impu: 'sip:1001@pbx.example.com',
password: 'secret',
display_name: '坐席-1001',
websocket_proxy_url: 'wss://pbx.example.com:8089/ws',
outbound_proxy_url: 'udp://pbx.example.com:5060',
ice_servers: [{ url: 'stun:stun.example.com:3478' }],
events_listener: { events: '*', listener: function (e) {
console.log('Stack 事件:' + e.type);
}}
});
stack.start();
逐项说参数。 realm 是 SIP 认证域,通常填 PBX 域名或 IP,要和服务器端配置一致,否则认证阶段直接报 403。 impi 是认证私有标识,相当于登录账号; impu 是对公 SIP URI( sip:1001@pbx.example.com ),别人呼叫你用的就是它,多数环境里两值相同。 websocket_proxy_url 指向 SIP over WebSocket 服务地址,Asterisk 默认在 8089 端口的 /ws 路径,FreeSWITCH 的 mod_verto 通常用 8081,路径写错会一直停在 connecting。 outbound_proxy_url 是可选字段,如果 PBX 要求信令改走另一台服务器就填上,没有就直接删掉这一行,不要留一个连不通的地址。
3.3 注册、呼出、接听三段可复用的代码
注册要基于 Stack 再开一个 register 会话:
var registration = stack.newSession('register', {
expires: 300,
events_listener: { events: '*', listener: function (e) {
if (e.type === 'connected') {
console.log('注册状态:' + registration.getConnectionState());
}
}}
});
registration.register();
expires 是注册生命周期,单位秒。PBX 侧如果配置的过期时间更短(Asterisk 常见 120 秒),会以服务器为准强制重注册,客户端不用刻意调小。回调里的 connected 事件只代表注册流程走通了一段,真正的注册结果要通过 getConnectionState() 验证,打日志时把两者分开看,能省去很多“以为注册成功其实没有”的排查时间。
呼出和接听共用一个 call-audio 会话:
var call = stack.newSession('call-audio', {
audio_remote: document.getElementById('remoteAudio'),
events_listener: { events: '*', listener: function (e) {
if (e.type === 'invite') {
call.accept(); // 来电自动接听,弹屏业务可放在这里
}
if (e.type === 'terminated') {
console.log('通话结束,清理 UI 状态');
}
}}
});
call.call('sip:2002@pbx.example.com'); // 呼出
call.hang(); // 挂断
audio_remote 指向页面上一个隐藏的 <audio> 元素,远端声音会在元素里播放;该元素必须带 autoplay 属性,否则 Chrome 自动播放策略会把它静音掉。通话中需要按分机键时,调用 call.sendDTMF('1') 发送 DTMF 信号,传统 IVR 菜单识别用的就是它。
提示:页面不在 HTTPS 下时,
getUserMedia不会返回音频流。localhost 除外,但内网坐席机必须走 HTTPS,否则执行call.call()时 Console 报错,后台状态看起来却一切正常。
3.4 用 nginx 把静态资源和 WSS 一起转发出去
坐席机浏览器访问的地址要带证书,静态文件和 WebSocket 可以共用一个 nginx 配置:
server {
listen 443 ssl;
server_name agent.example.com;
ssl_certificate /etc/nginx/ssl/agent.crt;
ssl_certificate_key /etc/nginx/ssl/agent.key;
root /var/www/sip-agent;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /ws {
proxy_pass http://127.0.0.1:8089;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
root 指向解压出来的资源目录。 location /ws 把浏览器发到 443 的 WebSocket 请求透传给 Asterisk 的 8089, proxy_set_header Upgrade 两行是 WebSocket 转发的固定搭配,漏掉会出现握手失败,DevTools Network 面板里 WebSocket 显示为红色并伴随 502。
证书用内网自签名即可,把 CA 证书导入坐席终端受信任根列表,Chrome 就不再报不安全链接;别为省事走 HTTP,麦克风权限和通话内容加密两关都过不去。
4. 呼叫中心落地绕不开的四个坑:权限、SIP ALG、Chrome 版本与闪白
4.1 麦克风授权没拿到,注册一百次也“通不了话”
Chrome 的权限模型对呼叫中心这种多人公用坐席非常不友好。首次打开软电话页面,地址栏右侧弹麦克风授权,任何一个坐席点了拒绝,后续每次 getUserMedia 都会直接走进 error 回调,页面不会再有第二层提示。授权状态跟着浏览器配置文件走,换人登录就等于换了一次权限。
如果页面里嵌了 iframe, SIPml-api.js 跑在 iframe 内,权限策略还会叠加一层限制:iframe 标签要显式声明 allow="microphone" ,否则地址栏允许也没用。排查这类问题最直接的办法是在 DevTools 的 Application 面板里看 Site Settings,那里能看到该站点麦克风权限的最终判定是 allowed 还是 blocked。
4.2 防火墙 SIP ALG 才是注册掉线的头号嫌疑人
不少企业路由器和下一代防火墙默认开着 SIP ALG,它的最初目的是改写 SIP 报文里的 IP 和端口,帮内网话机穿透 NAT。但实现粗糙的 SIP ALG 会把 Contact 头和 VIA 头改成错误地址,导致注册当时成功、来电却找不到话机,或者出现只有一边有声音。WebRTC 坐席走的是 WSS 信令,SIP ALG 解析不了 WebSocket 帧,理论上是免疫的;但呼叫中心往往同时存在传统话机和 VoLTE 网关,只要混合组网,就必须在防火墙上关掉 SIP ALG,把 NAT 逻辑交还给 PBX 自身的 force_rport 和 nat 参数去处理。
媒体端口这一侧要主动收窄。在 Asterisk 的 rtp.conf 里加上:
rtpstart=10000
rtpend=20000
rtpstart 与 rtpend 定义 RTP 媒体端口范围,防火墙策略只需放行这段 UDP,也便于抓包时一眼认出哪条流是通话媒体。改完配置要重启 Asterisk 生效,正在通话的中继不受影响,但请放在夜间维护窗口执行。
4.3 Chrome 109 之后,老坐席机和新浏览器两头受气
很多呼叫中心的坐席机还跑着 Windows 7,Chrome 109 是最后支持 Win7 的主线版本,想上 110 以上直接装不上安装包。而老版本 Chrome 对 WebRTC API 的兼容与新版本有明显差异,SIPml 又是个静态库,不会跟着浏览器自动适配。所以这里有个两难:坐席机越老越不敢升级 Chrome,越不升级越容易碰上当前 PBX 固件不兼容的怪问题。
如果链路报错指向 RTCPeerConnection 或 getUserMedia 不存在,先引入 WebRTC 官方 adapter 垫片,再加载 SIPml-api.js :
<script src="js/adapter-latest.js"></script>
<script src="SIPml-api.js"></script>
adapter-latest.js 把各家浏览器对 WebRTC 的 API 差异抹平,不涉及 SIP 业务逻辑,对现有代码无侵入。建议把这个文件下载放本地,别在坐席机上依赖公网 CDN——离线网络环境下页面会直接卡死。另外,Chrome 企业版可以用组策略把自动更新固定到某个版本,呼叫中心环境里这比天天追新版靠谱。
4.4 打开页面闪一下变空白:先查三类 404
“Chrome 浏览器打开网址后闪一下就变空白”是这类静态 SIP 客户端最高频的工单。原因多数不是代码写错,是部署少了目录。 index.html 里用相对路径引用 assets/ 、 images/ 、 sounds/ ,如果只把 HTML 和 JS 拷上,CSS 全部 404,页面失去样式后看起来就是一片空白;图标加载失败时拨号盘按钮错位,更像“崩了”。
开 DevTools 的 Network 面板,按 Img、CSS、Media 三个类型筛一遍,红色状态一目了然。需要注意的是声音文件 404 是静默的: sounds/ringtone.wav 加载失败不会报错,但来电没有铃声,坐席会以为系统故障。所以排查闪白时顺手把音频资源也验一遍,别只看图片和样式。
5. 用 chrome://webrtc-internals 验证一条语音链路通没通
5.1 三个关键面板怎么读
坐席报“打不通电话”时,先让他在操作机上打开 chrome://webrtc-internals ,再发起一次呼叫。这个页面实时记录当前 Chrome 进程里所有 WebRTC 会话的内部数据,比 DevTools 更底层。看三个地方:
- WebSocket 信令:切到 DevTools Network 面板过滤
ws类型。REGISTER 先收到 401、随后带Authorization重发并拿到 200,认证链路就是通的;一直停在 401,去查impi和password。 - ICE Connection State:在 webrtc-internals 里搜这一项。状态为
connected或completed,说明媒体链路选路成功;卡在checking,代表 STUN/TURN 没配置或 UDP 被防火墙拦截。 - 候选对类型:找到被选中的
googCandidatePair,看 local 地址是host、srflx还是relay。同一网段内坐席互拨全是 host 正常;跨网络必须有srflx(STUN 反射地址)或relay(TURN 中继),只有 host 且没声音,问题就锁定在 ICE server 配置。
5.2 用字节数定位单向语音到底卡在谁那边
单向语音(A 听不到 B,B 能听到 A)是呼叫中心最磨人的故障。在 webrtc-internals 的 RTC 统计里找 RTCInboundRTPAudioStats 和 RTCOutboundRTPAudioStats ,对比 packetsReceived 与 packetsSent :如果 A 的 packetsSent 持续增长,而 B 的 packetsReceived 不动,问题在 A 的上行链路,可能是上行带宽耗尽或机房防火墙只放行了入方向;反过来同理。和 PBX 侧交叉验证时,Asterisk 执行 pjsip set logger on ,抓 INVITE 的 SDP 看 c= 行地址,如果 SDP 里的地址和 webrtc-internals 里选中的候选对不一致,就是 NAT 或 ACL 层把 RTP 流量拦掉了。把两边的 ICE 候选导出给网络组,同时附上 webrtc-internals 里的 packetsSent 增长曲线,问题范围能直接缩到某条防火墙规则上。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)