讲前须知
WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的API。它于2011年6月1日开源并在Google、Mozilla、Opera支持下被纳入万维网联盟的W3C推荐标准。
首先,他即是API也是协议。
其次,他是浏览器进行音频与视频通话的API,其实还有屏幕共享的功能。
最后,它现在已经处于W3C标准,各大浏览器厂商已经对他进行兼容了。
但是如果我们想使用好webrtc,就得先了解websocket。而对于websocket,大家应该都比较熟悉了,比如社交聊天、多人游戏、协同编辑、视频会议、基于位置的应用(地图)、等等需要高实时的场景。我们比较常用的微信、QQ、某些直播软件等等也都是基于websocket实现消息与信令的转发。大家看到这里可能在信令这里迟疑了,接着看。
webrtc是P2P的一种技术,什么是P2P?其实就是 端对端,就说是你的音频、视频流不经过服务器中转,直接由一端发送到另一端。
不经过服务器中转,也就说时候,如果通过过程中服务器突然崩溃,是不是通话还能继续?
是的!但是发送音频视频流前,一定是需要建立P2P连接的,建立连接前一定需要服务器进行信令转发,这个信令就是通话两端的标识。
而如果想用webrtc实现通话,就得先中转信令、建立连接。而建立连接的话最好是要用websocket进行信令转发的。大家都知道,websocket是个通道,在这个通道的所有端,都可以收到任意一端的消息流,包括发消息的本人。
为什么不经过服务器就可以直接获取到对方的视频音频流呢?是因为建立了P2P 通道,这个P2P在中转信令的时候就已经通了,传输视频音频流的时候还要啥服务器啊。这个时候,肯定有小伙伴表示怀疑,音频视频流可以不通过服务器?是的,我骗了大家,确实要经过服务器,但是只是线上需要服务器转发,如果我们是本地两台或者多台同一局域网的端 进行webrtc音频视频流的转发,确实不需要中转服务器,但是线上有可能需要,也有可能不需要,这里又涉及到了一个打洞的概念。
我们平常可能会听到比较牛x的词汇,什么打洞、内网穿透、NAT穿越,各种高大上的东西,其实也是蛮好理解的。大家都知道,两个不同网络下的两台主机不可以直接进行通信,而是需要走公网或者说各自的网关。打洞、内网穿透、NAT穿越其实就是一个意思,就是使用udp让我们两台非同一网络的主机互联,不走公网,直接实现连接。有玩过花生壳的同学一定能理解内网穿透这个概念。
本地开发的话,两台主机连同一局域网,根本不需要内网穿透,就可以直接通信。
线上开发的话,如果能够STUN打洞成功,也不需要中转服务器。但是,有打洞不成功的概率,为什么呢,因为没有走公网,没有给运营商带来收益却带来通信成本,肯定要限制。国外打洞成功的概率在70%,但是国内50%都不到。
所以,为了防止打洞不成功的情况,我们使用TURN中转服务器 转发流媒体数据进行一个最后保障。此外还有一种方式为 逆向连接 ,也可以帮助我们实现P2P建立,他的原理是必须是一方走公网,也是有局限性的。
coturn中继服务器由两部分组成 STUN与TURN,STUN帮助我们打洞,TURN帮助我们转发流媒体数据。
连接过程
以下所有API截止到2021.12.06为最新

