简介:这是一份基于JSSIP库构建的支持WebRTC的SIP通信DEMO,面向Web开发、VoIP及实时通信初学者,演示在浏览器环境中完成SIP注册、呼入呼出、接听挂断、媒体流建立等核心流程。压缩包共72个文件、3.16MB,以JavaScript脚本、HTML页面、CSS样式为核心,另含多张页面图标背景图、多段ogg/wav提示音效、gif加载动画、jQuery依赖及README说明文档,功能模块完整,目录结构清晰便于检索。已有767人学习下载。DEMO完整展示了JSSIP初始化配置、事件监听、invite/accept/bye呼叫操作、RTCPeerConnection本地与远程媒体流处理等关键代码,还附带拨号盘、会话控制界面和全套音效素材,覆盖从呼叫建立到结束的完整回路。研读并运行示例后,开发者可快速理解SIP信令与WebRTC媒体传输如何协同,并以此为基础扩展出自己的实时音视频通话应用。 做过VoIP通信的人应该都有感触:过去做SIP软电话,要么装桌面客户端,要么嵌ActiveX控件,要么折腾浏览器插件,一套流程下来,光环境适配就够喝一壶的。现在WebRTC普及之后,情况完全不一样了——纯JavaScript就能在浏览器里跑起一个SIP客户端,信令走SIP协议,媒体走WebRTC,用户打开网页就能打电话。

这篇内容就是围绕一个支持WebRTC的SIP JS Demo展开,我会把它拆开揉碎:从协议配合逻辑、技术选型,到注册、呼叫、接听的完整代码链路,再到浏览器环境下的各种坑和弱网优化方向。无论你是刚接触WebRTC的初学者,还是已经在做通信平台想快速出一个浏览器端Demo验证方案的开发者,这篇文章都能帮你少走不少弯路。

1. 一个浏览器电话背后的三方协作逻辑

在写代码之前,得先把一个基础问题讲清楚:SIP和WebRTC到底是怎么配合的?很多人一上来就找API调用,结果被各种事件回调搞晕,根源就是没理解这两者各管哪一段。

1.1 SIP管信令,WebRTC管媒体:二者互补

SIP(Session Initiation Protocol)诞生于上世纪九十年代,设计初衷是建立、修改和终止多媒体会话。它只负责"商量"——谁呼叫谁、用什么编码、在哪个IP和端口收流——但音频和视频数据本身不归它管。打个比方,SIP就像酒店前台,负责登记入住、分配房间、办理退房;而你真正睡觉用的床、空调、热水,是另一个系统在管。

WebRTC就是那个管"床"的系统。它通过SRTP(安全实时传输协议)传输音视频数据,底层整合了音频采集、视频采集、编解码、抖动缓冲、丢包重传等一系列能力。浏览器里打电话的音频数据流,走的完全是WebRTC通道,SIP信令只是告诉双方"我在这个地址等你,用OPUS编码,DTLS指纹是xxx"。

在传统SIP软电话里,RTP媒体流是直接点对点传输的。而WebRTC模式多了一层DTLS-SRTP的协商过程,这也意味着SIP消息体(SDP Offer/Answer)里携带的字段更多、更复杂。但好在JS库把这些细节都封装掉了,你要做的是理解流程,而不是去手搓SDP。

1.2 为什么非得是JS,不能是插件

放在十年前,想在浏览器里打电话,主流的方案是搞一个ActiveX控件或者NPAPI插件。那套玩意的维护成本高到离谱:每个浏览器版本、每个操作系统都要单独适配,用户还经常因为权限设置装不上。后来Flash走了,插件这条路彻底断了。

WebRTC普及之后,浏览器原生支持音视频采集和传输,JS只需要做三件事:连接SIP服务器、解析信令消息、操作WebRTC API。这三件事纯前端就能干完,不需要任何插件,不需要安装运行时,打开Chrome或者Firefox就能跑。对产品方来说,这意味着CTI(计算机电话集成)功能的部署成本大幅下降——客服系统、在线问诊、远程协审这些场景,直接在网页里嵌一个软电话就行了。

1.3 这套方案适合谁、不适合谁

不是所有场景都适合纯浏览器SIP客户端。我见过不少团队在选型时踩坑,所以这里把边界说清楚。

