1.RTCPeerConnection 开启视频通话过程

1.1 获取本地音视频流

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
//创建 RTCPeerConnection 对象
let localPeerConnection = new RTCPeerConnection(servers);
...

//调用 getUserMedia API 获取音视频流
navigator.mediaDevices.getUserMedia(mediaStreamConstraints).
  then(gotLocalMediaStream).
  catch(handleLocalMediaStreamError);
  
//如果 getUserMedia 获得流,则会回调该函数
//在该函数中一方面要将获取的音视频流展示出来
//另一方面是保存到 localSteam
function gotLocalMediaStream(mediaStream) {
  ...
  localVideo.srcObject = mediaStream;
  localStream = mediaStream;
  ...

}
...
//将音视频流添加到 RTCPeerConnection 对象中
localPeerConnection.addStream(localStream);
...

1.2 交换媒体描述信息

 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
...
localPeerConnection.createOffer(offerOptions)
  .then(createdOffer).catch(setSessionDescriptionError);
...

//当创建 offer 成功后,会调用该函数
function createdOffer(description) {
  ...
  //将 offer 保存到本地
  localPeerConnection.setLocalDescription(description)
    .then(() => {
      setLocalDescriptionSuccess(localPeerConnection);
    }).catch(setSessionDescriptionError);
  ...
  //远端 pc 将 offer 保存起来
  remotePeerConnection.setRemoteDescription(description)
    .then(() => {
      setRemoteDescriptionSuccess(remotePeerConnection);
    }).catch(setSessionDescriptionError);
  ...
  //远端 pc 创建 answer
  remotePeerConnection.createAnswer()
    .then(createdAnswer)
    .catch(setSessionDescriptionError);
}

//当 answer 创建成功后,会回调该函数 
function createdAnswer(description) {
  ...
  //远端保存 answer
  remotePeerConnection.setLocalDescription(description)
    .then(() => {
      setLocalDescriptionSuccess(remotePeerConnection);
    }).catch(setSessionDescriptionError);

  //本端pc保存 answer
  localPeerConnection.setRemoteDescription(description)
    .then(() => {
      setRemoteDescriptionSuccess(localPeerConnection);
    }).catch(setSessionDescriptionError);
}

1.3 端与端建立连接

 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
...
localPeerConnection.onicecandidate= handleConnection(event); 
...

...
function handleConnection(event) {
  
  //获取到触发 icecandidate 事件的 RTCPeerConnection 对象
  //获取到具体的Candidate
  const peerConnection = event.target;
  const iceCandidate = event.candidate;

  if (iceCandidate) {
     //创建 RTCIceCandidate 对象
    const newIceCandidate = new RTCIceCandidate(iceCandidate);
    //得到对端的RTCPeerConnection
    const otherPeer = getOtherPeer(peerConnection);

  //将本地获到的 Candidate 添加到远端的 RTCPeerConnection对象中
    otherPeer.addIceCandidate(newIceCandidate)
      .then(() => {
        handleConnectionSuccess(peerConnection);
      }).catch((error) => {
        handleConnectionFailure(peerConnection, error);
      });
    ...
  }
}
...

1.4 显示远端媒体流

1
2
3
4
5
6
7
8
9
...
localPeerConnection.onaddstream = handleRemoteStreamAdded;
...
function handleRemoteStreamAdded(event) {
  console.log('Remote stream added.');
  remoteStream = event.stream;
  remoteVideo.srcObject = remoteStream;
}
...

2. 如何控制传输速率

音视频服务质量因素

  • 网络质量,包括物理链路的质量、带宽的大小、传输速率的控制等;
  • 数据,包括音视频压缩码率、分辨率大小、帧率等。

2.1 网络质量

物理链路层

抖动,延迟,丢包等因素引起

带宽

充分利用带宽

传输速率

音视频压缩率码率:是有损压缩还是无损压缩,压缩率大小

传输码率:发包速度和包体积决定

2.2 数据

分辨率

可以通过调整分辨率来降低传输大小,比如将1280 x 720 变成 640 * 360来缩减视频体积

帧率

降低帧率其实并不能很好缩减体积,在一个GOP中,其实关键帧I所在比重较大,比P帧和B帧多的多,通过减少P帧和B帧并不能降低多少体积大小

2.2 webrtc 控制传输速率的方法

可以通过以下两种方式来控制传输速率。第一种是通过压缩码率这种“曲线救国”的方式进行控制;第二种则是更直接的方式,通过控制传输速度来控制速率。

webrtc中,网络部分的控制手段都是已经封装好,所以我们多是从压缩码率来改变传输速率。

 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
....

