title: WebRTC之实现1v1音视频通话
date: 2019-12-05 21:06:38
tags:
- WebRTC
- 音视频
categories:
- WebRTC
原文: https://ahoj.cc/
打通一下 1v1 音视频通话的流程。
基本知识
端到端连接基本流程:
客户端信令消息设计:
- join 加入房间
- leave 离开房间
- message 端到端消息
- offer 消息
- answer 消息
- candidate 消息
服务端信令消息设计:
- joined 已成功加入房间
- otherjoin 其他用户加入此房间
- full 要加入的房间已满
- leave 已成功离开房间
- bye 对方离开房间
消息处理流程:
客户端状态机:
客户端加入相关流程:
客户端离开流程图:
代码逻辑
连接
- 网页加载完毕,用户初始状态为
init
。
-
点击连接按钮,进入 start() 函数,获取权限并开启本地音视频,然后进入 conn() 函数和信令服务器建立连接,并向信令服务器发送一个 join 信号,当收到服务器返回的 joined 后,客户端的状态变为 joined 。然后执行 createPeerConnection() 函数,创建 pc 并绑定媒体流,等待第二个人加入。注意:addTrack 将采集的音视频轨道添加并发送给远端。
-
当有第二个人点击连接按钮,执行完上述步骤后,第一个人的客户端会收到信令服务器发来的一个 otherjoin 信号,这时会触发 call() 函数。在 call() 中执行 createOffer() ,在创建 offer 成功后 setLocalDescription,并将创建好的 desc 信息发给信令 message 给信令服务器。
setLocalDescription 调用后底层会悄悄地向 sturn/turn 服务器发送一个 bind request,这个时候就开始收集所有能和对方连接的候选者了。
当服务器收到 message 后,会将其原封不动的转发给房间内除发送者外的其他成员:
socket.to(room).emit('message', room, data); // 给房间出自己外所有人回消息
-
然后第二个人会收到信令服务器发来的 message 信息,信息中的
data.type==='offer'
。下来第二个人的客户端会执行 setRemoteDescription,然后 createAnswer,并 setLocalDescription,最后讲创建的 answer 以 message 信息。同样,信令服务器收到这个 answer 后还是原封不动的转发给了第一个连接的人。
-
第一个人在收到 answer 后 setRemoteDescription,这样两个人的 SDP 就交换好了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P2JB3Ezk-1575561809056)(https://i.loli.net/2019/12/05/gnqZImlSVCbB4HY.png)]
-
已经根据SDP信息创建好本地的相关 Channel 后会开启Candidate数据的收集,接收由 TURN 服务器收集好的 candidate 信息。
-
当 candidate 到达第一个连接的人那后会触发 onicecandidate,第一个人的客户端会将这个 candidate 发送给信令服务器,信令服务器发给第二个进来的人,第二个进来的人会 addIceCandidate,然后会触发自己的 onicecandidate,再给信令服务器,信令服务器再给第一个进来的人。
-
这样两个人就建立了音视频传输的P2P通道,接收对方传送过来的 MediaStream 对象并渲染出来。
-
onTrack 监听音视频数据的到达,到达后执行 getRemoteStream。
-
当其他人再加入时,信令服务器发现此房间已满,会发送一个 full 信号,提示当前房间已满,并关闭 pc 和本地音视频流的 Track。
离开
离开的逻辑就简单一些了。
先向服务器发送 leave 信令,收到 leaved 后变更状态然后关闭pc,关闭媒体流。
注意
网络连接要在音视频流数据获取之后,否则有可能绑定音视频流失败。
当一端退出房间后,另一端的 PeerConnection 要关闭重建,否则与新用户互通的时候媒体协商失败。
异步事件处理。
完整代码
Client.html
<!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
'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
'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');
其他参考资料:
MOOC: https://coding.imooc.com/class/chapter/329.html#Anchor
tencent cloud: https://cloud.tencent.com/developer/article/1480648
MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/ontrack
MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/onicecandidate
webrtc: https://hpbn.co/webrtc/#establishing-a-peer-to-peer-connection
cnblogs: https://www.cnblogs.com/fangkm/p/4364553.html
EOF