适合的场景:

  • 客服工作台、CRM系统里的点击拨号
  • 需要快速验证明星产品的WebRTC通话能力
  • 已经有FreeSWITCH、Asterisk、Kamailio等服务端,想在浏览器端做接入的场景
  • 需要跨平台覆盖(Windows、macOS、Linux、移动端)但没有桌面客户端开发资源

不适合的场景:

  • 对通话稳定性要求极其苛刻的呼叫中心(仍建议用原生客户端,浏览器后台限制多)
  • 需要在后台保持注册状态的场景(浏览器标签页一旦休眠,WebSocket连接可能断开)
  • 需要接入高级电话功能(如复杂呼叫转移、多方会议编排)且话务量极高的场景

技术选型这件事,先确认边界,再谈实现,比什么都要紧。

2. 技术选型:SIP.js与JsSIP的取舍

明确了"用JS做SIP客户端"这个方向之后,接下来面对的就是选库问题。目前市面上主流的开源方案有两个:SIP.js和JsSIP。这两个我都实际用过,简单说说差异和踩坑心得。

2.1 SIP.js:官方认证的升级路线

SIP.js是目前活跃度最高、维护最频繁的SIP客户端库。它经历过大版本重构,现在的API设计更贴近WebRTC原生习惯,而且官方长期跟踪RFC标准,比如Message、Refer、Info等扩展方法的支持都比较完整。

SIP.js 0.x系列和2.x系列的API差别比较大。0.x的时候注册、呼叫的写法偏向回调风格,2.x全面转向Promise和基于事件的Session管理,整体更现代。如果你在网上下载到一份老教程,代码用的是 ua.register() 的回调写法,那大概率是基于0.x的,需要先确认版本再抄作业。

2.2 JsSIP:轻量但维护放缓

JsSIP也是一个老牌库,体积相对小巧,早年很多人拿它做快速原型。它的API设计比较稳定,文档也算齐全。但问题在于维护节奏明显放缓,对最新WebRTC规范中部分新特性(比如Insertable Streams)的跟进不够及时。

如果项目对依赖体积敏感,且功能需求相对固定,JsSIP并不是不能用。但如果你是在一个全新项目里做长期技术选型,我建议优先考虑SIP.js——从维护活跃度、社区反馈速度和未来兼容性来看,它更稳妥。

2.3 我最终的选择和理由

这个Demo我选的是SIP.js的2.x版本。理由如下:

  1. 它对TypeScript支持良好,用起来更安心,报错信息也更友好。
  2. 官方示例仓库里有一个 demo 目录,代码可以直接跑,省去很多前期摸索时间。
  3. 底层事件模型清晰,调试WebSocket信令时容易定位问题。
  4. 社区活跃,遇到问题在GitHub Issues里基本能找到答案,或者直接提Issue有人回复。

提示:无论选择哪个库,先把Demo跑通,再深入改业务逻辑。不要一开始就纠结各种高级配置,先把基础链路跑通比什么都重要。

3. Demo核心链路拆解:从注册到接听的完整实现

下面进入正文核心部分。我会按照一个最小可用Demo的完整流程来拆解:初始化UA、建立WebSocket连接、注册分机、发起呼叫、被叫接听、通话结束。每一段都会讲清楚代码在做什么,以及为什么要这样做。

3.1 初始化UA与WebSocket连接

在SIP.js中,UA(User Agent)是最核心的对象,代表一个SIP用户终端。UA需要指定SIP服务器的WebSocket地址,通常会加一个 sipjs 的路径标识。

import { UserAgent } from 'sip.js';
// 或者用浏览器全局引入方式
const { UserAgent } = window.sipjs;

const ua = new UserAgent({
  uri: 'sip:1001@your-sip-server.com',
  transportOptions: {
    server: 'wss://your-sip-server.com:7443/sipjs'
  },
  authorizationUsername: '1001',
  authorizationPassword: 'your-password',
  displayName: '坐席小王',
  sessionDescriptionHandlerFactoryOptions: {
    peerConnectionConfiguration: {
      iceServers: [
        { urls: 'stun:stun.your-server.com:3478' }
      ]
    }
  }
});