var vsender = null; //定义 video sender 变量
var senders = pc.getSenders(); //从RTCPeerConnection中获得所有的sender

//遍历每个sender
senders.forEach( sender => {
  if(sender && sender.track.kind === 'video'){ //找到视频的 sender
      vsender = sender; 
  }
});

var parameters = vsender.getParameters(); //取出视频 sender 的参数
if(!parameters.encodings){ //判断参数里是否有encoding域
    return;
}

//通过 在encoding中的 maxBitrate 可以限掉传输码率
parameters.encodings[0].maxBitrate = bw * 1000;

//将调整好的码率重新设置回sender中去,这样设置的码率就起效果了。
vsender.setParameters(parameters) 
       .then(()=>{
          console.log('Successed to set parameters!');
       }).catch(err => {
          console.error(err);
       })

...

3.打开/关闭音频

3.1 声音静音的操作

播放端控制

video静音

1
2
3
4
...
var remotevideo = document.querySelector('video#remote');
remotevideo.muted = false;
...

去掉audio 流

 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
...
var remoteVideo = document.querySelector('video#remote');
...
{
    //创建与远端连接的对象
    pc = new RTCPeerConnection(pcConfig);
  ...
    //当有远端流过来时,触发该事件
    pc.ontrack = getRemoteStream;
    ...
}
...

function getRemoteStream(e){
    //得到远端的音视频流
  remoteStream = e.streams[0];
    //找到所有的音频流
    remoteStream.getAudioTracks().forEach((track)=>{
      if (track.kind === 'audio') { //判断 track 是类型
        //从媒体流中移除音频流    
        remoteStream.removeTrack(track);
      }
    }); 
    //显示视频 
  remoteVideo.srcObject = e.streams[0];
}
...

发送端控制

不采集音频

 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
...

//获取本地音视频流
function gotStream(stream) {
    localStream = stream;
    localVideo.srcObject = stream;
}

//获得采集音视频数据时限制条件
function getUserMediaConstraints() {
  
  var constraints =  { 
    "audio": false,
    "video": {
        "width": {
            "min": "640",
            "max": "1280"
        },
        "height": {
            "min": "360",
            "max": "720"
        }
    }
  };
  
  return constraints;
}

...
//采集音视频数据
function captureMedia() {
    ...
    if (localStream) {
      localStream.getTracks().forEach(track => track.stop());
    }
      ...
      //采集音视频数据的 API
    navigator.mediaDevices.getUserMedia(getUserMediaConstraints())
      .then(gotStream)
      .catch(e => {
       ...
      });
}
...

关闭通道

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  ...
  var localStream = null;
  
  //创建peerconnection对象
  var pc = new RTCPeerConnection(server);
  ...
  
  //获得流
  function gotStream(stream){
    localStream = stream;
  }
  ...
  
  //peerconnection 与 track 进行绑定 
  function bindTrack() {
    //add all track into peer connection
    localStream.getTracks().forEach((track)=>{
      if(track.kink !== 'audio') {
        pc.addTrack(track, localStream);
      }
    });
  }
  
  ...
  • 在关闭本地视频时不是直接在采集的时候就不采集视频数据,而是不将视频数据与 RTCPeerConnection 对象进行绑定,因为自己本地还需要预览

4. 数据统计

4.1 信息获取

webrtc 已经已经提供了一个比较全面的数据监控工具,在chrome中通过如下链接可以直接看到

1
chrome://webrtc-internals
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
...
//获得速个连接的统计信息
pc.getStats().then( 
    //在一个连接中有很多 report
    reports => {
        //遍历每个 report
        reports.forEach( report => {
            //将每个 report 的详细信息打印出来
            console.log(report);
        });

    }).catch( err=>{
      console.error(err);
    });
);
...  
  • getStats API 是 RTCPeerConnecton 对象的方法,用于获取各种统计信息
  • 参数可以为null,返回全部信息;参数为MediaStreamTrack,指定特定部分

具体输出如下

RTCStatsReport 输出格式

  • id:对象的唯一标识,是一个字符串。
  • timestamp:时间戳,用来标识该条 Report 是什么时间产生的。
  • type:类型,是 RTCStatsType 类型

三种获取stats的方法

  • RTCPeerConnection 对象的 getStats 方法获取的是所有的统计信息,除了收发包的统计信息外,还有候选者、证书、编解码器等其他类型的统计信息。
  • RTCRtpSender 对象的 getStats 方法只统计与发送相关的统计信息。
  • RTCRtpReceiver 对象的 getStats 方法则只统计与接收相关的统计信息

4.2 信息渲染

  • 引入第三方库 graph.js;
  • 启动一个定时器,每秒钟绘制一次图形;
  • 在定时器的回调函数中,读取 RTCStats 统计信息,转化为可量化参数,并将其传给 graph.js 进行绘制。

