点到点的视频通信

本文主要是使用了一只斌师傅的 webrtc 教程完完整整的复现来的,链接如下:

https://www.bilibili.com/video/BV1py4y1e7G6/?spm_id_from=333.999.0.0&vd_source=270df07e19daab36f36e1863f3440455

目的

​ 实现点对点的视频通信,原本的实验要求如下:

(1)发送端能够正确捕获视频、接收端正确显示视频;
(2)视频需要选用一种编码方式
(3)可选要求:使用RTP/RTCP监测视频流状态、同时传输音频

​ 但是经过查找资料发现,现在主流的视频通信大都用 webrtc 实现,于是自学了 webrtc 最终做出来一个视频通信的 demo。

webRTC 基本概念

​ MDN(Mozilla Developer Network)对WebRTC的定义:WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。


注:引用自https://juejin.cn/post/7071994793710075911

架构

image-20221103232054752

可以看到,一个简单的点对点通讯系统主要由四部分组成:

  • WebRTC客户端:负责生产/消费音视频数据,位于NAT之内,属于内网
  • NAT:Network Address Translation (NAT),网络地址转换协议, 能够将设备的内网地址映射到一个公网的地址。
  • 信令服务器:用于传输SDP、candidate等信令数据。
  • STUN/TURN服务器(中继服务器):
    • STUN:用于为位于NAT内的设备找到自己的公网地址。WebRTC客户端通过给处于公网的STUN服务器发送请求来获得自己的公网地址信息,以及是否能够被(穿过路由器)访问。
    • TURN:对于无法通过STUN服务器进行内网穿越的“对称型NAT”,我们可以借助TURN服务器作为中继服务器,通过TURN服务器对数据进行转发。

点对点的通信原理:

  1. 首先客户端需要信令服务器连接,后续双方需要通过信令服务器来了解对方的一些必要的信息,比如告诉对方自己的支持的音视频格式、自己外网IP地址和端口是多少等(此时还无法知道自己的公网地址)。
  2. 与STUN建立连接,获得自己的外网IP地址和端口,以及是否能够进行内网穿越。不支持内网穿越的情况下还需要连接TURN服务器进行中继通信。
  3. WebRTC客户端拿到自己的外网IP地址和端口后,通过信令服务器将自己的信息(candidate信息)交换给对方。当双方都获取到对方的地址后,它们就可以尝试NAT穿越,进行P2P连接了。

MediaStreamTrack

​ WebRTC中的基本媒体单位,一个MediaStreamTrack包含一种媒体源(媒体设备或录制内容)返回的单一类型的媒体(如音频,视频)。单个轨道可包含多个通道,如立体声源尽管由多个音频轨道构成,但也可以看作是一个轨道

MediaStream

​ MediaStream是MediaStreamTrack的合集,可以包含 >=0 个 MediaStreamTrack。MediaStream能够确保它所包含的所有轨道都是是同时播放的,以及轨道的单一性。

source 与 sink

​ 在MediaTrack的源码中,MediaTrack都是由对应的source和sink组成的。

​ 浏览器中存在从source到sink的媒体管道。其中source负责生产媒体资源,包括多媒体文件,web资源等静态资源以及麦克风采集的音频,摄像头采集的视频等动态资源。而sink则负责消费source生产媒体资源,也就是通过等媒体标签进行展示,或者是通过RTCPeerConnection将source通过网络传递到远端。RTCPeerConnection可同时扮演source与sink的角色,作为sink,可以将获取的source降低码率,缩放,调整帧率等,然后传递到远端,作为source,将获取的远端码流传递到本地渲染。

MediaTrackConstraints

MediaTrackConstraints描述MediaTrack的功能以及每个功能可以采用的一个或多个值,从而达到选择和控制源的目的。 MediaTrackConstraints 可作为参数传递给applyConstraints()以达到控制轨道属性的目的,同时可以通过调getConstraints()用来查看最近应用自定义约束。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const constraints = {
width: {min: 640, ideal: 1280},
height: {min: 480, ideal: 720},
advanced: [
{width: 1920, height: 1280},
{aspectRatio: 1.333}
]
};
//使用1280x720的摄像头分辨率
const constraints = {
audio: true,
video: { width: 1280, height: 720 }
};
const constraints = {
audio: true,
video: { facingMode: "user" }
};
const constraints = {
audio: true,
video: { facingMode: { exact: "environment" } }
};