媒体协商开始
A 与B通过后端服务进行了websocket连接,进入了相同的管道,A与B都可以收到自己与对方的消息与信令。
A Create PeerConnect创建了RTCPeerConnection实例(webrtc连接实例)。
A AddStream (getUserMedia方法)获取本地的音频流, 在本地展示。(打开通话先显示自己的视频画面)。
A 调用webrtc连接实例的CreateOffer方法,创建offer(SDP格式描述), 这个offer包含A自己的媒体信息和编解码信息。
A 调用webrtc连接实例的setLocalDescription方法将offer设置为本地描述,并且向sturn/turn中继服务器发送bind request请求收集candidate(候选者)。
A 发送了offer,信令服务器(后端服务)将其中转到B。
B 收到offer, Create PeerConnect创建了RTCPeerConnection实例(webrtc连接实例)。
B AddStream (getUserMedia方法)获取本地的音频流, 在本地展示。(显示自己的视频画面)。
B 调用webrtc连接实例的setRemoteDescription方法,将offer设置为自己的远端描述。
B 调用webrtc连接实例的CreateAnswer方法,创建answer(SDP格式描述),这个answer包含A自己的媒体信息和编解码信息。
B 调用webrtc连接实例的setLocalDescription方法将answer设置为本地描述,并且向sturn/turn中继服务器发送bind request请求收集candidate(候选者)。
B 发送了answer,信令服务器(后端服务)将其中转到A。
A 收到answer,调用webrtc连接实例的setRemoteDescription方法,将answer设置为自己的远端描述。
媒体协商完成
sturn/turn 服务器这时候不在接收到bind request请求了, 回应 A onIceCandidate(候选者), 这里面包含A的公网IP与端口。
A 发送候选者给信令服务器(后端服务), 信令服务器中转到B。
B 调用webrtc连接实例的addIceCandidate方法将候选者A(A的公网IP与端口)添加到B的候选者列表。
sturn/turn 服务器这时候不在接收到bind request请求了, 回应 B onIceCandidate(候选者), 这里面包含B的公网IP与端口。
B 发送候选者给信令服务器(后端服务), 信令服务器中转到A。
A 调用webrtc连接实例的addIceCandidate方法将候选者B(B的公网IP与端口)添加到A的候选者列表。
这时候A/B都拿到了对方的通信候选者(公网IP与端口)。
进行P2P选出最优线路。
B 调用webrtc连接实例onAddStream将A的视频音频流播放出来。
A 调用webrtc连接实例onAddStream将B的视频音频流播放出来(通话开始,对方的视频有了)。
这时候如果内网穿透失败了。
sturn/turn 服务器(其实是turn起作用)就会帮忙转发音频流,因为他已经有所有的候选者列表(所有的IP与端口)。
加深理解
本地开发,同一局域网,P2P连接建立不需要coturn中转服务器,因为不需要打洞,websocket通道使主机能够在同一管道内,相互发送offer和answer,可以建立p2p。
线上环境,不同局域网,websocket通道使主机在同一管道内,相互发送offer和answer,想要建立p2p(一定要使用sturn进行nat穿越),但是运营商截断(建立p2p失败),使用turn中转音频流。
我有疑问
P2P连接是什么?
P2P流媒体技术,这个技术主要是解决服务器负载过大(传统的都是服务器转发音频流),多端不经过服务器转发音频流,而是在网状的P2P通道内进行。下载资源、直播、音视频通话、共享桌面等很大一部分是基于此技术实现。
offer的本质是什么?SDP又是什么?
offer就是个信令名字,创建offer本质上就是创建sdp。
给大家看看sdp的本质,就是自身的媒体信息和编解码信息
sdp: {
type: 'offer',
sdp: 'v=0\r\n' +
'o=- 890410854023526853 2 IN IP4 127.0.0.1\r\n' +
's=-\r\n' +
't=0 0\r\n' +
'a=group:BUNDLE audio video\r\n' +
'a=extmap-allow-mixed\r\n' +
'a=msid-semantic: WMS EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc\r\n' +
'm=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 106 105 13 110 112 113 126\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:+b1T\r\n' +
'a=ice-pwd:2MMQo86tKV27zgrrsMhvhGqK\r\n' +
'a=ice-options:trickle\r\n' +
'a=fingerprint:sha-256 A0:F2:F7:C0:BE:1B:8C:EF:6C:42:03:D7:6E:6B:B2:DC:AE:57:F1:F3:DD:67:86:F6:11:F5:5B:44:49:D5:44:9A\r\n' +
'a=setup:actpass\r\n' +
'a=mid:audio\r\n' +
'a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n' +
'a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n' +
'a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtpmap:111 opus/48000/2\r\n' +
'a=rtcp-fb:111 transport-cc\r\n' +
'a=fmtp:111 minptime=10;useinbandfec=1\r\n' +
'a=rtpmap:63 red/48000/2\r\n' +
'a=fmtp:63 111/111\r\n' +
'a=rtpmap:103 ISAC/16000\r\n' +
'a=rtpmap:104 ISAC/32000\r\n' +
'a=rtpmap:9 G722/8000\r\n' +
'a=rtpmap:0 PCMU/8000\r\n' +
'a=rtpmap:8 PCMA/8000\r\n' +
'a=rtpmap:106 CN/32000\r\n' +
'a=rtpmap:105 CN/16000\r\n' +
'a=rtpmap:13 CN/8000\r\n' +
'a=rtpmap:110 telephone-event/48000\r\n' +
'a=rtpmap:112 telephone-event/32000\r\n' +
'a=rtpmap:113 telephone-event/16000\r\n' +
'a=rtpmap:126 telephone-event/8000\r\n' +
'a=ssrc:1511813723 cname:P0KGpA3OHyfIh1hw\r\n' +
'a=ssrc:1511813723 msid:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc a3daa1c2-1f35-426f-a242-2a0286202c04\r\n' +
'a=ssrc:1511813723 mslabel:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc\r\n' +
'a=ssrc:1511813723 label:a3daa1c2-1f35-426f-a242-2a0286202c04\r\n' +
'm=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 35 36 124 119 123 118 114 115 116\r\n' +
'c=IN IP4 0.0.0.0\r\n' +
'a=rtcp:9 IN IP4 0.0.0.0\r\n' +
'a=ice-ufrag:+b1T\r\n' +
'a=ice-pwd:2MMQo86tKV27zgrrsMhvhGqK\r\n' +
'a=ice-options:trickle\r\n' +
'a=fingerprint:sha-256 A0:F2:F7:C0:BE:1B:8C:EF:6C:42:03:D7:6E:6B:B2:DC:AE:57:F1:F3:DD:67:86:F6:11:F5:5B:44:49:D5:44:9A\r\n' +
'a=setup:actpass\r\n' +
'a=mid:video\r\n' +
'a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\n' +
'a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n' +
'a=extmap:13 urn:3gpp:video-orientation\r\n' +
'a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n' +
'a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n' +
'a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n' +
'a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n' +
'a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n' +
'a=sendrecv\r\n' +
'a=rtcp-mux\r\n' +
'a=rtcp-rsize\r\n' +
'a=rtpmap:96 VP8/90000\r\n' +
'a=rtcp-fb:96 goog-remb\r\n' +
'a=rtcp-fb:96 transport-cc\r\n' +
'a=rtcp-fb:96 ccm fir\r\n' +
'a=rtcp-fb:96 nack\r\n' +
'a=rtcp-fb:96 nack pli\r\n' +
'a=rtpmap:97 rtx/90000\r\n' +
'a=fmtp:97 apt=96\r\n' +
'a=rtpmap:98 VP9/90000\r\n' +
'a=rtcp-fb:98 goog-remb\r\n' +
'a=rtcp-fb:98 transport-cc\r\n' +
'a=rtcp-fb:98 ccm fir\r\n' +
'a=rtcp-fb:98 nack\r\n' +
'a=rtcp-fb:98 nack pli\r\n' +
'a=fmtp:98 profile-id=0\r\n' +
'a=rtpmap:99 rtx/90000\r\n' +
'a=fmtp:99 apt=98\r\n' +
'a=rtpmap:100 VP9/90000\r\n' +
'a=rtcp-fb:100 goog-remb\r\n' +
'a=rtcp-fb:100 transport-cc\r\n' +
'a=rtcp-fb:100 ccm fir\r\n' +
'a=rtcp-fb:100 nack\r\n' +
'a=rtcp-fb:100 nack pli\r\n' +
'a=fmtp:100 profile-id=2\r\n' +
'a=rtpmap:101 rtx/90000\r\n' +
'a=fmtp:101 apt=100\r\n' +
'a=rtpmap:102 H264/90000\r\n' +
'a=rtcp-fb:102 goog-remb\r\n' +
'a=rtcp-fb:102 transport-cc\r\n' +
'a=rtcp-fb:102 ccm fir\r\n' +
'a=rtcp-fb:102 nack\r\n' +
'a=rtcp-fb:102 nack pli\r\n' +
'a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f\r\n' +
'a=rtpmap:121 rtx/90000\r\n' +
'a=fmtp:121 apt=102\r\n' +
'a=rtpmap:127 H264/90000\r\n' +
'a=rtcp-fb:127 goog-remb\r\n' +
'a=rtcp-fb:127 transport-cc\r\n' +
'a=rtcp-fb:127 ccm fir\r\n' +
'a=rtcp-fb:127 nack\r\n' +
'a=rtcp-fb:127 nack pli\r\n' +
'a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f\r\n' +
'a=rtpmap:120 rtx/90000\r\n' +
'a=fmtp:120 apt=127\r\n' +
'a=rtpmap:125 H264/90000\r\n' +
'a=rtcp-fb:125 goog-remb\r\n' +
'a=rtcp-fb:125 transport-cc\r\n' +
'a=rtcp-fb:125 ccm fir\r\n' +
'a=rtcp-fb:125 nack\r\n' +
'a=rtcp-fb:125 nack pli\r\n' +
'a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n' +
'a=rtpmap:107 rtx/90000\r\n' +
'a=fmtp:107 apt=125\r\n' +
'a=rtpmap:108 H264/90000\r\n' +
'a=rtcp-fb:108 goog-remb\r\n' +
'a=rtcp-fb:108 transport-cc\r\n' +
'a=rtcp-fb:108 ccm fir\r\n' +
'a=rtcp-fb:108 nack\r\n' +
'a=rtcp-fb:108 nack pli\r\n' +
'a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f\r\n' +
'a=rtpmap:109 rtx/90000\r\n' +
'a=fmtp:109 apt=108\r\n' +
'a=rtpmap:35 AV1/90000\r\n' +
'a=rtcp-fb:35 goog-remb\r\n' +
'a=rtcp-fb:35 transport-cc\r\n' +
'a=rtcp-fb:35 ccm fir\r\n' +
'a=rtcp-fb:35 nack\r\n' +
'a=rtcp-fb:35 nack pli\r\n' +
'a=rtpmap:36 rtx/90000\r\n' +
'a=fmtp:36 apt=35\r\n' +
'a=rtpmap:124 H264/90000\r\n' +
'a=rtcp-fb:124 goog-remb\r\n' +
'a=rtcp-fb:124 transport-cc\r\n' +
'a=rtcp-fb:124 ccm fir\r\n' +
'a=rtcp-fb:124 nack\r\n' +
'a=rtcp-fb:124 nack pli\r\n' +
'a=fmtp:124 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f\r\n' +
'a=rtpmap:119 rtx/90000\r\n' +
'a=fmtp:119 apt=124\r\n' +
'a=rtpmap:123 H264/90000\r\n' +
'a=rtcp-fb:123 goog-remb\r\n' +
'a=rtcp-fb:123 transport-cc\r\n' +
'a=rtcp-fb:123 ccm fir\r\n' +
'a=rtcp-fb:123 nack\r\n' +
'a=rtcp-fb:123 nack pli\r\n' +
'a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f\r\n' +
'a=rtpmap:118 rtx/90000\r\n' +
'a=fmtp:118 apt=123\r\n' +
'a=rtpmap:114 red/90000\r\n' +
'a=rtpmap:115 rtx/90000\r\n' +
'a=fmtp:115 apt=114\r\n' +
'a=rtpmap:116 ulpfec/90000\r\n' +
'a=ssrc-group:FID 1741155232 1898443615\r\n' +
'a=ssrc:1741155232 cname:P0KGpA3OHyfIh1hw\r\n' +
'a=ssrc:1741155232 msid:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc fb34f344-fbe3-45e9-969d-af4d9fb5bdc4\r\n' +
'a=ssrc:1741155232 mslabel:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc\r\n' +
'a=ssrc:1741155232 label:fb34f344-fbe3-45e9-969d-af4d9fb5bdc4\r\n' +
'a=ssrc:1898443615 cname:P0KGpA3OHyfIh1hw\r\n' +
'a=ssrc:1898443615 msid:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc fb34f344-fbe3-45e9-969d-af4d9fb5bdc4\r\n' +
'a=ssrc:1898443615 mslabel:EHsXxPKkpBfFwyGLbTIFHt4eXe6smVEHN9Yc\r\n' +
'a=ssrc:1898443615 label:fb34f344-fbe3-45e9-969d-af4d9fb5bdc4\r\n'
}
setLocalDescription方法是干什么的?setRemoteDescription又是干什么的?
setLocalDescription是让自己的webrtc实例清楚,自己的媒体信息和编解码信息。
setRemoteDescription是让对方的webrtc实例清楚,我的媒体信息和编解码信息。
一个offer,一个answer,我们彼此都知道对方的媒体信息与编解码信息,这样我们才能好好协商,我这边该用什么方式对你的视频音频流进行解码、渲染。
向sturn/turn中继服务器发送请求收集candidate是什么鬼?

