今天我们来进行STUN和TURN服务的搭建,我们此前已经介绍了WebRTC信令服务器的搭建,以及介绍了端到端视频的传输,在信令服务器搭建的时候我们使用的是nodeJS+socket.io并且最终一个多人的文本聊天室来验证信令服务器的可用性,第二个通过端对端的传输我们是通过我们本机内部的一个网络传输,通过一端的PeerConnection传到另一端的PeerConnection,通过第二个PeerConnection将这个视频获取出来进行展示,下面我们在介绍完这个STUN和TURN服务之后在真实网络下不同的PC之间不同的网络设备之间可以进行一对一的直播系统。

1 STUN/TURN服务器选型

rfc5766-turn-server

市面上有很多的STUN和TURN服务,一般情况下都是将这两种协议融合到一起形成一个服务器,也就是说在一个服务器里同时支持了STUN协议和TURN协议,比较知名的是rfc5766-turn-server,rfc5766-turn-server是由谷歌公司发起的,而且延续了很多年很多用户在使用,但是随着时间的推移我们发现rfc5766-turn-server还是有很多功能不全。

coTurn

有很多贡献者在它的基础上又做了很多修改,那就形成了现在的coTurn,coTurn就是rfc5766-turn-server的一个升级版本,它里面除了UDP的中转,还支持TCP的中继,除了IPV4之外还支持IPV6等等支持了很多的功能,现在是活跃度非常高的一个STUN和TURN服务器,那么在选型的时候也是按照两个标准 ,第一个就是它的活跃度比较高,第二个是用户量比较大,coTurn显然是我们选中的类型之一。

ResTurn

ResTurn它是比较老的一直TURN服务,也有不少人在用,相对来说它与coTurn相比就会差一些。

所以我们最终还是选择了coTurn作为我们的STUN和TURN服务器。

coTurn服务器的搭建与部署

参考:https://blog.csdn.net/zsj777/article/details/81784205

//切换root账户
sudo su root//更新
apt-get update//安装openssl
apt-get install opensslapt-get install libssl-dev//安装libevent
wget https://github.com/downloads/libevent/libevent/libevent-2.0.21-stable.tar.gztar xvfz libevent-2.0.21-stable.tar.gzcd libevent-2.0.21-stable./configure make make install//安装 coturnapt-get install sqliteapt-get install libsqlite3-devwget https://github.com/coturn/coturn/archive/4.5.0.7.tar.gztar xvfz 4.5.0.7.tar.gzcd coturn-4.5.0.7./configure --prefix=/usr/local/coturnmake
使用ls -alt Makefile 查看生成的makefile文件make install//配置并启动coturnturnadmin -a -u your_name -p your_password -r your_realmcp /usr/local/etc/turnserver.conf.default /usr/local/etc/turnserver.confturnserver -a -f -v -r your_realm

如何安装好了之后,我们就可以看到 /usr/local/下面有个coturn

cd /usr/local/coturn/

其中bin下面是一些可执行的程序

turnadmin 表示管理

turnserver 表示TURN服务

turnutils_stunclient 表示STUN的客户端

turnutils_peer 表示一些工具

turnutils_natdiscovery 表示NAT发现

turnutils_uclient 表示TURN的一个客户端

etc下放置配置文件

turnserver.conf 是一些配置文件

include放置头文件

lib中放置库文件

man中放置手册

share中配置的例子

配置

对于TURN服务来说,它有很多的配置项,最关键的其实就这四项

1、默认的侦听端口都是3478,

2、如果在是云服务器上要指定外网的IP地址,

3、还有就是用户名和密码,以冒号隔开,还有第二种方式就是通过restful接口,这个我们以后再介绍,现在我们没那么复杂,用一个用户名和密码就就可以了,

4、最后一个是非常关键的,就是域名,这个一定要设置

它还有很多的配置项,包括不使用普通的用户名和密码格式,都是可以设置的。但是现在这个最简单的配置我们用肯定是没问题的。

我们就设置这四项,其他的我们都不用设置

有了这四项之后我们就可以将这个服务起来了,服务起来我们就可以通过这个测试的页面,

上节我们讲到SDP里面也有IP地址和端口,但是那个IP地址和端口对WebRTC来说是忽略掉的,就是写不写都无所谓,最终用的IP地址就是candidate的IP地址和端口,也就是说ICE里面收集的IP地址和端口

下面我们就来看一下,那有了这个配置之后,我们就可以把这个TURN服务启动起来了,所以启动TURN服务也比较简单

./bin/turnserver -c ./etc/turnserver.conf

我们检测一些启动起来没有

我们这里看到这个TURN服务就启动起来了,TURN启动之后我们就来看一下

访问:https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

在这里我们实际已经加入了我的这个地址

下面我们点击一些Gather candidates按钮,这里可以选择的ICE类型有两种,all表示对所有的candidate都收集,relay表示只收集relay

如果是全部都收集

显然这是一个对称型的NAT,它的端口号已经变了是16215和16216,如果两个端口是一摸一样的显然就是非对称型,现在来看两个就是对称型的,虽然IP地址是相同的,但是端口已经变了,从这里我们可以看出relay的级别是最低的,host是最高的

如果我们设置了筛选relay,那么之前的host都没有了

它的Time用时显然是第一个最少,用时0.076秒,显然第二个relay的时间就会很长,所以在选择的时候第一个优先级最高的,后面 是优先级低的。

2 RTCPeerConnection

在我们之前使用RTCPeerConnection的时候我们把这个参数设置成了null,或者不填,因为这个参数configuration本身是可以不填的

基本格式

pc = new RTCPeerConnection([configuration]);

其实它里面可以有很多的参数,在RTCconfiguration这个结构体里有好几项,

最关键的一项是iceServers,也就是说我们在建立这个RTCPeerConnection之前可以给他传入很多的iceServers,也就是我们的STUN和TURN服务,那么通过这个STUN和TURN服务可以检测获取到相应的反射地址和中继地址,那些形成这些服务之后他就可以进行这些连接性检测的时候找出它的优先级,那么优先级我们上节就说了,在测试STUN和TURN服务的时候那个工具里就有这个优先级的显示;