//{ video: true }也是一个MediaTrackConstraints对象,用于指定请求的媒体类型和相对应的参数。
navigator.mediaDevices.getUserMedia({ video: true })
.then(mediaStream => {
const track = mediaStream.getVideoTracks()[0];
track.applyConstraints(constraints)
.then(() => {
// Do something with the track such as using the Image Capture API.
})
.catch(e => {
// The constraints could not be satisfied by the available devices.
});
});

如何播放MediaStream

​ 可将MediaStream对象直接赋值给HTMLMediaElement 接口的 srcObject属性。

1
video.srcObject = stream;

WebRTC 实现点对点通信

  1. 检测本地音视频设备和进行采集音视频的采集;
  2. 通过信令服务器与对方建立连接;
  3. 创建RTCPeerConnection对象
    • 绑定音视频数据
    • 进行媒体协商
    • 交换candidate信息
  4. 音视频数据传输与渲染

检测本地音视频设备

​ 通过MediaDevices.enumerateDevices()我们可以得到一个本机可用的媒体输入和输出设备的列表,例如麦克风,摄像机,耳机设备等。

1
2
3
4
//获取媒体设备
navigator.mediaDevices.enumerateDevices().then(res => {
console.log(res);
});

​ 列表中的每个媒体输入都可作为MediaTrackConstraints中对应类型的值,如一个音频设备输入audioDeviceInput可设置为MediaTrackConstraints中audio属性的值。

1
const constraints = { audio : audioDeviceInput }

​ 将该constraint值作为参数传入到MediaDevices.getUserMedia(constraints)中,便可获得该设备的MediaStream。

采集本地音视频数据

​ 可通过调用MediaDevices.getUserMedia()来访问本地媒体,调用该方法后浏览器会提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D转换器等等),也可能是其它轨道类型。

1
2
3
4
5
6
7
8
navigator.mediaDevices.getUserMedia(constraints)
.then(function(stream) {
/* 使用这个stream*/
video.srcObject = stream;
})
.catch(function(err) {
/* 处理error */
});

通过信令服务器与对方建立连接

​ 信令服务器主要用于帮我们进行业务逻辑的处理(如加入房间、离开房间)以及进行媒体协商和交换candidate。

​ 信令服务器可以有很多种方案,在这里我们借助node.js和socket.io实现一个简单的信令服务器。

创建HTTP服务器

1
2
3
4
5
6
7
let http = require('http'); // 提供HTTP 服务
let express = require('express');

let app = express();

let http_server = http.createServer(app);
http_server.listen(8081, '127.0.0.1');

引入 socket.io 实现两端的实时通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let http = require('http'); // 提供HTTP 服务
const { Server } = require('socket.io');
let express = require('express');

let app = express();

//HTTP 服务
let http_server = http.createServer(app);
http_server.listen(8081, '127.0.0.1');

const io = new Server(http_server, {
// 处理跨域配置
cors: {
origin: ['http://127.0.0.1:3000', 'http://localhost:3000'],
credentials: true,
},
});

监听客户端的消息

1
socket.on('messageName', messageHandler)

客户端加入房间

1
socket.join(roomId);

向房间内的客户端发送消息

1
socket.to(roomId).emit('messageName', data);

转发消息

1
2
3
4
// 用于转发sdp、candidate等消息
socket.on('message', ({ roomId, data }) => {
socket.to(roomId).emit('message', data);
});

创建RTCPeerConnection对象

​ RTCPeerConnection 接口代表一个由本地计算机到远端的WebRTC连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。

1
const pc = new RTCPeerConnection()

绑定音视频数据

​ 我们可以通过 addTrack 方法和 addStream 方法(已过时,不推荐)将音视频数据和 RTCPeerConnection 对象进行绑定。