uri 是SIP地址标识,一般格式是 sip:分机号@域名 。 authorizationUsername 和 authorizationPassword 用于注册鉴权。 peerConnectionConfiguration 里配置STUN/TURN服务器——这是WebRTC媒体协商的关键,如果两边都在NAT后面,没有STUN/TURN基本连不通媒体流。

UA创建之后需要启动和连接:

await ua.start();

这一步会触发WebSocket连接,连接成功后UA会自动发送REGISTER请求(如果设置了自动注册)。

3.2 注册流程的完整实现

SIP协议里,注册的作用是告诉服务器"这个分机当前在这个地址,可以收到针对它的呼叫"。SIP.js把注册封装成了 register() 方法,但底层仍然在走标准的REGISTER事务。

ua.register({
  extraHeaders: []
}).then(() => {
  console.log('注册成功');
}).catch((error) => {
  console.error('注册失败', error);
});

ua.delegate = {
  onRegistered: () => {
    console.log('UA已注册,随时可以呼叫');
  },
  onUnregistered: (cause) => {
    console.log('已注销,原因:', cause);
  },
  onCallReceived: (invitation) => {
    // 来电处理逻辑,后面细说
  }
};

注册和来电是两个独立的事件流。注册成功后UA会维护心跳,服务端通过心跳判断客户端是否在线。如果WebSocket断开,UA会自动重连并重新注册,但这个重连机制在浏览器后台标签页中可能会失效——这点后面踩坑章节会展开。

3.3 呼叫与接听:从invite到answer的消息流转

发起呼叫(主叫侧)

简单的呼叫可以用 ua.call() 方法,它会生成一个 Inviter 实例。

const targetUri = 'sip:1002@your-sip-server.com';

const inviter = new Inviter(ua, targetUri, {
  sessionDescriptionHandlerOptions: {
    constraints: {
      audio: true,
      video: false
    }
  }
});

inviter.invite()
  .then(() => console.log('INVITE已发送'))
  .catch((error) => console.error('呼叫失败', error));

inviter.stateChange.addListener((state) => {
  if (state === SessionState.Established) {
    console.log('通话已建立');
  }
  if (state === SessionState.Terminated) {
    console.log('通话已结束');
  }
});

这里的一个关键点是 constraints 。 audio: true 表示要采集麦克风音频; video: false 表示不采集视频。在发起呼叫时,浏览器会弹出麦克风权限请求,这个权限提示是浏览器安全模型的一部分,无法取消,但可以通过HTTPS和用户交互来缓解。

被叫接听(被叫侧)

被叫侧的接入点在 ua.delegate.onCallReceived 中:

onCallReceived: async (invitation) => {
  console.log('收到来电,主叫:', invitation.remoteIdentity.uri);

  // 设置接听时的媒体参数
  const options = {
    sessionDescriptionHandlerOptions: {
      constraints: {
        audio: true,
        video: false
      }
    }
  };

  // 延迟接听,模拟振铃过程
  setTimeout(async () => {
    try {
      await invitation.accept(options);
      console.log('已接听');
    } catch (error) {
      console.error('接听失败', error);
    }
  }, 2000);

  // 如果不接听
  // invitation.reject();
}

invitation.accept() 会返回一个Promise。接听成功后,WebRTC协商完成,双方进入通话状态。SIP.js会为接听的对话自动创建一个Session实例,可以通过 invitation 对象来管理挂断、静音等操作。

挂断通话

挂断事件可以在 Inviter 或 Session 上监听:

// 主叫挂断
inviter.hangup().then(() => {
  console.log('通话已挂断');
});

// 被叫挂断(在Session上操作)
session.hangup().then(() => {
  console.log('通话已挂断');
});

挂断会发送BYE消息通知对端,双方同时释放媒体资源。

注意: hangup() 必须在通话建立之后调用,如果在振铃阶段想取消呼叫,应该用 cancel() 方法。

3.4 流媒体处理:推流拉流在通话中的体现

WebRTC通话说到底就是推流和拉流的双向过程。这里"推流"指本端采集音频并编码发送,"拉流"指接收对端音频并解码播放。SIP.js封装了底层操作,但你可以通过 SessionDescriptionHandler 拿到底层的 RTCPeerConnection 对象,做更精细的媒体控制。

