打通一下 1v1 音视频通话的流程。

基本知识

端到端连接基本流程:

客户端信令消息设计:

  • join 加入房间
  • leave 离开房间
  • message 端到端消息
    • offer 消息
    • answer 消息
    • candidate 消息

服务端信令消息设计:

  • joined 已成功加入房间
  • otherjoin 其他用户加入此房间
  • full 要加入的房间已满
  • leave 已成功离开房间
  • bye 对方离开房间

消息处理流程:

image.png

客户端状态机:

image.png

客户端加入相关流程:

image.png

客户端离开流程图:

image.png

代码逻辑

连接

  1. 网页加载完毕,用户初始状态为init

  2. 点击连接按钮,进入 start() 函数,获取权限并开启本地音视频,然后进入 conn() 函数和信令服务器建立连接,并向信令服务器发送一个 join 信号,当收到服务器返回的 joined 后,客户端的状态变为 joined 。然后执行 createPeerConnection() 函数,创建 pc 并绑定媒体流,等待第二个人加入。注意:addTrack 将采集的音视频轨道添加并发送给远端。

    image.png

  3. 当有第二个人点击连接按钮,执行完上述步骤后,第一个人的客户端会收到信令服务器发来的一个 otherjoin 信号,这时会触发 call() 函数。在 call() 中执行 createOffer() ,在创建 offer 成功后 setLocalDescription,并将创建好的 desc 信息发给信令 message 给信令服务器。

    setLocalDescription 调用后底层会悄悄地向 sturn/turn 服务器发送一个 bind request,这个时候就开始收集所有能和对方连接的候选者了。

    当服务器收到 message 后,会将其原封不动的转发给房间内除发送者外的其他成员:

    socket.to(room).emit('message', room, data); // 给房间出自己外所有人回消息

  4. 然后第二个人会收到信令服务器发来的 message 信息,信息中的 data.type==='offer'

    下来第二个人的客户端会执行 setRemoteDescription,然后 createAnswer,并 setLocalDescription,最后讲创建的 answer 以 message 信息。同样,信令服务器收到这个 answer 后还是原封不动的转发给了第一个连接的人。

  5. 第一个人在收到 answer 后 setRemoteDescription,这样两个人的 SDP 就交换好了。

  6. 已经根据SDP信息创建好本地的相关 Channel 后会开启Candidate数据的收集,接收由 TURN 服务器收集好的 candidate 信息。

  7. 当 candidate 到达第一个连接的人那后会触发 onicecandidate,第一个人的客户端会将这个 candidate 发送给信令服务器,信令服务器发给第二个进来的人,第二个进来的人会 addIceCandidate,然后会触发自己的 onicecandidate,再给信令服务器,信令服务器再给第一个进来的人。

  8. 这样两个人就建立了音视频传输的P2P通道,接收对方传送过来的 MediaStream 对象并渲染出来。

  9. onTrack 监听音视频数据的到达,到达后执行 getRemoteStream。

  10. 当其他人再加入时,信令服务器发现此房间已满,会发送一个 full 信号,提示当前房间已满,并关闭 pc 和本地音视频流的 Track。

离开

离开的逻辑就简单一些了。

先向服务器发送 leave 信令,收到 leaved 后变更状态然后关闭pc,关闭媒体流。

注意

网络连接要在音视频流数据获取之后,否则有可能绑定音视频流失败。

当一端退出房间后,另一端的 PeerConnection 要关闭重建,否则与新用户互通的时候媒体协商失败。

异步事件处理。

完整代码

Client.html
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Room</title>
<link href="main.css" rel="stylesheet">
</head>
<body>
<div>
<div id="preview">
<table align="center">
<tr>
<td>
<h2>Local:</h2>
<video id="localvideo" autoplay playsinline muted></video>

<label>Offer SDP:
<textarea id="textarea_offer"></textarea>
</label>
</td>
<td>
<button id="connserver">连接</button><br>
<button id="leave" disabled>离开</button>
</td>
<td>
<h2>Remote:</h2>
<video id="remotevideo" autoplay playsinline></video>