过程有些繁杂,具体流程小伙伴们可以看这篇文章 WebRTC TURN协议初识及turnserver实践。
代码
目的
了解webrtc的音视频采集、桌面采集;
了解websocket和webrtc的整个链路建立过程;
实现1V1 文字传输、视频通话、语音通话、屏幕共享;
实现视频通话、语音通话、屏幕共享过程中的截图、录音、录屏及 截图、录音、录屏的在线播放与下载;
将以上功能部署上线;
流程构建
在这里,我们要对音视频建立过程画一个基本的流程图。
基本流程图
对于这些信令,我们使用websocket进行转发,这里大家会问,为什么不使用http?
首先,我们的要实现的demo本来就含有发送普通文本消息的功能,就是使用websocket。(长短轮询太老了,性能太差)
其次,第一点可以忽略,http的请求会打回原路,A向服务器请求,绝对不会传向B。
但是如果我们要使用websocket转发信令,就要清楚的了解,在同一管道的所有端都会收到这条消息。所以,对于上面流程图来说,其实所有的小箭头都是双向的。
这时候,我们可以在node服务中来控制推送消息的方向,也可以在客户端来控制,这里我选择在AB端来控制。
其次,我们在本地开发,如果使用一台电脑,两个浏览器的形式,websocket文字消息是可以的。但是音视频通话不行,因为不管是传输通道还是音视频设备(麦克、扬声器等)都是冲突的。所以,我们可以通过同一局域网,使用两台电脑解决这个问题。并且,因为webrtc的安全限制,必须使用https(不管是线上还是本地)与域名,我们可以通过线上配置https与域名,本地设置浏览器忽略https与配置host文件映射来解决这个问题。
接下来,我们使用vue和nodejs,可以最快最简单的实现demo。
废话少说,我们开撕!
开撕
展示部分代码
socket-io
这里我使用socket.io这个第三方包,快速的首发消息,转发信令。(建议大家使用vue-socket.io)可以在组件中收发消息与信令。
将socket-io的websocket建立连接与接收消息,接收信令放到vuex中。
async connectSocket({ commit, state, dispatch }) {
// 局域网
let socket = io.connect("http://172.28.74.16:3004");
// 线上
// let socket = io.connect("https://www.codeting.top:3004");
socket.on("connect", () => {
commit(types.SET_SOCKET, socket);
dispatch("handleChatData", state.friends);
});
// 监听好友发过来的消息
socket.on("friendMessage", (res) => {
if (res) {
commit(types.SET_MESSAGE, res);
} else {
console.log("有问题");
}
});
socket.on("apply", (res) => {
if (!state.isCalling) {
let text = "";
if (res.webRtcType === "video") {
text = "视频通话";
}
MessageBox(
`您的好友${res.userName}请求与你进行${text}, 是否同意?`,
"提示",
{
confirmButtonText: "同意",
cancelButtonText: "拒绝",
type: "warning",
}
)
.then(() => {
const friendInfo = {
account: res.userName,
_id: res.Id,
};
commit(types.SET_FRIENDINFO, friendInfo);
commit(types.SET_ISCALLING, true);
commit(types.SET_WEBRTCSTATE, "reply");
})
.catch(() => {
console.log("9. 拒绝接听");
});
} else {
}
});
socket.on("reply", (res) => {
if (res) {
localStorage.setItem("nowRoomId", res.roomId);
commit(types.SET_REPLYISAGREE, true);
}
});
socket.on("1v1ICEA", (res) => {
if (res && state.role === "receiver") {
commit(types.SET_ECE, res);
}
});
socket.on("1v1ICEB", (res) => {
if (res && state.role === "caller") {
commit(types.SET_ECE, res);
}
});
socket.on("1v1OFFER", (res) => {
if (res) {
commit(types.SET_ISOFFER, true);
commit(types.SET_OFFER, res);
}
});
socket.on("1v1ANSWER", (res) => {
if (res) {
commit(types.SET_ISANSWER, true);
commit(types.SET_ANSWER, res);
}
});
},
同样,我们在node服务中,也是使用socket-io这个包
io.on("connect", function (socket) {
socket.on('friendMessage', async function (res) {
const roomId = res.userId > res.friendId ? res.userId + res.friendId : res.friendId + res.userId
io.to(roomId).emit('friendMessage', res)
});
socket.on('apply', async function (res) {
io.to(res.roomId).emit('apply', res)
});
socket.on('reply', data => {
io.to(data.roomId).emit('reply', data)
})
socket.on('1v1ICEA', data => {
console.log('1v1ICEA', data)
io.to(data.roomId).emit('1v1ICEA', data)
})
socket.on('1v1ICEB', data => {
io.to(data.roomId).emit('1v1ICEB', data)
})
socket.on('1v1OFFER', data => { // 转发 Offer
io.to(data.roomId).emit('1v1offer', data)
})
socket.on('1v1ANSWER', data => { // 转发 answer
io.to(data.roomId).emit('1v1answer', data)
})
});
音视频采集
对于视频、音频、及屏幕共享来说,代码都是类似的。所以,举例视频采集。
const constraints = {
audio: {
noiseSuppression: true,
echoCancellation: true,
},
video: true,
};
this.localstream = navigator.mediaDevices.getUserMedia(constraints);
let video = document.querySelector("#rtcA");
video.srcObject = this.localstream;
通过使用 getUserMedia,我们可以采集到音视频双轨的媒体流,我们传入一个参数 constraints,这个参数可以配置(控制采集音频还是视频)
将采集到的动态媒体流赋值给video标签,我们自己的画面就显示在网页上了。
同样,如果是音频采集,只需要配置参数 constraints中的video为false即可。
电脑屏幕采集,只需要将getUserMedia这个API替换为getDisplayMedia即可。
传输过程
此时视频端发起端,采集到了媒体流后,需要发送apply信令给接收端。这个信令是询问接收端是否接起视频通话。
如果接起,接收端会采集自己的音视频双轨的媒体流,并且初始化peerconnection,将媒体流放入此管道,监听ICE候选信息 如果收集到,就发送给对方,并将自己的同意信令reply,回复给视频发起端。
如果拒绝接起,接收端会回复一个拒绝的信令给视频发起端。
接收端此时收到拒绝,会关闭视频音频流的采集。
接收端此时收到接起,会初始化peerconnection,并将自己的媒体流放入此管道,监听ICE候选信息 如果收集到,就发送给对方。并且创建一个offer(此offer包含sdp),将offer放到本地后,发送offer给视频接收端。
视频接收端接收到offer,放到自己的远端,并且创建一个answer,将answer放到本地后,发送给视频发起方。
视频发起方接收到answer,将answer放到远端。
props: {
visible: Boolean,
friendInfo: Object,
webRtcType: String,
},
data() {
return {
showDialog: false,
localstream: {},
peer: null,
isToPeer: false,
iceServers: {
iceServers: [
{
url: "turn:www.codeting.top:3478", // xxxxx 为域名
credential: "xxxxx", // 密码
username: "xx", // 账号
},
],
sdpSemantics: "plan-b",
},
bufferFriend: [],
bufferMy: [],
mediaRecorder: {},
startRecordVideo: true,
};
},
watch: {
visible(val) {
this.handleVisible(val);
if (!val) {
this.$store.commit("chat/SET_ISCALLING", false);
}
},
"$store.state.chat.replyIsAgree"(v) {
if (v && this.$store.state.chat.role === "caller") {
let roomId = getLocalStorage("nowRoomId").nowRoomId;
this.initPeer({ roomId, webRtcType: "video" });
this.createOffer({ roomId, webRtcType: "video" });
}
},
"$store.state.chat.isEce"(v) {
if (v && this.$store.state.chat.role === "receiver") {
if (this.$store.state.chat.ece) {
this.onIce(this.$store.state.chat.ece);
}
}
if (v && this.$store.state.chat.role === "caller") {
if (this.$store.state.chat.ece) {
this.onIce(this.$store.state.chat.ece);
}
}
},
"$store.state.chat.isOffer"(v) {
if (v && this.$store.state.chat.role === "receiver") {
if (this.$store.state.chat.offer) {
this.onOffer(this.$store.state.chat.offer);
}
}
},
"$store.state.chat.isAnswer"(v) {
if (v && this.$store.state.chat.role === "caller") {
if (this.$store.state.chat.answer) {
this.onAnswer(this.$store.state.chat.answer);
}
}
},
},
methods: {
handleVisible(val) {
this.showDialog = val;
this.$emit("update:visible", val);
},
async apply() {
let constraints = null;
if (this.webRtcType === "video") {
constraints = {
audio: {
noiseSuppression: true,
echoCancellation: true,
},
video: true,
};
} else {
constraints = {
audio: true,
video: false,
};
}
this.localstream = await navigator.mediaDevices.getUserMedia(constraints);
let video = document.querySelector("#rtcA");
video.srcObject = this.localstream;
const userId = getCookie();
const friendId = this.friendInfo._id;
let roomId = userId > friendId ? userId + friendId : friendId + userId;
this.$store.state.chat.socket.emit("apply", {
webRtcType: this.webRtcType,
roomId: roomId,
userName: getLocalStorage("account").account,
id: userId,
});
},
reply(roomId) {
this.$store.state.chat.socket.emit("reply", {
roomId,
webRtcType: this.webRtcType,
});
},
async createP2P(data) {
await this.createMedia(data);
},
async createMedia(data) {
try {
let constraints = null;
if (this.webRtcType === "video") {
constraints = {
audio: {
noiseSuppression: true,
echoCancellation: true,
},
video: true,
};
} else {
constraints = {
audio: true,
video: false,
};
}
this.localstream = await navigator.mediaDevices.getUserMedia(
constraints
);
let video = document.querySelector("#rtcA");
video.srcObject = this.localstream;
} catch (e) {
console.log("getUserMedia: ", e);
}
await this.initPeer(data);
},
initPeer(data) {
let PeerConnection =
window.RTCPeerConnection ||
window.mozRTCPeerConnection ||
window.webkitRTCPeerConnection;
this.peer = new PeerConnection(this.iceServers);
this.peer.addStream(this.localstream);
window.streamMy = this.localstream;
this.peer.onicecandidate = (event) => {
if (event.candidate && this.$store.state.chat.role === "caller") {
this.$store.state.chat.socket.emit("1v1ICEA", {
...data,
sdp: event.candidate,
});
}
if (event.candidate && this.$store.state.chat.role === "receiver") {
this.$store.state.chat.socket.emit("1v1ICEB", {
...data,
sdp: event.candidate,
});
}
};
this.peer.onaddstream = (event) => {
window.streamFriend = event.stream;
this.isToPeer = true;
let video = document.querySelector("#rtcB");
video.srcObject = event.stream;
};
},
async createOffer(data) {
try {
let offer = await this.peer.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1,
});
await this.peer.setLocalDescription(offer);
this.$store.state.chat.socket.emit("1v1offer", {
...data,
sdp: offer,
});
} catch (e) {
console.log("createOffer: ", e);
}
},
async onIce(data) {
try {
await this.peer.addIceCandidate(data.sdp);
} catch (e) {
console.log("onAnswer: ", e);
}
},
async onOffer(data) {
try {
await this.peer.setRemoteDescription(data.sdp);
let answer = await this.peer.createAnswer();
await this.peer.setLocalDescription(answer);
this.$store.state.chat.socket.emit("1v1answer", {
...data,
sdp: answer,
});
} catch (e) {
console.log("onOffer: ", e);
}
},
async onAnswer(data) {
try {
await this.peer.setRemoteDescription(data.sdp);
} catch (e) {
console.log("onAnswer: ", e);
}
},
}
播放对方画面
此时,接收和发起端都在监听 ICE候选信息 如果收集到,就发送给对方。一但监听到了就将对方的动态媒体流赋值给B,播放出来。
if (event.candidate && this.$store.state.chat.role === "receiver") {
this.$store.state.chat.socket.emit("1v1ICEB", {
...data,
sdp: event.candidate,
});
}
截图与录音录像录屏
截图:我们可以使用 canvas利用相关方法getContext("2d").drawImage, 实现web层面的图片截取。
let picture = document.querySelector("#picture");
let rtcA = document.querySelector("#rtcA");
picture.getContext("2d").drawImage(rtcA, 0, 0, 200, 120);
录音/录像/录屏:使用 MediaRecorder 将我们的媒体流或者对方的媒体流保存到数组中。
let that = this;
let options = {
mineType: "video/webm;code=vp8",
};
if (!MediaRecorder.isTypeSupported(options.mineType)) {
console.error(`${options.mineType}is not supported`);
}
try {
this.mediaRecorder = new MediaRecorder(window.streamFriend, options);
} catch (error) {
console.error(error, "失败");
return;
}
// 当数据有效时触发的事件
this.mediaRecorder.ondataavailable = function(e) {
if (e && e.data && e.data.size > 0) {
that.bufferFriend.push(e.data);
}
};
this.mediaRecorder.start(10);
播放录音录像录屏
只需要将保存的静态媒体流赋值给video标签。
let recplayer = document.querySelector("#recplayer");
let blob = new Blob(this.bufferFriend, { type: "video/webm" });
recplayer.src = window.URL.createObjectURL(blob);
recplayer.srcObject = null;
recplayer.controls = true;
recplayer.play();
下载录音录像录屏
同理,我们可以将音视频流下载下来。
download(videoName) {
var blob = new Blob(this.bufferFriend, { type: "video/webm" });
var url = window.URL.createObjectURL(blob);
var downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.style.display = "none";
downloadLink.download = `${videoName}.webm`; //可以命名任意格式例如mp4/webm等等,其中webm格式可以使用浏览器播放视频
downloadLink.click();
document.body.removeChild(downloadLink)
},
本地效果
桌面分享


