近日做的一个功能是页面打电话,使用了WebRTC的技术,实际上使用了JsSIP后,难度就直线下降到库的使用了,光学习库的使用是没有意义的,所以还是得先了解一下WebRTC的原理
原理简述
WebRTC是点到点,数据通道是P2P的,但是依旧需要服务器的支持,服务器的作用基本就是传输WebRTC所需要的信令信息,告诉浏览器端应该如何连接
在连接过程中,比较重要的点就是Network Address Translation (NAT)穿透,通常也叫做打洞,因为为了解决ipv4地址不足,大多数客户端都处在路由器的内网环境,比如内网地址为192.168.1.3,这时候需要绕过防火墙,建立一个在公网可见的唯一地址,通过NAT穿透,这个地址会被映射为公网IP+唯一端口,如182.150.184.98:52054
而这个穿透的过程通常使用Interactive Connectivity Establishment (ICE),也就是ICE协议框架,在此协议框架里,有两种服务器,分别为Session Traversal Utilities for NAT(STUN)服务器,和Traversal Using Relays around NAT(TURN)服务器,一般情况只会使用到STUN服务器,获得公网唯一可见地址后两端便可建立连接,但是如果路由器不允许主机直连,就需要TURN服务器来转发数据
STUN连接过程图示

TURN连接过程图示