<label>Answer SDP:
<textarea id="textarea_answer"></textarea>
</label>
</td>
</tr>
</table>
</div>
</div>

<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
<script src="https://cdn.bootcss.com/webrtc-adapter/zv4.1.1/adapter.min.js"></script>
<script src="room.js"></script>
</body>
</html>
Client.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
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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
'use strict';

var btnConn = document.querySelector('button#connserver');
var btnLeave = document.querySelector('button#leave');

var localVideo = document.querySelector('video#localvideo');
var remoteVideo = document.querySelector('video#remotevideo');

var localStream = null;
var remoteStream = null;

var roomid = '111111';
var socket = null;
var state = 'init';

var pc = null;

var textarea_offer = document.querySelector('textarea#textarea_offer');
var textarea_answer = document.querySelector('textarea#textarea_answer');


var pcConfig = {
'iceServers': [{
'urls': 'turn:ahoj.luoshaoqi.cn:3478',
'credential': 'xxxxxx',
'username': 'xxxxxx'
}]
};


btnConn.onclick = connSignalServer;
btnLeave.onclick = leave;

function connSignalServer() {
// 开启本地音视频设备
start();

return true;
}

function start() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('不支持');
} else {
var constraints = {
video: true,
audio: true
};
navigator.mediaDevices.getUserMedia(constraints)
.then(getMediaStream)
.catch((e) => {
console.error(e);
});
}
}

function getMediaStream(stream) {
if (localStream) {
stream.getAudioTracks().forEach((track) => {
localStream.addTrack(track);
stream.removeTrack(track);
});
} else {
localStream = stream;
}

localVideo.srcObject = localStream;

conn();
}

/* 信令部分 */
function conn() {
socket = io.connect(); // 与信令服务器连接

socket.on('joined', (roomid, id) => {
state = 'joined';
console.log('receive msg: joined', roomid, id, 'state = ', state);
createPeerConnection();

btnConn.disabled = true;
btnLeave.disabled = false;
});

socket.on('otherjoin', (roomid, id) => {
if (state === 'joined_unbind') {
createPeerConnection();
}

state = 'joined_conn';
console.log('receive msg: otherjoin', roomid, id, 'state = ', state);

call();
});

socket.on('full', (roomid, id) => {
state = 'leaved';
console.log('receive msg: full', roomid, id, 'state = ', state);

socket.disconnect();
closeLocalMedia();

console.error('房间已满');
btnConn.disabled = false;
btnLeave.disabled = true;
});

socket.on('leaved', (roomid, id) => {
state = 'leaved';
console.log('receive msg: leaved', roomid, id, 'state = ', state);

socket.disconnect();
btnConn.disabled = false;
btnLeave.disabled = true;
});

socket.on('bye', (roomid, id) => {
state = 'joined_unbind';
closePeerConnection();
textarea_offer.value = '';
textarea_answer.value = '';
console.log('receive msg: bye', roomid, id, 'state = ', state);
});

socket.on('disconnect', (socket) => {
console.log('disconnect message', roomid);
if (!(state === 'leaved')) {
hangup();
closeLocalMedia();
}
state = 'leaved'
});

socket.on('message', (roomid, data) => {
console.log('receive msg: message', roomid, data);
/* 媒体协商 */
if (data) {
if (data.type === 'offer') {
textarea_offer.value = data.sdp;

pc.setRemoteDescription(new RTCSessionDescription(data));

pc.createAnswer()
.then(getAnswer)
.catch((e) => {
console.error(e);
});

} else if (data.type === 'answer') {
textarea_answer.value = data.sdp;
pc.setRemoteDescription(new RTCSessionDescription(data));

} else if (data.type === 'candidate') {
var candidate = new RTCIceCandidate({
sdpMLineIndex: data.label,
candidate: data.candidate
});
pc.addIceCandidate(candidate);

} else {
console.error('data type error');
// console.log(data);
}
}
});

socket.emit('join', '111111'); // 加入房间 111111
}

function hangup() {
if (pc) {
pc.close();
pc = null;
}
}