视频通话


上线
部署webrtc重要的两个条件:域名 与 https,我们需要配置这两块。
我们的node服务不仅是https+域名,websocket也需要更为安全的wss协议,我们需要给我们的websocket配置wss。
在前面我们也提过,本地开发之所以能够成功,并且有效果,是因为内网是直接通信的,并没有走公网,也就没有实现内网穿透。
如果我们想要在线上实现这个功能,我们必须配置coturn中转服务器。centos内核的配置文章可以参考 这篇,ubuntu内核的配置参考 这篇。
缺陷
在开发和上线后能够发现以下几个问题。
环境、设备、信号溢出、算法不兼容产生的噪音、声学与线路产生的回音、网络拥塞及数据包传输速率不稳定产生的延迟。
我们可以通过接入一些算法及提高硬件设备质量,来减少噪音回音,降低延迟。
对于噪音,采集音频时可以设置 noiseSuppression: true,可以降低 一些环境及设备的噪音。
对于回声,采集音频时可以设置 echoCancellation: true,可以去除回声。
剩下的交给算法、设备和网络来处理了。
在这方面的探索,我就到此为止了,大家可以接着研究 WebRTC传输是如何保证音视频服务质量,研究一下成熟应用是如何解决这三大难点的。
更多
特效
大家在视频通话过程中,可以使用多种特效。美颜、贴纸等等。
然而在webrtc的web端领域,视频特效领域是非常潜的。造成这种情况的原因是js的性能问题。
比较简单的方法就是使用canvas画布,对我们的视频图象加一层滤镜,但是在本质上并没有改变媒体流。传输到远端仍然是没有特效的。当然,我们可以通过websocket控制远端的视频特效,但是由于视频流没有改变,对方如果下载视频流的话,播放出来仍然是没有特效的。
另一种方案如下,这里我就不做赘述,大家可以思考一下是如何实现的(以下为简单特效与贴纸)。