JsSIP踩坑笔记
SIP协议和上面的WebRTC其实没有太大关系,通常的WebRTC并不会使用SIP协议,SIP协议用于发起、维持和终止实时会话包括语音、视频、消息的应用程序
JsSIP它主要就是解析SIP信令,让我们和服务器知道现在应该打电话,还是接电话,顺便封装了WebRTC的东西,不需要手动建立RTCPeerConnection连接
看看它官网的示例
var socket = new JsSIP.WebSocketInterface('wss://sip.myhost.com');
var configuration = {
sockets : [ socket ],
uri : 'sip:alice@example.com',
password : 'superpassword'
};
var ua = new JsSIP.UA(configuration);
ua.start();
// 注册打电话的回调事件
var eventHandlers = {
'progress': function(e) {
console.log('call is in progress');
},
'failed': function(e) {
console.log('call failed with cause: '+ e.data.cause);
},
'ended': function(e) {
console.log('call ended with cause: '+ e.data.cause);
},
'confirmed': function(e) {
console.log('call confirmed');
}
};
var options = {
'eventHandlers' : eventHandlers,
'mediaConstraints' : { 'audio': true, 'video': true }
};
var session = ua.call('sip:bob@example.com', options);先说基本流程
- 建立与服务器的
socket连接 ——new JsSIP.WebSocketInterface - 建立一个ua对象 ——
new JsSIP.UA - 拨打电话 ——
ua.call
它的问题在于事件绑定有点迷惑,我现在也不是特别懂,它有2种事件绑定方式
ua绑定回调事件call函数绑定回调事件
ua绑定事件
ua.on('connected', () => console.log('[SIP Phone] : Connected (On Call)'));
ua.on('registered', () => console.log('[SIP Phone] : Registered (On Call)'));
ua.on('registrationFailed', () => console.log('[SIP Phone] : Registration Failed (On Call)'));这是连接、注册、注册失败的事件,有一个最重要的事件,是newRTCSession,代表有新的通话
ua.on('newRTCSession', (e) => {
// 其中能拿到session对象和originator对象
// originator代表通话时本地呼出还是远程呼入
const { session, originator } = e;
// session绑定的事件才是最重要的
// 连接中
session.on('connecting', () => {});
// 连接已接受
session.on('accepted', () => {});
// 接通,在这一步可以处理音频播放
// 接通并不代表对方已经接受,接通代表 滴 滴 滴
session.on('confirmed', () => {});
// 结束
session.on('ended', () => {});
// 失败
session.on('failed', () => {});
// 手动让打孔结束,最多4次,有时候等待时间会很长
let iceCandidateCount = 0;
session.on('icecandidate', (data) => {
if (iceCandidateCount++ > 4) data.ready();
});
}事件注册上了,那么如何播放音频呢?
通过查阅了很多资料,发现了至少3种方式
const audioElement; // 获取到的页面audio元素
ua.on('newRTCSession', (e) => {
const { session } = e;
// session.connection代表的即是RTCPeerConnection实例对象
session.on('confirmed', (event) => {
// 第一种方式,已被废弃掉,但是chrome 80可用,此api在MDN上无法找到
audioElement.srcObject = session.connection.getRemoteStreams()[0];
// 第二种方式,已被废弃掉了,但是还是可用
session.connection.onaddstream = (e) => {
audioElement.srcObject = e.stream;
}
// 第三种方式,未被废弃,呼出触发,呼入不触发……
session.connection.ontrack = (e) =>{
audioElement.srcObject = e.streams[0]
}
// 前三种都被废弃了,最后研究了半天
// 闪亮登场的最终方式
const stream = new MediaStream();
const receivers = session.connection?.getReceivers();
if (receivers) receivers.forEach((receiver) => stream.addTrack(receiver.track));
audioElement.srcObject = stream;
// 最后都要播放
audioElement.play();
});
}确定了如何将声音播放了,下一步研究如何接电话
接电话、挂电话
在流程里,应该是这样的
ua.on('newRTCSession', (e) => {
const { session } = e;
session.answer();
}但是这样实际肯定行不通,因为这个事件肯定是需要绑定到按钮上,所以需要把这个事件挪出来,所以需要一个变量保存session对象
function sip() {
// ...
let currentSession;
ua.on('newRTCSession', (e) => {
const { session } = e;
currentSession = session;
});
// 包一层接电话函数
const answer = (options) => {
try {
if (currentSession) currentSession.answer(options);
else console.error('接通时RTCSession对象为空');
} catch (e) {
console.warn('电话接通失败:', e.message);
}
};
return {
answer
};
}打电话和挂电话同理,都需要将事件返回出去,挂在元素上,最后封装后应该这样返回
return {
call, // 打电话
terminate, // 挂电话
answer, // 接听
};在封装过程中,有一个比较重要的点就是this,实际上session的每一个回调都可以用this访问session对象,在箭头函数写多了、函数体里不使用this的规则后都忘记了this这个东西了
所以可以直接从外部传入一个整体回调,它们都this都可以访问session对象,所以也可以自己加自定义的回调,最后使用call绑定this就好了
// 当前状态
export enum Originator {
Local = 'local',
Remote = 'remote',
Idle = 'idle',
}
// 可传入的回调
export interface CallEventHandlers {
connecting?(this: RTCSession, event: SessionConnectingEvent): void;
accepted?(this: RTCSession, event: SessionAcceptedEvent): void;
confirmed?(this: RTCSession, event: SessionConfirmedEvent, stream: MediaStream): void;
ended?(this: RTCSession, event: SessionEndedEvent): void;
failed?(this: RTCSession, event: SessionFailedEvent): void;
// 自定义当有新来电时的事件
newCall?(this: RTCSession, originator: Originator): void;
// 自定义电话呼叫滴滴滴后对方接通的事件
connected?(this: RTCSession, originator: Originator): void;
}这里还有一个坑,我没有找到官方对应的解决方案,就是呼出 滴 滴 滴后,没有对方接听的事件,这个事件对应我上面的connected事件,我这里的解决方式是后台得在写一个websocket,通过这个来告知我是否接听
总结
使用JsSIP不需要处理SIP信令,也不需要学WebRTC相关知识,虽然个人觉得它的事件形式不太好,ts类型也不完善,但是已然是一个非常好,非常简单易用的库了
个人认为页面打电话这一套的技术难点主要还是在后台,所以我很佩服我们这儿的后台,我脑子里完全无法想象他一个人是怎么搞设备插了几百张卡,又怎么连接到linux,又怎么搭STUN和TURN服务器,又怎么写服务能和我通话,真到牛皮
我觉得也许SIP协议都不应该放在前端处理,应该服务端再搞一个SIP协议解析的中间件,前端单纯使用WebRTC就更好了,说起来本来觉得这个打电话没什么难度,周末想自己学习WebRTC写一个在线的视频demo,结果好像难度很大的样子就放弃了,今天想想怎么能轻言放弃,下周一定得搞一搞
参考资料:https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API