1
2
3
mediaStream.getTracks().forEach(track => {
peerConnection.addTrack(track, mediaStream);
});

进行媒体协商

​ 所谓的媒体协商,就是交换双方SDP信息,SDP包含音视频的编解码(codec),源地址,和时间信息等信息。

​ 呼叫端获取本地sdp(offer),调用pc.setLocalDescription(offer)保存本地的sdp信息后,通过信令服务器发送本地sdp到远端。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 呼叫端获取本地sdp(offer)
pc.createOffer().then(offer => {
console.log('------ 获取到了本地offer', offer);

// 绑定本地sdp信息
pc.setLocalDescription(offer);

// 通过信令服务器发送本地sdp到远端
signalServer.send({
type: 'offer',
value: offer,
});
});

​ 被叫端接收到来自远端的offer后,调用 pc.setRemoteDescription(offer) 绑定远端sdp,然后调用pc.createAnswer() 创建本地sdp并使用 pc.setLocalDescription(answer) 进行保存,最后利用信令服务器将 answer sdp 发送给远端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const onGetRemoteOffer = offer => {
console.log('------ 获取到了远端offer', offer);
// 远端发起呼叫,开始建立连接
// 绑定远端sdp
pc.setRemoteDescription(offer);
// 创建本地sdp
pc.createAnswer().then(answer => {
// 绑定本地sdp
pc.setLocalDescription(answer);
console.log('------ 获取到了本地answer', answer);
// 发送本地sdp到远端
signalServer.send({
type: 'answer',
value: answer,
});
});
};

​ 呼叫端接收到远端的answer后,调用 pc.setRemoteDescription(answer) 绑定远端sdp。

1
2
3
4
5
const onGetRemoteAnswer = answer => {
console.log('------ 获取到了远端answer', answer);
// 绑定远端sdp
pc.setRemoteDescription(answer);
};

ICE

​ 当媒体协商完成后,WebRTC就开始建立网络连接了。建立网络连接的前提是客户端需要知道对端的外网地址,这个获取并交换外网地址的过程,我们称为ICE

收集

​ WebRTC内部集成了收集Candidate的功能。收集到Candidate后,为了通知上层,WebRTC还提供onicecandidate事件。

1
2
3
4
5
6
7
8
// 监听 candidate 获取事件
pc.addEventListener('icecandidate', event => {
const candidate = event.candidate;
if (candidate) {
console.log('------ 获取到了本地 candidate:', candidate)
//...
}
});
交换
 收集到candidate后,可以通过信令系统将candidate信息发送给远端。
1
2
// 发送candidate到远端
signalServer.send({ type: 'candidate', value: candidate });

​ 远端接收到对端的candidate后,会与本地的candidate形成CandidatePair(即连接候选者对)。有了CandidatePair,WebRTC就可以开始尝试建立连接了。

1
2
3
4
5
// 获取到远端的candidate
const onGetRemoteCandidate = candidate => {
console.log('------ 获取到了远端candidate', candidate);
pc.addIceCandidate(candidate);
};

远端音视频数据接收与渲染

​ 当双方都获取到对端的candidate信息后,WebRTC内部就开始尝试建立连接了。连接一旦建成,音视频数据就开始源源不断地由发送端发送给接收端。

​ 通过RTCPeerConnection对象的track事件,我们能接收到远端的音视频数据并进行渲染。

1
2
3
4
5
6
7
// 监听到远端传过来的媒体数据
pc.addEventListener('track', e => {
console.log('------ 获取到了远端媒体数据:', e);
if (remoteVideo.srcObject !== e.streams[0]) {
remoteVideo.srcObject = e.streams[0];
}
});

代码实现 & 讲解

环境

vscode + nodejs + xirsys

1
2
3
npm init //创建初始项目
npm install express // 安装express
npm install socket.io

配置 https ,如果不配的话局域网内开摄像头会被拦截。

1
2
openssl genrsa -out privkey.key 2048
openssl req -new -x509 -key privkey.key -out cacert.pem -days 1095

