WebRTC入门
一、基本原理&流程
1、基本原理
基本实现原理(Peer To Peer)信令与视频通话 - Web API 接口参考
(1)WebRTC服务构成
- 信令服务器:用于连接两个客户端,两个客户端连接时,需要进行协商,相互确定媒体格式,告知在网络中的位置(地址),在本篇使用WebSocket作为信令服务器:
WebSocket:一个客户端告知另一个客户端,建立通信请求并携带通讯信息,caller(发送者)发送offer,callee(接受者)接收offer,返回answer,offer/answer包含type(消息类型-offer/answer)、name(发送者用户名)、target(接受者用户名)、SDP(Session Description Protocol会话协议描述,存放视频编码、通讯协议【中间还可能包含身份验证操作】
STUN和TURN(提供ICE):
STUN:(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。
TURN:Traversal Using Relays around NAT,是STUN/RFC5389的一个拓展,主要添加了Relay功能。如果
终端在NAT之后, 那么在特定的情景下,有可能使得终端无法和其对等端(peer)进行直接的通信,这时就需要公网的服务器作为一个中继, 对来往的数据进行转发。这个转发的协议就被定义为TURN。
-
流媒体服务器(Kurento-Media-Server KMS)
当前需求下因为连接数低于三四个,也不需要额外功能,暂且不需要KMS,webrtc媒体服务器介绍_uianster的博客-CSDN博客_webrtc 媒体服务器。
-
客户端(浏览器/Electron)
(2)Demo流程
[]内为执行主体
参考demo项目地址:https://github.com/chachae/WebRTC-With-Socket
启动本demo需要配置coturn,在第二章可以查看
[被监控客户端(monitor)]启动->
-
[monitor]连接上Websocket
-
[ws]接收monitor用户id,并存入ws服务维护的ConcurrentHashMap(id为key,session为value且该Session为自定义封装,存放有netty-channel,如果该ConcurrentHashMap已存在,不使用新的value覆盖旧的value)
-
[monitor]用Promise异步打开本地音视频,并向ws服务定时不断发送心跳(循环调用/1t1接口,向redis发送广播消息)
//发送到redis广播中 subscribe netty-test-channel //发送的信息 Message(fromId=114, toId=114, content=ok, command=heart)
-
[ws]receiveMessage接收到redis的广播消息,消息类型为heart,执行handleHeart,状态为正常,关闭当前websocket会话(当超过一段时间没有接受到monitor的心跳,则默认monitor已关闭,此时ws会从ConcurrentHashMap中删除monitor的id)
但是有可能ws关闭,但客户端并未关闭,后期需要优化
[监控客户端(index)]启动->
-
[index]连接上Websocket
-
[ws]接收index用户id,并存入ws服务器维护的ConcurrentHashMap(此时monitor和index的用户id都存在此ConcurrentHashMap中)
[P2P开始连接阶段]
-
[index]输入任意用户id,点击提交,调用sendMonitor,初始化RTCPeerConnection:
initPeer() { //stun&turn地址配置 this.myPeerConnection = new RTCPeerConnection(this.configuration); 添加事件监听函数,接收ICE candidate信息,用于NAT 穿透,本质就是地址信息 this.myPeerConnection.onicecandidate = this.handleIceCandidate; this.myPeerConnection.ontrack = this.handleOnTrack; this.RTCPeerConnectionCreated = true; },
并将message封装好,调用/1t1接口,传入message,推送到redis广播中
//index发送给monitor {"command":"cmd","fromId":"index","toId":"monitor"}
-
[ws]接收redis广播,将toId和ConcurrentHashMap中的对比,没有直接忽略(有则继续),message的command的值为cmd,执行handleCmd,将fromId和toId对换后使用ConcurrentHashMap中根据key(原toId-monitor,现fromId)拿到的Session中Channel发送ws消息给monitor(该消息借助ws发送给toId)
-
[monitor]handleMessage接收到ws通过Websocket(monitor创建时建立的netty-channel)发送来的消息:
{"command":"cmd","fromId":monitor,"toId":"index"}
然后进入handleCmd,初始化RTCPeerConnection:
//monitor唯一初识化RTCPeerConnection操作 initPeer() { //stun&turn地址配置 this.rtcPeerConnection = new RTCPeerConnection(this.configuration); this.rtcPeerConnection.onicecandidate = this.handleIceCandidate; for (const track of this.localMediaStream.getTracks()) { this.rtcPeerConnection.addTrack(track, this.localMediaStream); } }
接下来创建SDP-offer(由monitor调用rtcAPI创建):
handleCmd(message) { this.cmdUser = message.toId //初始化RTCPeerConnection this.initPeer(); //创建SDP offer this.rtcPeerConnection.createOffer(this.offerOptions).then(this.setLocalAndOffer) .catch((e) => { console.log(e) } ); }, offerOptions格式: { offerToReceiveAudio: true, // 告诉另一端,是否想接收音频,默认true offerToReceiveVideo: true, // 告诉另一端,是否想接收视频,默认true iceRestart: false, // 是否在活跃状态重启ICE网络协商 } setLocalAndOffer(sessionDescription) { this.rtcPeerConnection.setLocalDescription(sessionDescription); this.sendOne(JSON.stringify({ command: 'offer', fromId: this.loginForm.userId, toId: this.cmdUser, content: {sdp: sessionDescription} })) }, 此时sessionDescription: RTCSessionDescription : { type: 'offer', sdp: 'v=0\r\no=- 6155307945155998122 2 IN IP4 127.0.0.1\r\ns…7eJSfKC9gV ee272cf4-2cd4-4a44-9cf9-9d6edf250413\r\n' }
最后调用sendOne发送至redis广播
-
[ws]handlerMessage接收monitor发来的SDP-offer消息(from:monitor、to:index),type为offer,调用handleCmd,fromId和toId转换(from:index、to:monitor),拿取toId的channel发送给toId(index)
-
[index]接收到monitor传来的SDP-offer信息:
Message(fromId=433, toId=756, content={"sdp":{"type":"offer","sdp":"xxxxxxxxx"}}, command=offer)
调用handleMessage接受,type为offer,调用handleOffer,判断RTCConnection标记是否为true,如果为false则实例化,调用rtcAPI接收sdp信息,并创建SDP-answer:
/*** * 处理offer */ handleOffer(message) { if (this.RTCPeerConnectionCreated === false) { this.initPeer() } let sdpMessage = message.content; let sdp = JSON.parse(sdpMessage).sdp; //设置连接端的sdp this.myPeerConnection.setRemoteDescription(new RTCSessionDescription(sdp)); //创建SDP-answer this.myPeerConnection.createAnswer().then(this.setLocalAndAnswer) .catch((e) => { console.log(e); } ); }, /*** * 处理 offer 并 answer */ setLocalAndAnswer(sessionDescription) { this.myPeerConnection.setLocalDescription(sessionDescription) this.sendOne(JSON.stringify({ command: 'answer', fromId: this.loginForm.userId, toId: this.monitorForm.userId, content: {sdp: sessionDescription} })) },
index创建好SDP-answer后,发送给ws,ws转换from和to后,转发给monitor
Message(fromId=756, toId=433, content={"sdp":{"type":"answer","sdp":"xxxx"}}, command=answer)
-
[monitor]接收index的SDP-answer,执行handleAnswer,设置远端(index)的SDP信息
handleAnswer(message) { let sdp = JSON.parse(message.content).sdp; this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(sdp)) }
-
当index和monitor设置localDescription时,会触发RTCPeerConnection实例.onIcecandidate()时间,rtc获取到RTCPeerConnectionIceEvent,经过ws发送给对方:
Message(fromId=615, toId=306, content={"candidate":{"sdpMLineIndex":0,"candidate":"candidate:122006300 1 udp 2122260223 192.168.30.1 61672 typ host generation 0 ufrag LLWN network-id 1","sdpMid":"0"}}, command=candidate)
-
双方接收到对方candidate消息后,执行handleCandidate方法:
handleCandidate(message) { let candidate = JSON.parse(message.content).candidate this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(candidate)).catch((e) => { console.log(e) }) },
-
因为monitor作为发送视频流方,在创建RTCPeerConnection实例时,将视频流加入到RTCPeerConnection实例中:
initPeer() { //创建RTCPeerConnection this.rtcPeerConnection = new RTCPeerConnection(this.configuration); this.rtcPeerConnection.onicecandidate = this.handleIceCandidate; for (const track of this.localMediaStream.getTracks()) { this.rtcPeerConnection.addTrack(track, this.localMediaStream); } }, //或者在收到对方Candidate消息时再添加视频流 handleCandidate(message) { let candidate = JSON.parse(message.content).candidate this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(candidate)).catch((e) => { console.log(e) }) },
-
[index]monitor调用addTrack(track, stream)后,index会接收到monitor的流信息,调用方法后播放:
this.myPeerConnection.ontrack = this.handleOnTrack; /*** * 处理track */ handleOnTrack(event) { let remoteMediaStream = event.streams[0]; let remoteVideo = document.getElementById("remoteVideo"); remoteVideo.srcObject = remoteMediaStream; remoteVideo.play(); }
-
监控联通成功(end)
二、STUN+TURN搭建
WebRTC服务搭建之Kurento | 码客 (psvmc.cn)
1、注意开放对应端口,在测试网址上出现Done则成功
2、coturn安装时make可能会报错缺少依赖
sudo yum install gcc
sudo yum install epel-release3、无外网环境下只配置内网地址即可
4、其中的stun和turn地址,需要写入上文演示demo中的stun&turn配置中
三、需求场景
可能浏览器会其实要求对应权限
1、电话
//mediaConstraints配置中开启audio 关闭video
navigator.mediaDevices.getUserMedia(this.mediaConstraints)
2、录屏
//screenConstraints配置中可选是否开启audio
navigator.mediaDevices.getDisplayMedia(this.screenConstraints)
3、视频电话
//mediaConstraints配置中开启audio(video默认开启)
navigator.mediaDevices.getUserMedia(this.mediaConstraints)
4、电话+录屏
5、视频通话+录屏
四、需求点
1、通话计时(页面展示)
解决方式:
- 单纯页面展示的话,做一个监听事件,当play()开启即可计时
- 其他用处?
2、流合并(电话+录屏/视屏通话+录屏)
https://github.com/muaz-khan/MultiStreamsMixer
3、通讯文件生成&上传
五、其他可用性
1、自定关闭按钮
解决方式:写一个监听事件处理,避免监控段看到暂停画面
openLocalScream() {
return new Promise((resolve, reject) => {
// 摄像头(二选一)
// navigator.mediaDevices.getUserMedia(this.mediaConstraints)
// 屏幕共享
navigator.mediaDevices.getDisplayMedia(this.screenConstraints)
.then((stream) => {
this.localMediaStream = stream;
let localScream = document.getElementById("localScream");
localScream.srcObject = this.localMediaStream;
localScream.play();
})
.then(() => {console.log("打开本地音视频设备成功")
//关闭共享事件监听
this.localMediaStream.getVideoTracks()[0].addEventListener('ended', () => console.log('停止屏幕共享 '))})
.catch(() => console.log("打开本地音视频设备失败"));
});
},
2、清晰度/分辨率
- 分辨率固定
//录屏参数
screenConstraints: {
video: {
cursor: 'always' | 'motion' | 'never',
displaySurface: 'window' | 'browser' | 'monitor' | 'window',
width: 1920,
height: 1080
}
},
//视频参数
mediaConstraints: {
video: {
width: 1920,
height: 1080
},
//开始语音
audio: true
},
- 分辨率约束(目前demo中screenConstraints不可用下列配置,会发生异常)
//1. 指定值
const constraints = {
width: 1280,
height: 720,
aspectRatio: 3/2
};
//2. 指定最小值和理想值
const constraints = {
frameRate: {min: 20},
width:{min: 640, ideal: 1280},
height:{min: 480, ideal: 720},
aspectRatio: 3/2
};
//3.指定最小值、理想值和最大值
const constraints = {
width: {min: 320, ideal: 1280, max: 1920},
height: {min: 240, ideal: 720, max: 1080},
}
参考第二章约束
- 分辨率动态调整(?)
3、流畅度(影响要素?)
(1)传输速率
-
SDP修改
Chrome默认将WebRTC的数据通道传输速度限制在30K
https://blog.csdn.net/holdsky/article/details/120841013?utm_medium=distribute.pc_relevant.none-task-blog-2defaultbaidujs_utm_term~default-0-120841013-blog-124912802.pc_relevant_multi_platform_whitelistv3&spm=1001.2101.3001.4242.1&utm_relevant_index=3
六、参考链接
WebRTC常用的API详解和SDP协商过程 | Manoner的学习笔记
WebRtc入门 - API说明_fun binary的博客-CSDN博客_webrtc接口文档
webrtc分辨率和比特率–问题集锦-蒲公英云 (dandelioncloud.cn)
Webrtc及WEB端音视频设备获取及流处理 | 码客 (psvmc.cn)
webrtc 入门第二章 音视频录制_日落班的博客-CSDN博客_webrtc录像
MediaDevices.getUserMedia() - Web API 接口参考 | MDN (mozilla.org)
webrtc之onicecandidate的 event handler的一点疑惑_会飞的胖达喵的博客-CSDN博客_onicecandidate什么时候调用
何时调用RTCPeerConnection.onIcecandidate()事件? - 问答 - 腾讯云开发者社区-腾讯云 (tencent.com)
Web实时语音/视频聊天/文件传输 - 掘金 (juejin.cn)