5. 文本发送

  • 发送方
1
2
3
dc = pc.createDataChannel('sendDataChannel', dataConstraint); //一端主动创建 RTCDataChannel 
...
dc.onmessage = receivedMessage; //当有文本数据来时,回调该函数。

使用RTCPeerConnection创建RTCDataChannel,注册RTCDataChannel的回调方法onmessage,消息会从onmessage中传入

  • 接收方
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  var servers = {'iceServers': [{
        'urls': 'turn:youdomain:3478',
        'credential': "passwd",
        'username': "username"
      }]
  };
 
  pc = new RTCPeerConnection(servers, pcConstraint);
  pc.onicecandidate = handleIceCandidate; //收集候选者
  pc.ondatachannel = onDataChannelAdded;  //当对接创建数据通道时会回调该方法。


  function onDataChannelAdded(event) {
      dc = event.channel;
      dc.onmessage = receivedMessage;
      ...
  }

接受端在创建RTCPeerConnection时,注册一个ondatachannel方法,通信双方完成了媒体协商、交换了 SDP 之后,另一端收到发送端的消息,ondatachannel 事件就会被触发。此时就会调用它的回调函数 onDataChannelAdded ,通过 onDataChannelAdded 函数的参数 event 你就可以获取到另一端的 RTCDataChannel 对象

5. 传输文件

  • 发送方
 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
...
function sendData(){
  var offset = 0; //偏移量
  var chunkSize = 16384; //每次传输的块大小
  var file = fileInput.files[0]; //要传输的文件,它是通过HTML中的file获取的
  ...
  //创建fileReader来读取文件
  fileReader = new FileReader();
  ...
  fileReader.onload = e => { //当数据被加载时触发该事件
    ...
    dc.send(e.target.result); //发送数据
    offset += e.target.result.byteLength; //更改已读数据的偏移量
    ...  
    if (offset < file.size) { //如果文件没有被读完
      readSlice(offset); // 读取数据
    }
  }
  var readSlice = o => {
    const slice = file.slice(offset, o + chunkSize); //计算数据位置
    fileReader.readAsArrayBuffer(slice); //读取 16K 数据
  };
  readSlice(0); //开始读取数据
}
...

通过FileReader每次读取固定大小的数据,读取成功,触发onload方法,然后将读取的数据通过RTCDataChnanel发送出去。

  • 接收方
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
var receiveBuffer = []; //存放数据的数组
var receiveSize = 0; //数据大小
...
onmessage = (event) => {
  //每次事件被触发时,说明有数据来了,将收到的数据放到数组中
  receiveBuffer.push(event.data);
  //更新已经收到的数据的长度
  receivedSize += event.data.byteLength;
  //如果接收到的字节数与文件大小相同,则创建文件
  if (receivedSize === fileSize) { //fileSize 是通过信令传过来的
    //创建文件
    var received = new Blob(receiveBuffer, {type: 'application/octet-stream'});
    //将buffer和 size 清空,为下一次传文件做准备
    receiveBuffer = [];
    receiveSize = 0;  
    //生成下载地址
    downloadAnchor.href = URL.createObjectURL(received);
    downloadAnchor.download = fileName;
    downloadAnchor.textContent =
      `Click to download '${fileName}' (${fileSize} bytes)`;
    downloadAnchor.style.display = 'block';
  }
}

当有数据来临时,会触发RTCDataChannel的onmessage方法,然后我们将数据读出,同时记录数据块大小,当数据块累计大小等于文件大小时,通过 blob来实现下载。

  • 文件信息传输

上述为文件实体传输,我们还需要将文件信息传输,这样方便传输计算(比如文件大小对比)

可以通过socket.io来事先传给接收方文件的数据信息

1
2
3
4
fileName = file.name;
fileSize = file.size;
fileType = file.type;
lastModifyTime = file.lastModified;

6.安全

webrtc 安全序列

  • SDP 传输对方的 ice-ufrag、ice-pwd 和 fingerprint 信息,有了这些信息后,就可验证对方是否是一个合法用户了。
  • 紧接着,A 通过 STUN 协议(底层使用 UDP 协议)进行身份认证。如果 STUN 消息中的用户名和密码与交换的 SDP 中的用户名和密码一致,则说明是合法用户
  • 确认用户为合法用户后,则需要进行 DTLS 协商,交换公钥证书并协商密码相关的信息。同时还要通过 fingerprint 对证书进行验证,确认其没有在传输中被窜改。
  • 最后,再使用协商后的密码信息和公钥对数据进行加密,开始传输音视频数据