/* 退出时关闭 track 流 */
function closeLocalMedia() {
if (localStream && localStream.getTracks()) {
localStream.getTracks().forEach((track) => {
track.stop();
});
}
localStream = null;
}

function leave() {
if (socket) {
socket.emit('leave', '111111'); // 离开房间 111111
}

/* 释放资源 */
closePeerConnection();
closeLocalMedia();

textarea_offer.value = '';
textarea_answer.value = '';

btnConn.disabled = false;
btnLeave.disabled = true;
}

function createPeerConnection() {
console.log('create RTCPeerConnection!');
if (!pc) {
pc = new RTCPeerConnection(pcConfig);

pc.onicecandidate = (e) => {

if (e.candidate) {
sendMessage(roomid, {
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
});
} else {
console.log('this is the end candidate');
}

};

pc.ontrack = getRemoteStream;

} else {
console.log('the pc have be created')
}

if ((localStream !== null || localStream !== undefined) && (pc !== null || pc !== undefined)) {
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream); // 进行添加, 并发送给远端。
});
} else {
console.log('pc or localStream is null or undefined');
}

} /* createPeerConnection */

function closePeerConnection() {
console.log('close RTCPeerConnection!');
if (pc) {
pc.close();
pc = null;
}
}

function getRemoteStream(e) {
remoteStream = e.streams[0];
remoteVideo.srcObject = e.streams[0];
}

function call() {
if (state === 'joined_conn') {

var options = {
offerToReceiveVideo: 1,
offerToReceiveAudio: 1,
};
pc.createOffer(options)
.then(getOffer)
.catch((e) => {
console.error(e);
});

}
}

function getOffer(desc) {
pc.setLocalDescription(desc);
textarea_offer.value = desc.sdp;
sendMessage(roomid, desc);
}

function getAnswer(desc) {
pc.setLocalDescription(desc);
textarea_answer.value = desc.sdp;
sendMessage(roomid, desc);
}

function sendMessage(roomid, data) {
console.log('send p2p message', roomid, data);
if (socket) {
socket.emit('message', roomid, data);
}
}
Signal Server
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
'use strict';

var http = require('http');
var https = require('https');
var fs = require('fs');

var express = require('express');
var serveIndex = require('serve-index');

var socketIo = require('socket.io');


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

// http server
var http_server = http.createServer(app);
http_server.listen(8087, '0.0.0.0');


// https server
var options = {
key: fs.readFileSync('./cret/xxx.key'),
cert: fs.readFileSync('./cret/xxx.pem')
};
var https_server = https.createServer(options, app);

var io = socketIo.listen(https_server);
io.sockets.on('connection', (socket) => {
socket.on('message', (room, data) => {
io.in(room).emit('message', room, data); // socket.to(room).emit('message', room, data);

console.log('[message] room:', room, 'data:', data);
});

socket.on('join', (room) => {
socket.join(room);
var myRoom = io.sockets.adapter.rooms[room];
var users = Object.keys(myRoom.sockets).length;

if (users <= 2) {
socket.emit('joined', room, socket.id); // 发消息给房间里除自己之外的所有人

console.log('[joined] room:', room, 'user_id:', socket.id);

if (users > 1) {
socket.to(room).emit('otherjoin', room, socket.id);

console.log('[otherjoin] room:', room, 'user_id:', socket.id);
}
} else {
socket.leave(room);
socket.emit('full', room, socket.id);
console.log('[otherjoin] room:', room, 'user_id:', socket.id);
}
});

socket.on('leave', (room) => {

console.log(room);
var myRoom = io.sockets.adapter.rooms[room];
socket.to(room).emit('bye', room, socket.id);
socket.emit('leaved', room, socket.id);

console.log('[otherjoin] room:', room, 'user_id:', socket.id);
});
});
https_server.listen(443, '0.0.0.0');

其他参考资料:

https://cloud.tencent.com/developer/article/1480648

https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/ontrack

https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/onicecandidate

https://hpbn.co/webrtc/#establishing-a-peer-to-peer-connection

https://www.cnblogs.com/fangkm/p/4364553.html


EOF