第二项是iceTransportPolicy,也就是传输的策略,它的传输策略有两种,一种是all一种是relay。如果是all的话就支持host本机的地址,也就是穿越NAT反射后的candidate以及中继的candidate,如果是relay类型只收集中继类型的candidate也就是中继的通路,这是第二项。

第三项是bundlePolicy,bundlePolicy这个策略实际也有好几种,默认是balanced,也就是平衡的,后面我们会详细的介绍;

第四项是rtcpMuxPllicy的复用,复用策略,默认是require;

第五项peerIdentity就是一个标识的字符串,这里不用过多的讨论它。

第六项certificates就是一些证书,也就是我们每一个连接,每一个可连通的候选者都需要有一个证书,所以如果你有多个连接,你就有多个证书,但是一般情况下如果是复用的话,我就用一个证书就可以了,这样可以增加它建成整个传输的速度。

第七项就是iceCandidatePoolSize,也就是我要收集的候选者的空间有多大,默认是0;如果你设置成5的话,如果你有20个,那只选其中的5个。

bubdlePolicy

Balanced实际就是音频轨、视频轨各自有一个传输通道,音频轨和视频轨是分开的,那这个如果有多路音频,多路视频,那音频的类型,都由音频的这个传输通道。视频都有视频的传统。

max-compat是最大兼容性,怎样才能达到最大的兼容性呢?就是每一个轨都有自己的通道,如果我有这个两个音频,一个视频,就有三个通道。如果使用Balanced失败,就会走max-compat这种方式

max-bundle就是最大化的使用一个绑定,那就是将所有的媒体流都用一个通道进行传输。这是webrtc建议的方式,这样的话对于底层来说就比较简单了,而且你建立这个连接之后,你的证书是需要一个,不需要一堆,否则的话,你每一个连接都有证书,就会非常的耗费时间。

certificates

如果不提供的话就会自动产生,所以这个证书的话一般我们也不会设置

iceCandidatePoolSize

比如最开始我设成了3,然后我紧接着设成了10,这个时候呢,它会在底层会重新触发这个收集候选者,就重新再来一遍,这其实也挺重要的,就是当我们发现这个链路,其实一开始我们建立了一个链路,但是这个链路由于网络原因发生拥塞了,一条路走不通了,那么我们可以改变这个值,让它触发,重新收集这个Candidate,然后想另外条路,这都是可以的

iceTransportPolicy

一种是relay就是中继,收集候选者的时候,只收集这个中继的候选者。因为在真实的网络情况下,就是中继的这个候选者,NAT穿越在中国实现比较困难,所以呢,一般都是使用relay。然后还有all,就是说我的这个hosts的类型的,NAT穿越反射的类型,以及这个relay类型的都要收集,这是它的不同的策略,但是我们一般用all

iceServers

credential,凭证,会根据下面这个credentialType类型,然后去处理,如果credentialType是password类型,那么这个属性就是password,如果credentialType是oauth,它这个值就是oauth。

urls,比如说我们设置的这个STUN和这个TURN服务,每一个都是一个url串,通过这个url串我们可以访问到这个地址

最后一个用户名,跟那个credential这个是匹配的,一个用户名一个密码

rtcpMuxPllicy

addIceCandidate

在接下来我能介绍一下addIceCandidate,实际我们之前就已经介绍过了,并且开始使用了,我们在看一下它的基本格式

基本格式

aPromise = pc.addIceCandidate(candidate);

pc就是RTCpeerConnection

candidate类型里面包括的属性如下

3 直播系统中的信令及其逻辑关系

今天我们开始讲真正的音视频传输了,将我们之前讲解的信令服务器与我们后面讲解的端到端传输过程连接到一起,这样就形成了一个真正的直播系统。下面我们就来分步骤的来实现我们这个一对一的直播系统。

首先我们来看看整个直播系统的核心,就是信令,终端与服务器之间如何进行交互,交互之后通过信令的中转然后将整个的逻辑串联到一起。那么在我们这个直播系统中涉及的信令其实还是蛮少的,只有几个信令,那么就简简单单的几个信令就将我们整个的业务串了起来。下面我们来看看。

首先是客户端发送的信令

join加入房间

客户端发送信令比较少,第一个就是加入房间,就是说我一个用户跟服务器连接之后,就发入一个join,那么服务器端在收到join这个信息之后就将这个用户记录到服务器端,说明该用户已经在了;

leave离开房间

第二个就是离开房间,这是成对出现的,我有进有出,这样能够随时知道房间内的人数实际有多少,我只进不出这样的话它就一直累加,那可能服务器端有一万人,实际真正的只有两个人,这也是我们很多直播系统里弄假数据的一个方法,它只进不出,但我们这里要有进有出。

message端到端消息

第三个就是message,message就是将消息发送给对端,怎么发送呢?也就是说它首先发送到服务端,服务端收到之后再将它中转给任意一个客户端 ,所以在服务器端是没有任何的逻辑操作的,只是做一个转发而已。

以上就是三个主要的命令,也就是加入房间、离开房间、和发送端对端的消息,但是端对端的消息我们又可以细分三个。

Offer消息

第一个就是通过我们createOffer获取到了我本机的媒体描述信息,就是我的媒体能力,这个我们此前已经介绍了就是createOffer,那当调用这样函数获取到我本机的媒体信息之后通过这个SDP,它是一个SDP的格式描述的,它就直接将它发送到服务端,服务端收到这个Offer之后转给另外一个终端,就可以将整个的协商过程进行串起来了。

Answer消息

同样的道理,当对方收到这个Offer之后它调用setRemoteDescription之后它要调createAnswer然后之后才能调用setLocalDescription,那在创建完Answer之后它也要创建Answer消息,也就是说将它本地的媒体协商信息传到呼叫端,这是Offer和Answer这就形成了一个交互。

Candidate消息

除此之外还有Candidate,也就是说如果我们双方要通信的话要知道彼此的candidate,对方拿到这个candidate之后就要进行这个连通性检测,找到一个非常高效的链路之后它们后边音视频数据传输打好基础。 

服务端信令消息

joined已加入房间

