近日做的一个功能是页面打电话,使用了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