// 获取底层RTCPeerConnection
const sdh = session.sessionDescriptionHandler;
const pc = sdh.peerConnection;

// 查看连接的ICE状态变化
pc.oniceconnectionstatechange = () => {
  console.log('ICE状态:', pc.iceConnectionState);
  if (pc.iceConnectionState === 'disconnected') {
    console.log('媒体链路异常,可能需要重新协商');
  }
};

// 查看当前使用的编码
const senders = pc.getSenders();
senders.forEach((sender) => {
  if (sender.track) {
    console.log('发送的音视频轨:', sender.track.kind);
  }
});

这些底层能力在日常Demo里可能用不上,但在排查"为什么通了话但听不到声音"这类问题时非常管用。

4. 真实环境里的坑与对策

跑通Demo只是第一步,把它放到真实浏览器环境里,问题才会一个个浮出来。这里总结我在实践中真实踩过的坑,每一个都有对应的排查路径和解决思路,按排查链路的顺序来写。

4.1 浏览器自动播放策略导致的声音丢失

这是WebRTC通话最经典的问题:呼通之后,发现听不到对方声音,但对方听得到你。原因很简单——浏览器的自动播放策略禁止页面在没有用户交互的情况下播放音频。来电时如果没有用户点击动作, AudioContext 默认是挂起状态。

排查链路:

  1. 先确认通话状态已建立( SessionState.Established )
  2. 打开浏览器控制台,检查 AudioContext 状态
  3. 如果状态是 suspended ,就实锤是自动播放策略的问题

解决思路:

  • 在用户点击"接听"按钮的事件处理函数里,先调用一次 audioContext.resume() ,再执行 invitation.accept()
  • 或者在页面初次交互时初始化一个 AudioContext 并放到 running 状态
// 在用户点击接听按钮的处理函数里
async function handleAcceptClick(invitation) {
  // 先恢复音频上下文
  const audioContext = new AudioContext();
  await audioContext.resume();
  // 再接听
  await invitation.accept();
}

4.2 弱网卡顿的排查方向

关于WebRTC弱网卡顿优化,网上搜出来的方案非常多,但真正落地时要明白优先级。我的排查顺序是这样的:

第一梯队:确认网络质量。用 RTCPeerConnection.getStats() 拉取实时统计,看往返时延、丢包率、抖动三个指标。丢包率超过2%就会感知到明显卡顿,超过5%基本没法正常通话。

setInterval(async () => {
  const stats = await pc.getStats();
  stats.forEach((report) => {
    if (report.type === 'inbound-rtp' && report.kind === 'audio') {
      console.log('丢包率:', 
        (report.packetsLost / report.packetsReceived * 100).toFixed(2) + '%');
      console.log('往返时延:', report.roundTripTime + 'ms');
    }
  });
}, 3000);

第二梯队:检查编码参数。OPUS编码适合语音,但要对码率做限制,避免带宽不足时满负荷发送反而加剧拥塞。可以在SDP协商阶段设置 maxaveragebitrate 为20000到30000bps。

第三梯队:配置TURN服务器。很多"通了话没声音"的案例,本质是媒体流没打通,ICE协商失败。用TURN中继转发可以绕开对称型NAT,代价是增加延迟和服务器带宽成本。生产环境一定要测一下无TURN时的连通率,再决定要不要上TURN。

弱网优化是个系统工程,但Demo阶段做到前两步已经能解决大部分问题。

4.3 设备切换与回音问题

浏览器里可以枚举音频输入输出设备,但切换后要让WebRTC感知到,最简单的办法是重启通话的媒体轨道。SIP.js在通话过程中动态替换轨道的封装不算友好,先确认你用的版本是否支持 replaceTrack 。

我的做法是:在设置界面把选中的设备ID存到 localStorage ,然后在每次点击呼叫前,用选定的设备约束重新获取用户媒体权限。

async function getAudioStream() {
  const deviceId = localStorage.getItem('micDeviceId');
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: deviceId ? { deviceId: { exact: deviceId } } : true
  });
  return stream;
}