具体代码实现

具体就是两个文件: index.js 与 camera.html

image-20221108181029563

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
## index.js 
const { Socket } = require('socket.io');

// 后台服务器
var express = require('express');
const { fstat } = require('fs');
var app = express();
var http = require('http').createServer(app);
var fs = require('fs')

let sslOptions = {
key: fs.readFileSync('./private.key'),
cert: fs.readFileSync('./cacert.pem')
};
const https = require('https').createServer(sslOptions, app);
var io = require('socket.io')(https);

https.listen(443, () => {
console.log('https listen on');
});

app.use("/static", express.static('static/'));

app.get('/camera', (req, res) => {
res.sendFile(__dirname + '/camera.html');
});

io.on("connection", (socket) => {
// 连接加入子房间
socket.join(socket.id);
console.log("a user connected " + socket.id);

socket.on("disconnect", () => {
console.log("user disconnected " + socket.id);
socket.broadcast.emit('user disconnected', socket.id);
})

socket.on('new user greet', (data) => {
console.log(data)
console.log(socket.id + " greet " + data.msg);
socket.broadcast.emit("need connect", {sender: socket.id, msg: data});
});
socket.on('ok we connect', (data) => {
io.to(data.receiver).emit('ok we connect', {sender: data.sender});
});

// sdp and candidate
socket.on('sdp', (data) => {
console.log('sdp');
console.log(data.description);
socket.to(data.to).emit('sdp', {
description: data.description,
sender: data.sender
});
});
socket.on('ice candidates', (data) => {
console.log('ice candidates: ');
console.log(data);
socket.to(data.to).emit('ice candidates', {
candidate: data.candidate,
sender: data.sender
});
});
});