第一个是joined,这个很简单,当一个用户发送join加入房间之后,服务端说你这个已经加入成功了,要给他回一个消息确认说joined,那客户端收到这个joined之后就知道我与服务端这个信令已经发送成功了,那它可以用joined的这个消息去做后面的逻辑处理。

otherjoin其他用户加入房间

当有一端发送join到服务端之后呢,服务端一方面要给发送者回一个joined消息告诉它你已经加入了,另外它要给另外一个端另外一个参与者发送一个otherjoin,就是告诉另外一个参与者说有有另外一个人加进来了,就是这个消息,那另外一个人知道有人加进来了那就知道现在有两个人现在在这个房间中了。这个时候我们就可以进行媒体协商然后进行音视频的通讯了。这就是otherjoin。

full房间人数已满

第三个是full,也就是当有第三个用户再加入房间的时候,就不让他通讯了,就告诉他这个房间人数已满,你不能再来了,我们只支持一对一的音视频传输;

leaved已离开房间

用户离开的时候它发送leave,服务端就给他回一个leaved,比较好的处理方式是说当用户收到这个leaved之后再离开,这个有利有弊,可以辩证的看,就是说我们在处理这种消息比较多的之后是两个方面都要做的,当用户离开的时候,为了客户端能快速的离开,那它可能就直接发送完了就断开了;还有一种是,客户端收到这个leaved之后要做一些资源的释放,然后再离开;如果,客户端在发送完leave的时候直接离开,那么客户端退的比较快,这样给用户的感觉更好一些。但是带来一些麻烦:虽然走了但是你这个leave有可能没有发送到服务端,这个时候服务端就要等这个超时时间,所以它在统计人数或者是说在做一些逻辑处理的时候可能就比较麻烦。所以我们一般的做法是客户端发送leave消息要等一个超时时间,如果服务端在这个超时时间内回这个消息了,那我就会去释放相应的资源,这样整个流程就是通的了。如果这个超时时间内没有回来,就是该离开还得离开,因为这个时候我还得离开,因为我不能等你,不能让用户感觉我卡死了。这就是leaved。

bye对方离开房间

另外一个就是也要告诉对方,也就是说发送一个bye告诉对方这个人离开了,在对方的用户列表里就要将这个人删除掉,或者说知道对方离开了,对我们一对一的来说,那它也要释放一些资源再等待,当有另外一个用户来的时候再重新创建资源进行音视频的通讯。

以上就是服务端的五个信令。

下面我们再来看一下整个直播系统的消息处理流程图。

左边有个Caller,它是呼叫者,第二个是SigServ就是信令服务器,第三个是Callee就是被呼叫者,第四个Callee是另外一个被呼叫者

(1)呼叫者要创建与信令服务器建立连接:conn,对于其他端来说也是先把连接建立好,否则信令就是不通的;

(2)呼叫者首先发送一个信令是join,说我要加入房间,那么信令服务器回一个joined,表示你这个加入消息已经知道了,已经将你加入到房间了;

(3)另外一个用户这时候也发送一个join加入房间,这时服务器也回一个joined,就是一个请求一个应答,这样它也被加入房间,当第二个用户也加入房间之后,服务端要给第一个用户回一个otherjoin,这时候第一个用户就知道有另外一个用户已经进来了,这时候他们可以进行协商了,然后进行通讯了,那如果这时候有第三个用户Callee发送join说我要加入房间,那信令服务器一查看它这个列表现在已经有两个用户了,所以就给它返回一个full,说你不能加入这个房间。这时候第三个用户就要释放资源,比如这个提示信息比如“房间已经满了,不能再添加了”;所以这个时候与第三个用户已经没有任何关系了。就剩Caller和Callee这两个用户了 ,这个时候他们就可以进行媒体协商了,那么怎么进行媒体协商呢?就是发送端对端的消息,也就是message,它这个message里包括三个包括我们刚才讲的,也就是一个Offer,也就是它刚才发送的一个Offer给对方,对方处理完了然后回一个Answer在回来,这时候当有收集到这个Candidate地址的时候就收集好了,收集完了之后就通过这个信令 服务器转发给对方,对方拿到这个Candidate之后就进行 连通性检测,检测成功了之后这时候就可以进行P2P或者TURN服务进行音视频数据的转发,那它们就真正的通讯了。

(4)当有一个用户说我要离开了,比如说这里的Callee,它发送一个leave给信令服务器,信令服务器收到这个leave之后,首先也要告诉对端,就是说对方和你说byebye了,那你要做相应的处理,紧接着再给这个发送者回一个leaved,也就是说你这个消息我已经成功转发了。

同样的道理如果是主叫方去发送这个leave,那这个信令服务器也是走同样的流程,先给另一方发送bye,然后回一下这个leaved,然后整个信令服务流程就是这样一个关系。

4 信令服务器改造

UBUNTU-SERVER端使用NodeJS中遇到的错误:

1. 切换到root下执行操作:非特权用户(非root用户)无法在1024以下的端口上打开侦听套接字

2. node升级到最新稳定版 :否则会报错:

et session = require('express-session');
^^^SyntaxError: Block-scoped declarations (let, const, function, class) not yet supported outside strict modeat exports.runInThisContext (vm.js:53:16)at Module._compile (module.js:373:25)at Object.Module._extensions..js (module.js:416:10)at Module.load (module.js:343:32)at Function.Module._load (module.js:300:12)at Module.require (module.js:353:17)at require (internal/module.js:12:17)at Object.<anonymous> (/home/qubianzhong/workspace/antzb-operate/operate-backend/bin/www:7:11)at Module._compile (module.js:409:26)at Object.Module._extensions..js (module.js:416:10)