回声问题是另一类高频问题。浏览器自带的回声消除(AEC)在大多数场景有效,但遇到以下情况依然会有回声:

  • 音量开太大
  • 对方用耳机时双方设备距离过近
  • 浏览器AEC和硬件回声消除叠加

排查回声问题时,先让对端静音,如果回声消失,说明问题出在对端的采集路径;如果回声仍在,大概率是本地播放路径的问题。这类问题排查链路要清晰,不要一上来就怀疑SIP服务器。

4.4 HTTPS与证书问题

WebRTC的 getUserMedia 、 AudioContext 这些API在非HTTPS环境下是被浏览器直接禁用的。本地开发时 localhost 是例外,但一旦部署到测试服务器,必须上HTTPS。这个其实很好理解:浏览器把用户的麦克风和摄像头当成敏感资源,不可能在没有安全传输的页面下开放权限。

如果测试环境没有正式证书,有两个常见折中方案:

  • 使用自签名证书,但要在每台测试机的浏览器里手动信任该证书
  • 使用内网HTTP搭配 chrome://flags 里的 unsafely-treat-insecure-origin-as-secure 选项,仅限Chrome,且不推荐作为长期方案

提示:自签名证书信任后,如果用 wss:// 连接SIP服务器,注意WebSocket的证书校验是独立于页面访问的。很多人在浏览器里访问测试页面没问题,但UA的WebSocket连接仍然报证书错误,就是因为没把证书加到系统信任链。

4.5 后台标签页导致呼叫断线

浏览器为了省资源,会冻结后台标签页的定时器和WebSocket连接,这在移动端尤其明显。如果你把浏览器切到后台,SIP注册超时、来电延迟甚至漏接都有可能发生。

这个问题在纯Web方案里没有一个完美的解决方式,但可以缓解:

  • 监听 visibilitychange 事件,页面重新可见时立刻检查 UA.isConnected() ,断了就执行 ua.start() 重新连接
  • 配合Web Worker做心跳保活,但要注意Worker里的 WebSocket 和主线程的 WebSocket 不能共享连接
  • 在产品的层面,明确告知用户:通话过程中不要切走标签页

我在实际项目中,曾经因为一个后台标签页问题被用户投诉"漏接了重要客户来电"。排查到最后,就是浏览器冻结了WebSocket,SIP服务器发来的INVITE根本没到UA。后来上了页面重新可见时的强校验重连,问题才有效缓解。

5. 更进一步:Demo之外的工程化思路

如果你已经跑通了上面这个Demo,下一步就是考虑怎么把它用到真实项目中。我没有打算写一个冗长的总结,只想分享几个我踩过坑之后觉得非常值得做的工程化改造点。

第一,把注册状态做成可观测的。在页面上明确标识当前分机的注册状态、媒体流质量、信令连接状态。这个看似简单的功能,能在联调和线上问题排查时节省大量的时间。

第二,把日志结构化。SIP.js提供了 Logger 工厂,可以自定义日志级别和输出。我在实际项目中会把所有信令消息、WebSocket连接状态、ICE状态全部输出到结构化日志里,遇到问题直接搜索日志,不用再开DevTools。

第三,考虑是否需要TURN。这个问题的答案是"做了负载测试才知道"。用一个简单的双向通话测试脚本,模拟不同网络环境(同一内网、不同内网、移动热点),记录ICE的成功率和通话质量。如果P2P连接率低于预期,再考虑部署TURN服务。

第三,版本锁定。SIP.js的API变动不算频繁,但涉及依赖的 sip.js/lib 路径和浏览器兼容性时,最好把版本号锁定在 package.json 里,并写清楚使用的SIP服务器版本。我遇到过好几次,Demo在FreeSWITCH上跑得好好的,换到另一个SIP Server后媒体协商失败,最后发现是编码不一致导致的。这种问题排查起来费神费力,提前做好版本记录能少走很多弯路。

最后再说一句关于调试工具的心得:除了Chrome DevTools,学会抓取WebSocket信令帧也很有用。SIP.js的Transport事件会暴露出底层消息,也可以在服务端抓包看SIP流量。信令流调试清楚之后,剩下的媒体问题通常就比较好定位了。希望这份经验对你有用,也欢迎在GitHub上提交你的Demo改进。

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

Logo

邀请您加入社区

更多推荐