http.listen(3000, () => {
console.log('listening on *:3000');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
## camera.html
<!DOCTYPE html>
<html>
<head>
<title>Camera</title>
</head>
<body>
<h1 id="user-id">用户名称</h1>
<ul id="user-list">
</ul>
<video id="video-local" controls autoplay></video>
<div id="videos"></div>
</body>
<script src="//cdn.bootcdn.net/ajax/libs/jquery/3.4.1/jquery.js"></script>
<script src="//cdn.bootcdn.net/ajax/libs/socket.io/3.0.4/socket.io.js"></script>

<script>
//封装一部分函数,引用别人的
function getUserMedia(constrains, success, error) {
let promise;
if (navigator.mediaDevices.getUserMedia) {
//最新标准API
promise = navigator.mediaDevices.getUserMedia(constrains).then(success).catch(error);
} else if (navigator.webkitGetUserMedia) {
//webkit内核浏览器
promise = navigator.webkitGetUserMedia(constrains).then(success).catch(error);
} else if (navigator.mozGetUserMedia) {
//Firefox浏览器
promise = navagator.mozGetUserMedia(constrains).then(success).catch(error);
} else if (navigator.getUserMedia) {
//旧版API
promise = navigator.getUserMedia(constrains).then(success).catch(error);
}
return promise;
}

function canGetUserMediaUse() {
return !!(navigator.mediaDevices.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);
}

const localVideoElm = document.getElementById("video-local");
$('document').ready(() => {
InitCamera();
});

const iceServer = {
xxx//省略了,需要到xirsys申请,或者自己用服务器搭一个
}

// PeerConnection
var pc = [];
var localStream = null;

function InitCamera(){
if(canGetUserMediaUse()){
getUserMedia({
video : true,
audio : false
}, (stream) => {
localStream = stream;
localVideoElm.srcObject = stream;
$(localVideoElm).width(800);
}, (err) => {
console.log('访问用户媒体失败', err.name, err.message);
});
} else {
alert("您的浏览器不兼容");
}
}

function StartCall(parterName, createOffer){
pc[parterName] = new RTCPeerConnection(iceServer);
if(localStream){
localStream.getTracks().forEach((track) => {
pc[parterName].addTrack(track, localStream);
});
} else {
if(canGetUserMediaUse()){
getUserMedia({
video: true,
audio: false
}, function (stream){
localStream = stream;
localVideoElm.srcObject = stream;
$(localVideoElm).width(800);
}, function (error){
console.log("访问用户媒体设备失败", error.name, error,message);
})
}else {
alert("您的浏览器不兼容");
}
}
// 呼叫者createOffer
if(createOffer){
pc[parterName].onnegotiationneeded = () => {
pc[parterName].createOffer().then((offer) => {
return pc[parterName].setLocalDescription(offer);
}).then(() => {
socket.emit('sdp', {
type: 'video-offer',
description: pc[parterName].localDescription,
to: parterName,
sender: socket.id
});
})
};
}


//当向连接中加入磁道时,track事件的此处理程序由本地webRTC层调用。例如,可以将传入媒体连接到元素以显示它。
pc[parterName].ontrack = (ev) => {
let str = ev.streams[0];
if (document.getElementById(`${parterName}-video`)){
document.getElementById(`${parterName}-video`).srcObject = str;
} else {
let newVideo = document.createElement('video');
newVideo.id = `${parterName}-video`;
newVideo.autoplay = true;
newVideo.controls = true;
newVideo.className = 'remote-video';
newVideo.srcObject = str;
document.getElementById('videos').appendChild(newVideo);
}
}

pc[parterName].onicecandidate = ({candidate}) => {
socket.emit('ice candidates',{
candidate: candidate,
to: parterName,
sender: socket.id
});
}


}

// socket.io 部分
var socket = io();
socket.on('connect', () => {
console.log('connect ' + socket.id);
$('#user-id').text(socket.id);
pc.push(socket.id);

socket.emit('new user greet', {
sender : socket.id,
msg : 'hello'
});
socket.on('need connect', (data) => {
console.log(data);
let li = $('<li>').text(data.sender).attr('user-id', data.sender);
$('#user-list').append(li);
let button = $('<button class="call">通话</button>');
button.appendTo(li);
$(button).click(function(){
console.log($(this).parent().attr('user-id'));
StartCall($(this).parent().attr('user-id'), true);
});

socket.emit('ok we connect', {
receiver : data.sender,
sender : socket.id
});
});
socket.on('ok we connect', (data) => {
console.log(data);
$('#user-list').append($('<li>').text(data.sender).attr('user-id', data.sender));
});

socket.on('user disconnected', (socket_id) => {
console.log("disconnect : " + socket_id);
$('#user-list li[user-id="' + socket_id + '"]').remove();
});

socket.on('sdp', (data) => {
if(data.description.type === 'offer'){
StartCall(data.sender, false);
//把发送者(offer)的描述,存储在接受者的remoteDesc中。
let desc = new RTCSessionDescription(data.description);
pc[data.sender].setRemoteDescription(desc).then(() => {

pc[data.sender].createAnswer().then((answer) => {
return pc[data.sender].setLocalDescription(answer);
}).then(() => {
socket.emit('sdp', {
type: 'video-answer',
description: pc[data.sender].localDescription,
to: data.sender,
sender: socket.id
});

}).catch();//catch error function empty

})
} else if(data.description.type === 'answer'){
let desc = new RTCSessionDescription(data.description);
pc[data.sender].setRemoteDescription(desc);
}
})
socket.on('ice candidates', (data) => {
console.log('ice candidate: ' + data.candidate);
if(data.candidate){
var candidate = new RTCIceCandidate(data.candidate);
pc[data.sender].addIceCandidate(candidate).catch();
}
});
});

</script>
</html>

运行效果

​ 输入”node index.js”并开始运行。

image-20221108181624270

​ 保证视频通信的双方连一个局域网,否则访问不到页面。

​ 两台设备访问 https://192.168.43.185/camera,如下图所示:

image-20221108182115322

​ 其中 iGxxxAAAD为此设备的ID,VssxxxAAAF为远程设备的ID。

​ 点击通话,效果如下图所示:

image-20221108182528724

项目代码

https://github.com/WD-2711/webrtc-demo

留言

2022-11-08

© 2024 wd-z711

⬆︎TOP