多人视频
需要创建n-1个PeerConnection连接,因为我们要与n-1个人进行视频共享,每个人都是这样。但是这里会涉及谁主动发offer的问题。我们可以让新加入的成员向其他n-1个成员发送offer,也可以使n-1个成员向新加入的成员发送offer。这里我们可以用遍历的方式生成PeerConnection和offer。当然多人通话就看你服务器顶不顶的住了。
这里我们就不知情的使用了多端通信的知名通信方案——Mesh,Mesh就是两两通信从而形成网状结构。除了Mesh这种通信方案,还有MCU,MCU方案主要是将同一房间的所有终端的音视频流进行混合然后发向各个终端,这样服务器的压力其实是非常巨大的。另外还有SFU通信方案,中转服务器收到某终端音视频流后,单一转发到其他终端。
总结
经过上面一系列的理解、思考、构建、开发、部署等等,我们对webrtc有了一些初步的了解及认识。对于这方面的研究与探索我们都要继续继续深入下去。因为满足我们的好奇心与求知欲,提升我们的这一领域的技术,丰富我们整体的知识体系,何乐而不为呢。
最后,以上所有的内容都来源于资料、个人实验及个人总结,文中有错的地方希望大家及时指正。
参考
https://www.html5rocks.com/en/tutorials/webrtc/infrastructure/
https://zhuanlan.zhihu.com/p/7102543
https://zhuanlan.zhihu.com/p/71025431
https://zhuanlan.zhihu.com/p/26796476

本文详细讲解了WebRTC的工作原理,包括API和协议的作用,P2P技术的应用,WebSocket在信令转发中的关键角色,以及WebSocket与WebSocket通道在不同环境下的连接过程。重点讨论了coturn中继服务器在nat穿透中的作用,以及如何处理内网穿透、NAT穿越等问题。
1609