这是因为 node的版本太低造成的,可以去官网上下载了个最新的版本( https://nodejs.org/zh-cn/download/current/),或者升级一下node的版本。
node 升级
node有一个模块叫 n ,是专门用来管理node.js的版本的。
第一步:首先安装n模块:
npm install -g n

第二步:升级node.js到最新稳定版
n stable

第二步:n后面也可以跟随版本号比如:
n v0.10.26

n 0.10.26

另外分享几个npm的常用命令
npm -v #显示版本,检查npm 是否正确安装。

npm install express #安装express模块

npm install -g express #全局安装express模块

npm list #列出已安装模块

npm show express #显示模块详情

npm update #升级当前目录下的项目的所有模块

npm update express #升级当前目录下的项目的指定模块

npm update -g express #升级全局安装的express模块

npm uninstall express #删除指定的模块

3 关于log4js报错

以NodeJS为例,版本号在package.json中所以不需要进入后续的lib/log4js.js,而是进入到/log4js/package.json,把最上面的"version"改为“1.0.0”

代码

'use strict'var log4js = require('log4js');
var http = require('http');
var https = require('https');
var fs = require('fs');
var socketIo = require('socket.io');var express = require('express');
var serveIndex = require('serve-index');var USERCOUNT = 3;log4js.configure({appenders: {file: {type: 'file',filename: 'app.log',layout: {type: 'pattern',pattern: '%r %p - %m',}}},categories: {default: {appenders: ['file'],level: 'debug'}}
});var logger = log4js.getLogger();var app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));//http server
var http_server = http.createServer(app);
http_server.listen(80, '0.0.0.0');var options = {key : fs.readFileSync('./cert/1557605_www.learningrtc.cn.key'),cert: fs.readFileSync('./cert/1557605_www.learningrtc.cn.pem')
}//https server
var https_server = https.createServer(options, app);
var io = socketIo.listen(https_server);io.sockets.on('connection', (socket)=> {socket.on('message', (room, data)=>{socket.to(room).emit('message',room, data);//给房间内的其他人});socket.on('join', (room)=>{socket.join(room);var myRoom = io.sockets.adapter.rooms[room]; var users = (myRoom)? Object.keys(myRoom.sockets).length : 0;logger.debug('the user number of room is: ' + users);if(users < USERCOUNT){socket.emit('joined', room, socket.id); //发给给字节if(users > 1){socket.to(room).emit('otherjoin', room, socket.id);//发给除自己之外的房间上的所有人}}else{socket.leave(room);    socket.emit('full', room, socket.id);}//socket.emit('joined', room, socket.id); //发给自己//socket.broadcast.emit('joined', room, socket.id); //发给除自己之外的这个节点上的所有人//io.in(room).emit('joined', room, socket.id); //发给房间内的所有人});socket.on('leave', (room)=>{var myRoom = io.sockets.adapter.rooms[room]; var users = (myRoom)? Object.keys(myRoom.sockets).length : 0;logger.debug('the user number of room is: ' + (users-1));//socket.emit('leaved', room, socket.id);//socket.broadcast.emit('leaved', room, socket.id);socket.to(room).emit('bye', room, socket.id);socket.emit('leaved', room, socket.id);//io.in(room).emit('leaved', room, socket.id);});});https_server.listen(443, '0.0.0.0');

3 再论CreateOffer

基本格式

aPromise = myPeerConnection.createOffer([options]);

在这个Options里面一共有四项,其中关键的需要重点说明的有两项 。一个是iceRestart另一个是voiceActivityDetection.

Options可选

iceRestart:该选择会重启ICE,重新进行Candidate收集。

voiceActivityDetection:是否开启静音检测,默认开启。

iceRestart:重新启动ICE,在重启启动ICE之后就会重新启动Candidate收集,收集完了之后在进行连通性检测。那它有什么好处呢?那可以想象一种场景就是当我们使用手机的时候从4G换成wifi,或者从wifi换成4G的时候,实际它的链路已经发生了变化,就是说我们的IP地址和出口都发生了变化。那这个时候我们应该进行重新的选择;第二种情况就是说我们的传输网络是动态变化的,就是你数据量的多少,有没有其他人和你抢占带宽都是有关的,那么当我们最开始选择的这条线路呢,是比较高效的,连通性比较好,但是过了一端时间由于某种原因它产生拥塞了,那这时候如果有路的话我们有必要重新选择一条路,还有一种就是比如说我们在服务器端增加了新的中继节点 ,就是TURN服务,那么我们又增加了三个TURN服务,那么将这些TURN服务增加到连接的配置服务中去,那这个时候也应该重新启动ICE让它选路。

基于以上的这些原因,这个iceRestart是非常好的一个方案,就是可以自动的去帮我们去选择新的有效的数据传输的线路,这是一个非常好的机制,当我们设置它为true的时候,当有这些变化的时候就可以触发重新选路,

voiceActivityDetection:静音检测,当我们不说话的时候,有一些背景噪音实际是可以被忽略掉,那么当检测到没有人声只是一些背景噪音的时候,我们可以选择不传输这些背景噪音,那么在另外一端呢,当我们打开了进行检测之后呢,当没有音频数据过来的时候,它会自己去创建那个静默包,整个音频是一个连续的,在没有声音的时候第一个可以减少带宽,那么第二个呢,把一些没必要的背景音就省得去掺杂了整个儿的这个直播系统中。

但除了这个两个option之后呢,还有两个这个option一个是用于接收音频,一个是用于接受视频。

ICE restart

ICE restart的最终的结果反映在SDP,反映到我们的媒体协商上,在媒体协商的时候,当我们在进行交换SDP的时候,ICE ufrog 和 ICE password这两个参数就是用于验证这个连通性Candidate的连通性的,当ICE ufrog 和 ICE password发生变化的时候,我们就要重新进行这个检测。那么在检测过程中,如果我们整个链路都通了,又发现一条新的路,那这个时候呢,他就会把老的那个数据切到这个新的路,在新的路没有建成之前,老的路还是依然在传输数据的.我们怎么能知道这个ICE restar的有没有真正的起效果,那么我们的观察点就在于将我们协商后的这个SDP然后看一看他的这个ICE-uflag和ICE password有没有发生变化?如果没有的话,说明ICE没有启动,如果发生变化了,说明ICE启动了.

<html><head><title>test createOffer from different client</title></head><body><button id="start">Start</button><button id="restart">reStart ICE</button><script src="https://webrtc.github.io/adapter/adapter-latest.js"></script><script src="js/main.js"></script></body>
</html>
'use strict'var start = document.querySelector('button#start');
var restart = document.querySelector('button#restart');var pc1 = new RTCPeerConnection();
var pc2 = new RTCPeerConnection();function handleError(err){console.log('Failed to create offer', err);
}function getPc1Answer(desc){console.log('getPc1Answer', desc.sdp);pc2.setLocalDescription(desc);pc1.setRemoteDescription(desc);/*pc2.createOffer({offerToRecieveAudio:1, offerToReceiveVideo:1}).then(getPc2Offer).catch(handleError);*/
}function getPc1Offer(desc){console.log('getPc1Offer', desc.sdp);pc1.setLocalDescription(desc);pc2.setRemoteDescription(desc);pc2.createAnswer().then(getPc1Answer).catch(handleError);}function startTest(){pc1.createOffer({offerToReceiveAudio:1, offerToRecieveVideo:1}).then(getPc1Offer).catch(handleError);}function getMediaStream(stream){stream.getTracks().forEach((track) => {pc1.addTrack(track, stream);    });var offerConstraints = {offerToReceiveAudio: 1,offerToRecieveVideo: 1,iceRestart:true }pc1.createOffer(offerConstraints).then(getPc1Offer).catch(handleError);}function startICE(){var constraints = {audio: true,video: true}navigator.mediaDevices.getUserMedia(constraints).then(getMediaStream).catch(handleError);
}start.onclick = startTest;
restart.onclick = startICE;

查看:

按下start后,创建的offer中没有指定ice start ,因此每次offer中ice-ufrag都是相同的,answer中的也是

当按下reStartICE后,使用了iceRestart选项,ice-ufrag发生变化

4 WebRTC客户端状态机及处理逻辑

状态机

今天我们来介绍直播客户端的实现,首先我们来看一下直播客户端的一个状态机,客户端与服务器直接通过信令的一个交互之后自然而然的形成一个状态机.

最开始的时候状态机是处于初始化状态:最上面蓝色的Init/Leave,当用户发送了一个join到服务端之后服务端会给它回一个joined消息,所以在客户端收到joined的消息之后就变成了joined状态,这个时候用户是可以离开房间的,当他离开的时候它又回到了Init/Leave

当一个用户A处于joined的状态的时候,另外一个用户B又进来了,这个时候A就变成了joined_conn状态,也就是说加入并且可以与对方进行通话的状态,当A收到otherjoin的时候它就改变为joined_conn状态,对于后加入B,它还是处于joined这个状态,因为它自己并没有收到otherjoin这个消息,所以房间这两个人其实是两个不同的状态,第一个人A是先加入的是joiner_conn状态 ,那么B后加入的是join状态,那对于join_conn这个状态的用户它也可离开,当他离开的时候它也处于初始化或者离开状态

那如果A离开的时候它会发送一个bye给这个现在还在房间中这个用户B,那B收到bye这个消息的时候变成joined_unbind,B虽然已经在这个房间内,但是由于另外一个用户已经走了,所以他们直接进行通讯的这个连接已经不需要了,那这个时候需要释放这个连接,所以要将peerconnection这个中的相应通道全部进行解绑,所以B就处于joined_unbind这个状态,如果这时候有一个用户C进来了,B就变成了joined_conn状态,而C它是join状态,BC则可以进行通讯了。这时候如果C离开了,Bj就回到joined_unbind状态,

处于joined_unbind状态B如果离开,那这个时候是房间里一个人都没有了,那都处于初始化和离开状态,经过这样一个状态机之后呢,我们客户端与服务器之间就可以进行交互了

流程图

在下来我们看一下客户端的流程图,

1 首先获取音视频数据,但如果不能获取到,那就直接失败了,这次通讯肯定不能完成

2 如果能获取到,就要与信令服务器进行连接,然后注册相关的处理函数,也就是我们接收服务端的函数,包括joined,otherjioned,leave,byte和full,注册完之后,服务器端发送消息的时候会触发各个不同的一个事件处理:

第一个分支:处于joined,这时候它要创建并绑定媒体流PeerConnection,如果这时候房间里面没有任何人,他也要进行这个绑定,就说先让他预备好。

第二个分支:收到otherjoin,如果说有另外一个用户加入,那他要判断他现在是这个现在的状态,那现在如果他是:joined_unbind,那他要创建PeerConnection,可能就要跟对方进行通讯,并且绑定这个他本地的媒体流到这个PeerConnection后面才能进入相应通讯,如果不是joined_unbind,那么他是一个joined状态,说明在这之前它已经绑定过了,它绑定了之后所以就不需要绑定,这时候他只要改变状态,变成了join_conn状态,然后收集这个candidate进行连接性检测,最终传输,这是第二个分支;

第三个分支:就当这个房间满了,用户发现房间满了,这时候让他设成full,关闭他所创建的这些资源PeerConnection,最后的是关闭本地的流,因为最开始的时候是一启动就获取音视频设备,而且从音视频设备中获取用,那么这时候你需要将这些资源释放,

另外还有这个leave消息,就leave分成两个:

一个是当我发送leave,收到服务器端确认已离开的消息:leaved,这时候它变成了leave状态,这时候关闭连接:disconnect。如果他是主动的发起方,他发起的时候其实他要做一部分工作,关闭PeerConnection,然后关闭音视频设备,也就是媒体流。

另外一个,当你收到对方说byebye的时候,这时候你就变成了join_unbind状态,同样也要关闭对应的peerconnection,在关闭之后另外用户再加入的时候,让你收到otherjoin这个消息(客户端流程第二个分支)的时候又会重新创建PeerConnection并进行绑定。这就是客户端的基本流程

流程图分成两部分,第一部分与加入相关,一是成功,第二个是跟离开相关的

端对端连接的基本流程

1 A与B进行通讯,首先进行信令的连接,连接完了之后整个信令就打通了

2 要创建create peerconnect,并且将音视频设备加入到这个PeerConnection里去,做一次绑定,绑定之后就开始整个的媒体协商

3 创建Offer,setLocalDescription会触发收集candidate:bind request

4 创建完Offer,通过信令然后转发给这B端呢,B端也要做相应的创建连接,设置remote description,创建Answer一系列操作,最终也要出发去收集这个candidate,通过信令再回来,交给A端,那么A端最终设置一个setRemoteDescription,这样整个协商部分在这块就完了

5 开始收集这个candidate,进行这个candidate的交换,我们信令中都有专门的这个message,就是做中转的,端对端的中转,将双方的candidate进行交互,添加到自己的列表里面进行连通性检测,整个中间这块儿就是对于整个候选者的这个数据检测连通性,那么这完了之后,整个数据通道就打通了,就开始传数据,

6 当A向B发送数据的时候,B收到一个事件,叫onAddStream或者onTrack,收到这个事件的时候,将底层的远端数据直接添加到本地的这个video标签里.

5 WebRTC客户端的实现

注意要点

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

建立网络连接要在音视频数据获取之后,否则就有可能绑定音视频流失败。如果在获取数据之前与信令服务器建立连接,那么这个时候由于通讯的双方有可能音频数据和视频数据还没准备好就已经可以开始传输信令了,一加入的时候马上就要发送join消息相应的后面一系列的信令都开始触发,那么这时候如果数据没有准备好,所以我们在创建这个协商的时候它的整个媒体通道都是没有准备好的,那么协商的时候肯定会失败。

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

第二点是当一端退出房间后,那么另一端的PeerConnection,也就是说媒体的这个通道也要关闭,然后在用户进来的时候进行重建,这样它才能始终保持一个比较干净的一个环境,否的话当我们反复的进出这个房间的时候,那么这个状态实际是很难控制的,非常容易导致新用户互通的时候也是媒体协商但是协商不成功。这也是经常遇到的错误 。

异步事件处理

那再有就是我们整个的逻辑包括上节我们所讲的状态机处理的流程,信令之间的交互,那么这些都是异步处理的.

createPeerConnection

它主要做两件事,第一件事就是创建这个RTCPeerConnection,创建完了之后要给它添加一些事件,主要是有两个事件:

第一个是onicecandidate,当我们收到这个事件的时候做一个处理,当我们监听到e.candidate存在我们就要发送消息给对端,发送出去这个candidata应该是一个什么样的格式呢?首先是roomid,然后是data包括了几个类型,type:"candidate";然后就是label:event.candidate.sdpMLineIndex,那么再下一个是id:event.candidate.sdpMid,最后一个candidate:event.candidate.candidate,们实际要用的时候主要是用label:event.candidate.sdpMLineIndex, 和candidate: event.candidate.candidate,

另外一个参数数 ontrack,这个时候我们可以将收到的这个e.streams[0]也就是第一组流赋值给远端,这样当有数据来的时候,我们就可以看到这个数据了;当这两个事件创建完了之后还要做一件事,也就是说当连接pc在,还要给pc设置一些track,如果是本地,这个track就是localStream获取到的,如果是远端它实际走的是ontrack

function createPeerConnection(){//如果是多人的话,在这里要创建一个新的连接.//新创建好的要放到一个map表中。//key=userid, value=peerconnectionconsole.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.warning('the pc have be created!');}return;
}

bindTracks

本地如果localStream为真,这个时候我们要遍历它获取Tracks数组中的每一项,当我们拿到每一项track的时候直接pc.addTrack将它加进去了,这个时候如果我们视频音频都加到这个PeerConnection之后,我们进行协商的时候创建Offer它里面SDP就产生音频的媒体流和视频的媒体流,如果这时候只有音频,SDP就只添加音频的媒体流,如果只有视频,SDP就只添加视频的媒体流,addTrack主要是起这个作用,这样双方才能知道有什么流才能进行通讯,如果不进行addTrack,则SDP没有表示媒体,那传到对方,对方发现你没有媒体也就不会做相应的操作

//绑定永远与 peerconnection在一起,
//所以没必要再单独做成一个函数
function bindTracks(){console.log('bind tracks into RTCPeerConnection!');if( pc === null || pc === undefined) {console.error('pc is null or undefined!');return;}if(localStream === null || localStream === undefined) {console.error('localstream is null or undefined!');return;}//add all track into peer connectionlocalStream.getTracks().forEach((track)=>{pc.addTrack(track, localStream); });}

hangup

销毁

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

closeLocalMedia

创建的时候已经绑定这个流了,我们还可以去关闭相应的流,就是当我们要离开的是实际是要将获取的媒体流关掉,也就是当我们结束要退出的时候我们要调用这个closeLocalMedia,我们将从设备中获取的这个track将它stop掉

function closeLocalMedia(){if(localStream && localStream.getTracks()){localStream.getTracks().forEach((track)=>{track.stop();});}localStream = null;
}

服务器发来的消息

joined

当用户成功加入的时候要改变状态state=joined,如果我们发送了这个join,服务端给我们回了joined,这个时候我们的状态就变了,变成joined说明我们已经在这个房间中了,在这个房间中之后我们就要创建createPeerConnection,创建了一个连接并且把我们本地设备获取的音视频数据,即音视频的track绑定到了这个连接上

 socket.on('joined', (roomid, id) => {console.log('receive joined message!', roomid, id);state = 'joined'//如果是多人的话,第一个人不该在这里创建peerConnection//都等到收到一个otherjoin时再创建//所以,在这个消息里应该带当前房间的用户数////create conn and bind media trackcreatePeerConnection();bindTracks();btnConn.disabled = true;btnLeave.disabled = false;console.log('receive joined message, state=', state);});

otherjoin

收到otherjoin,如果说有另外一个用户加入,那他要判断他现在是这个现在的状态,那现在如果他是:joined_unbind,那他要创建PeerConnection,可能就要跟对方进行通讯,并且绑定这个他本地的媒体流到这个PeerConnection后面才能进入相应通讯,如果不是joined_unbind,那么他是一个joined状态,说明在这之前它已经绑定过了,它绑定了之后所以就不需要绑定,这时候他只要改变状态,变成了join_conn状态,然后收集这个candidate进行连接性检测,最终传输,这是第二个分支;。

 socket.on('otherjoin', (roomid) => {console.log('receive joined message:', roomid, state);//如果是多人的话,每上来一个人都要创建一个新的 peerConnection//if(state === 'joined_unbind'){createPeerConnection();bindTracks();}state = 'joined_conn';call();//媒体协商console.log('receive other_join message, state=', state);});

full

就当这个房间满了,用户发现房间满了,这时候让他设成full,关闭他所创建的这些资源PeerConnection,最后的是关闭本地的流,因为最开始的时候是一启动就获取音视频设备,而且从音视频设备中获取用,那么这时候你需要将这些资源释放

 socket.on('full', (roomid, id) => {console.log('receive full message', roomid, id);hangup();closeLocalMedia();state = 'leaved';console.log('receive full message, state=', state);alert('the room is full!');});

leaved

一个是当我发送leave,收到服务器端确认已离开的消息:leaved,这时候它变成了leave状态,这时候关闭连接:disconnect。如果他是主动的发起方,他发起的时候其实他要做一部分工作,关闭PeerConnection,然后关闭音视频设备,也就是媒体流。

 socket.on('leaved', (roomid, id) => {console.log('receive leaved message', roomid, id);state='leaved'socket.disconnect();console.log('receive leaved message, state=', state);btnConn.disabled = false;btnLeave.disabled = true;});

bye

当你收到对方说byebye的时候,这时候你就变成了join_unbind状态,同样也要关闭对应的peerconnection

 socket.on('bye', (room, id) => {console.log('receive bye message', roomid, id);//state = 'created';//当是多人通话时,应该带上当前房间的用户数//如果当前房间用户不小于 2, 则不用修改状态//并且,关闭的应该是对应用户的peerconnection//在客户端应该维护一张peerconnection表,它是//一个key:value的格式,key=userid, value=peerconnectionstate = 'joined_unbind';hangup();offer.value = '';answer.value = '';console.log('receive bye message, state=', state);});

端对端消息

媒体协商

首先创建一个Offer,然后Offer创建成功之后通过端对端消息发送给对端,如果我们想创建这个Offer,那首先我们要判断一下我们的状态,那必须是要在joinconnction这个状态下我们才能调用call;而这个call是必须在调用者,如果两端的话调用者,如果是两端的话,调用者才能调用这个call,就是作为发起端才能调用call,那么作为接收端它是不能调用这个call;所以我们要做一个判断,如果state等于joined_conn那处于这种状态下,我们才能去做这件事

function call(){if(state === 'joined_conn'){var offerOptions = {offerToRecieveAudio: 1,offerToRecieveVideo: 1}pc.createOffer(offerOptions).then(getOffer).catch(handleOfferError);}
}

它是一个异步操作,如果成功了会调用getOffer,getOffer其实比较简单,它首先有个参数desc,首先我们要调用pc.setLocalDescription来触发收集candidate, 收集完之后我们要sendMessage发送一个端对端消息给另一端,第一个参数就是roomid,第二个参数就是数据offerdesc,这样我们就把消息发送给对端了,让他知道我这个Offer已经创建好了,对端收到这个Offer之后它会创建一个Answer再给我们返回来。

function getOffer(desc){pc.setLocalDescription(desc);offer.value = desc.sdp;offerdesc = desc;//send offer sdpsendMessage(roomid, offerdesc);   }
function sendMessage(roomid, data){console.log('send message to other end', roomid, data);if(!socket){console.log('socket is null');}socket.emit('message', roomid, data);
}

在getOffer中,先setLocalDescription然后紧接着是sendMessage,发送到服务器端进行转发,转发完了又会回到信令处理这里,所以在message要处理很多的逻辑。

首先我们要判断一下这里传过来的数据类型:

(1)Offer,那首先对端的pc已经创建好了,那它要调用setRemoteDescription,并且将这个desc设置进去,这个完了之后它紧接着要调用createAnswer,这个函数里如果成功就要getAnswer,如果出错了就调用handleAnswerError;参数中的data,在通过信令发过来的时候,它就已经不再是一个对象,只是文本,这时候我们还要给它生成一个对象,所以这里我们不能就简单的desc传进去,我们要new RTCSessionDescription(desc),

(2)Answer,那就是pc.setRemoteDescription(new RTCSessionDescription(desc)); 那么这时候我们就将这个远端设置好了

(3)candidate,我们设置了setLocalDescription之后,双方都可以进行收集candidate了,每当收到一个candidate都是触发了这个pc的onIceCandidate这个事件,在这个事件触发之后通过sendMessage发送到服务器转发给对端,发送过来之后,当我们处理candidate也需要new RTCIceCandidate,传一些参数,第一个是sdpMLineIndex: data.label, 也就是说我们媒体行的行号是多少,第二个是candidate: data.candidate,这样我们生成一个新的candidate,那有了这个新的candidate之后我们要加入本端,也就是pc.addIceCandidate,这样就将candidate加入到我们的PeerConnection当中去了,那它下面就会进行连接性检测

 socket.on('message', (roomid, data) => {console.log('receive message!', roomid, data);if(data === null || data === undefined){console.error('the message is invalid!');return;  }if(data.hasOwnProperty('type') && data.type === 'offer') {offer.value = data.sdp;pc.setRemoteDescription(new RTCSessionDescription(data));//create answerpc.createAnswer().then(getAnswer).catch(handleAnswerError);}else if(data.hasOwnProperty('type') && data.type == 'answer'){answer.value = data.sdp;pc.setRemoteDescription(new RTCSessionDescription(data));}else if (data.hasOwnProperty('type') && data.type === 'candidate'){var candidate = new RTCIceCandidate({sdpMLineIndex: data.label,candidate: data.candidate});pc.addIceCandidate(candidate);   }else{console.log('the message is invalid!', data);}});

getAnswer函数,在获取到本地的Answer之后,要做setLocalDescription,就是通知本地要收集这个candidate了,然后发送消息也就是sendMessage(roomid, desc)将answer发送给对端。

function getAnswer(desc){pc.setLocalDescription(desc);answer.value = desc.sdp;//send answer sdpsendMessage(roomid, desc);
}

共享桌面

基本格式

var promise = navigator.mediaDevices.getDisplayMedia(constraints);

与我们获取音视频基本的API基本上是一致的,这里获取桌面就是getDisplayMedia,后面的参数也是 constraints,有一个限制,

constraints可选

constraints中约束与getUserMedia函数中基本一致。大家可以去尝试一下,看看里面有什么不同

需要注意的点

getDisaplyMedia无法同时采集音视频

getDisaplyMedia是无法采集桌面的同时也采集声音的,这是与getUserMedia一个最大的不同,那就出现了一个问题,在我们直播的过程中我们共享桌面了,但是同时我们还要有声音 ,那如何实现既共享桌面又能听到对方的声音,那还有没有可能既看到对方的桌面又看到他的视频还能同时听到声音,大家可以尝试一下。

我们第一个目的就是先能共享桌面、听到声音

第二个就是既听到对方的声音又能共享桌面又能听到对方的视频,

桌面是否可以调整分辨率?

像我们采集视频一样可以按照我们的要求去调整分辨率大小,比如说640*480,获取7120P或者1080P,这个看看是否可以,

共享桌面的时候是否可以共享整个桌面或者是共享某个应用或者是共享某块区域

有的人就想共享整个桌面,让大家都看到,有的觉得不想给别人看到其他的应用,只想共享其中的某个应用,我们可以进行交流就好了,还有人就是说在这一块区域内可以看到,超过这块区域的你就不用看到了,看看这些在共享桌面上能不能做到

10-webrtc实现1V1音视频实时互动直播系统相关推荐

  1. 实现1V1音视频实时互动直播系统 十二、第九节 直播客户端的实现

    我们今天继续实现直播客户端的PeerConnection这个代码,上节我们只是将conn这个函数实现了一大半,在这里我们首先与这个信令服务器建立连接,然后将joined.otherjoin.full. ...

  2. 实现1V1音视频实时互动直播系统 十二、第三节 直播系统中的信令及其逻辑关系

    今天我们开始讲真正的音视频传输了,也就说将我们之前讲解的信令服务器与我们后面讲解的端到端传输过程,那么整个将他们连接到一起,这样就形成了一个真正的直播系统.看似很简单的东西,其实我们要改造的内容还是有 ...

  3. 【WebRTC---入门篇】(十七)实现1V1音视频实时互动直播系统

    STUN/TURN服务器搭建 详细搭建过程 RTCPeerConnection

  4. 5G网络逐渐普及TSINGSEE青犀视频云边端架构网页视频实时互动直播系统又将如何发展?

    国家发展的风口浪尖是互联网,互联网发展的风口浪尖是5G.中国工程院院士.中国互联网协会理事长邬贺铨说:未来5G会进一步使宽带化的移动互联网应用变得无处不在,支撑起一个万物互联的时代.5G以其超大宽带. ...

  5. WEBRTC音视频实时互动技术

    为了解决听得远和看得远的问题,科学家们一直在为此孜孜不倦地探索.1876年,贝尔发明了电话,使人们真的可以听到千里之外的声音,如图所示. 从此掀起了一场技术革命.对于我国来说,电话的引入是非常早的. ...

  6. 基于WebRTC实现1v1音视频聊天室

    一. 前言 WebRTC(Web Real-Time Communication)旨在将实时通信功能引入到浏览器,用户无需安装其他任何软件或插件即可在浏览器间进行实时通信功能.本文介绍基于 WebRT ...

  7. 场景实践:低代码音视频工厂-互动直播体验

    体验有礼 低代码音视频工厂vPaaS体验,完成体验提交报告可抢抽奖资格!抽奖100%中奖! 参与地址:https://developer.aliyun.com/adc/series/activity/ ...

  8. WebRTC音视频实时传输与服务质量

    为了保证音视频的质量,WebRTC底层做了大量的工作,尤其是网络传输与服务质量,更是其核心技术,本文由北京音视跳动科技有限公司 首席架构师 李超在LiveVideoStack线上分享的演讲整理而成,详 ...

  9. 刘连响:小程序实时音视频在互动场景下的应用

    本文来自腾讯云技术沙龙,本次沙龙主题为在线教育个性化教学技术实践 作者简介:刘连响,一起玩耍科技创始人.2013年起开始研究WebRTC, 对音视频处理. 直播.实时音视频相关技术非常感兴趣,具有多个 ...

最新文章

  1. 【建站系列教程】3、建站基本技术介绍
  2. CO-类的本质、description方法
  3. [编程题] 迷路的牛牛
  4. firewalld/iptables防火墙维护和状态查询命令(防火墙重载,区域操作命令,开启服务或端口,堵塞端口,iptables规则添加和删除)
  5. 常用加密算法(Java实现)总结
  6. 你真的了解JAVA的形参和实参吗?
  7. 安卓三维展示源码_谷歌也翻车了?全球数亿安卓设备难逃一“劫”,用户隐私数据库被利用长达10年!...
  8. 怎么将文字转换成语音?
  9. python输出个数、给定一个n*n的矩阵m_简述Numpy
  10. 古诗词html模板,田圆格古诗词书法模板
  11. veu项目中下载图片到本地
  12. java一直显示载入中_java – 当类在包中时为GUI加载图像的问题
  13. 如何处理电脑长时间未操作出现的假死?
  14. [转] 两篇关于flash 职业和webgame的文章
  15. 浅谈PHP代码执行的大致流程(opcode)
  16. STM32单片机低功耗剖析
  17. 2019复旦大学计算机分数线,2019复旦大学录取分数线(在各省市录取数据)
  18. 相干与非相干FSK解调和Viterbi软硬判性能的仿真对比
  19. 旅行时通过树莓派和 iPad Pro 备份图片
  20. 初级职称 英语和计算机,初级经济师需要考职称英语和计算机考试吗

热门文章

  1. php怎么把字弄到另一张图片上,怎样把一张图片p到另外一张图片上去
  2. uniapp离线打包apk安装在android12上无法安装
  3. oracle中部门工资降序排列,oracle面试题整理二(10级学员 乔宇整理)
  4. 链乔教育在线|智能合约学习——以太坊智能合约学习笔记(四)
  5. Surface Book2 购买、使用、体验
  6. 抽屉原理解释及简单举例
  7. 青少年CTF训练平台Misc-Middle愿风神忽悠你题解
  8. 怎么关闭vivo系统自检_科技资讯:vivo手机中软件的自启动功能怎么关闭
  9. 腾讯云双11服务器优惠报价表详细内容
  10. 联想小新一键恢复小孔_联想小新Air系列一键恢复及恢复后首次配置的步骤