webRTC-音视频通话

上一节讲了 webRTC 的原理,今天我们就来实践一下。

我们知道,webRTC 是点对点的连接,它不需要服务器的参与,但是需要一个信令服务器来传递信令,这样才能使双方建立起连接。

这里我们用 node.js 来充当信令服务器,通过 websocket(socket.io)来传递信令。

新建目录 demo,在 demo 下新建 index.js 文件(信令服务器)和 文件夹 public(存放静态文件)。在 public 下新建 index.html 和 main.js 文件。

安装
1
2
3
npm install express
npm install fs
npm install socket.io
编写信令服务器

index.js

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
var express = require("express");
const { createServer } = require("https");
const { Server } = require("socket.io");
const fs = require('fs');

let options = {
key: fs.readFileSync('./privatekey.key'), // 证书文件的存放目录
cert: fs.readFileSync('./certificate.crt')
}

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

var httpsServer = createServer(options, app)

var io = new Server(httpsServer)


io.on("connection", socket => {
socket.on("add_room", () => {
socket.join("room");
socket.emit("conn");
});

// 接收 Offer 信令并发送给其他连接
socket.on('signalOffer', function (message) {
socket.to('room').emit('signalOffer', message);
});

// 接收 Answer 信令
socket.on('signalAnswer', function (message) {
socket.to('room').emit('signalAnswer', message);
});

// 接收 iceOffer
socket.on('iceOffer', function (message) {
socket.to('room').emit('iceOffer', message);
});

// 接收 iceAnswer
socket.on('iceAnswer', function (message) {
socket.to('room').emit('iceAnswer', message);
});
});


httpsServer.listen(3000, () => {
console.log("服务开启");
})

这里用 https 协议是因为摄像头和麦克风只能在 https 环境下才能被正常调用。


静态文件

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<html>
<head>
<title>videoCall</title>
</head>

<div class="container">
<h1>音视频通话</h1>
<hr>
<div class="video_container" align="center">
<!-- 本地视频流 -->
<video id="local_video" controls autoplay muted webkit-playsinline></video>
<!-- 远端视频流 -->
<video id="remote_video" controls autoplay muted webkit-playsinline></video>
</div>
<hr>
<button id="startButton">加入房间</button>
<button id="hangupButton">挂断</button>
<!-- socket.io 客户端 -->
<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.4.1/dist/socket.io.min.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="main.js"></script>
</div>
</html>

main.js

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
// 本地视频 Video
var localVideo = document.getElementById('local_video');
// 远端视频 Video
var remoteVideo = document.getElementById('remote_video');

// 加入房间按钮
var startButton = document.getElementById('startButton');
// 挂断按钮
var hangupButton = document.getElementById('hangupButton');

var pc; // RTCPeerConnection 实例(WebRTC 连接实例)
var localStream; // 本地视频流

var socket = io.connect(); // 创建 socket 连接


// offer 配置
const offerOptions = {
offerToReceiveVideo: 1,
offerToReceiveAudio: 1
};

hangupButton.disabled = true;

startButton.addEventListener('click', startAction);
hangupButton.addEventListener('click', hangupAction);

// 点击加入房间
function startAction () {
// 浏览器兼容性处理
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function (constraints) {
var getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia || navigator.oGetUserMedia;

if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
}

return new Promise(function (resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject);
});
}
}

// 调用设备媒体
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(function (mediastream) {
localStream = mediastream; // 本地视频流
localVideo.srcObject = mediastream; // 播放本地视频流
startButton.disabled = true;
socket.emit('add_room'); // 连接 socket
}).catch(function (e) {
alert(e);
});
}

// socket 连接成功
socket.on('conn', function () {
hangupButton.disabled = false;
pc = new RTCPeerConnection(); // 创建 RTC 连接

localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // 添加本地视频流 track
// 创建 Offer 请求
pc.createOffer(offerOptions).then(function (offer) {
pc.setLocalDescription(offer); // 设置本地 Offer 描述,(设置描述之后会触发ice事件)
socket.emit('signalOffer', offer); // 发送 Offer 请求信令
});
// 监听 ice
pc.addEventListener('icecandidate', function (event) {
var iceCandidate = event.candidate;
if (iceCandidate) {
// 发送 iceOffer 请求
socket.emit('iceOffer', iceCandidate);
}
});
});

// 接收 Offer 请求信令
socket.on('signalOffer', function (message) {
pc.setRemoteDescription(new RTCSessionDescription(message)); // 设置远端描述
// 创建 Answer 请求
pc.createAnswer().then(function (answer) {
pc.setLocalDescription(answer); // 设置本地 Answer 描述
socket.emit('signalAnswer', answer); // 发送 Answer 请求信令
})

// 监听远端视频流
pc.addEventListener('track', function (event) {
event.streams.forEach(stream => {
remoteVideo.srcObject = stream;
});
});
});

// 接收 Answer 请求信令
socket.on('signalAnswer', function (message) {
pc.setRemoteDescription(new RTCSessionDescription(message)); // 设置远端描述
console.log('remote answer');

// 监听远端视频流
pc.addEventListener('track', function (event) {
event.streams.forEach(stream => {
remoteVideo.srcObject = stream;
});
});
});

// 接收 iceOffer
socket.on('iceOffer', function (message) {
addIceCandidates(message)
});

// 接收 iceAnswer
socket.on('iceAnswer', function (message) {
addIceCandidates(message)
});

// 添加 IceCandidate
function addIceCandidates (message) {
if (pc !== 'undefined') {
pc.addIceCandidate(new RTCIceCandidate(message));
}
}

// 挂断
function hangupAction () {
localStream.getTracks().forEach(track => track.stop());
pc.close();
pc = null;
hangupButton.disabled = true;
startButton.disabled = false;
}

webRTC 中交换描述和 ice 的具体过程:

第一步:创建一个 RTCPeerConnection,并添加本地媒体流。

1
2
3
pc = new RTCPeerConnection();

localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

第二步:如果是发起者,那么创建 offer,添加到本地描述,然后通过信令服务器发送给接收方。

1
2
3
4
pc.createOffer(offerOptions).then(function (offer) {
pc.setLocalDescription(offer); // 设置本地描述
socket.emit('signalOffer', offer)
});

第三步:接收方获取到 offer,将其添加到远程描述,然后创建 answer,添加到本地描述,并将 answer 发送给发起方。

1
2
3
4
5
6
pc.setRemoteDescription(new RTCSessionDescription(message)); // 设置远端描述
// 创建 Answer 请求
pc.createAnswer().then(function (answer) {
pc.setLocalDescription(answer); // 设置本地 Answer 描述
socket.emit('signalAnswer', answer); // 发送 Answer 请求信令
})

第四步:发起方获取到 answer,将其添加到远程描述。

1
pc.setRemoteDescription(new RTCSessionDescription(message));

第五步:交换 ice。其实在创建 offer 或 answer 时会触发 icecandidate 事件,通过该事件可以获取到 ice 信息,然后将信息发送给对方,对方将其添加即可。

1
2
3
4
5
6
7
8
9
10
pc.addEventListener('icecandidate', function (event) {
const iceCandidate = event.candidate;
if (iceCandidate) {
socket.emit('iceOffer', iceCandidate);
}
});

socket.on('iceOffer', function (message) {
pc.addIceCandidate(new RTCIceCandidate(message));
});
开启服务

执行

1
node index.js

然后在浏览器输入 https://localhost:3000 即可访问。

注意:通信双方需要在同一局域网下。