webrtc

基本使用

概述

WebRTC 是“网络实时通信”(Web Real Time Communication)的缩写。它最初是为了解决浏览器上视频通话而提出的,即两个浏览器之间直接进行视频和音频的通信,不经过服务器。后来发展到除了音频和视频,还可以传输文字和其他数据。

Google 是 WebRTC 的主要支持者和开发者,它最初在 Gmail 上推出了视频聊天,后来在 2011 年推出了 Hangouts,语序在浏览器中打电话。它推动了 WebRTC 标准的确立。

简单来说,WebRTC 是一个可以在 Web 应用程序中实现音频,视频和数据的实时通信的开源项目。在实时通信中,音视频的采集和处理是一个很复杂的过程。比如音视频流的编解码、降噪和回声消除等,但是在 WebRTC 中,这一切都交由浏览器的底层封装来完成。我们可以直接拿到优化后的媒体流,然后将其输出到本地屏幕和扬声器,或者转发给其对等端。

WebRTC 的音视频处理引擎:

img

WebRTC 主要让浏览器具备三个作用。

WebRTC 共分成三个 API,分别对应上面三个作用。

不过,虽然浏览器给我们解决了大部分音视频处理问题,但是从浏览器请求音频和视频时,我们还是需要特别注意流的大小和质量。因为即便硬件能够捕获高清质量流,CPU 和带宽也不一定可以跟上,这也是我们在建立多个对等连接时,不得不考虑的问题。

获取音视频流

MediaDevices.getUserMedia(constraints)

MediaDevices.getUserMedia() 会提示用户给予使用媒体输入的许可,媒体输入会产生一个 MediaStream,里面包含了请求的媒体类型的轨道。

此流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D 转换器等等),也可能是其它轨道类型。

参数

返回值

异常

描述

帧率

权限

在一个可安装的 app(如 Firefox OS app)中使用 getUserMedia() ,你需要在声明文件中指定以下的权限:

"permissions": {
  "audio-capture": {
    "description": "Required to capture audio using getUserMedia()"
  },
  "video-capture": {
    "description": "Required to capture video using getUserMedia()"
  }
}

连接数据流和 DOM

在旧版本的媒体资源规范中,添加流作为 vedio 元素需要创建一个关于 MediaStream 的对象 URL。现已没必要这样做了,浏览器已经移除了该操作的支持。

video.src = stream;
video.srcObject = mediaStream; // mediaStream 专用属性

Chrome 和 Opera 还允许 getUserMedia 获取的音频数据,直接作为 audio 或者 video 元素的值,也就是说如果还获取了音频,上面代码播放出来的视频是有声音的。

示例:获取摄像头

下面通过 getUserMedia 方法,将摄像头拍摄的图像展示在网页上。

首先,需要先在网页上放置一个 video 元素。图像就展示在这个元素中。

<video id="webcam"></video>

然后,用代码获取这个元素。

function onSuccess(stream) {
    var video = document.getElementById('webcam');
}

接着,将这个元素的 src 属性绑定数据流,摄影头拍摄的图像就可以显示了。

// 想要获取一个最接近 1280x720 的相机分辨率
var constraints = { audio: true, video: { width: 1280, height: 720 } } 

navigator.mediaDevices.getUserMedia(constraints)
.then(function(mediaStream) {
  video.srcObject = mediaStream
  video.onloadedmetadata = function(e) {
    video.play()
  }
})
.catch(function(err) { console.log(err.name + ": " + err.message); }) // 总是在最后检查错误

截屏

获取摄像头的主要用途之一,是让用户使用摄影头为自己拍照。Canvas API 有一个 ctx.drawImage(video, 0, 0) 方法,可以将视频的一个帧转为 canvas 元素。这使得截屏变得非常容易。

<video autoplay></video>
<img src="">
<canvas style="display:none;"></canvas>

<script>
  var video = document.querySelector('video');
  var canvas = document.querySelector('canvas');
  var ctx = canvas.getContext('2d');
  var localMediaStream = null;

  function snapshot() {
    if (localMediaStream) {
      ctx.drawImage(video, 0, 0);
      // “image/webp”对Chrome有效,
      // 其他浏览器自动降为image/png
      document.querySelector('img').src = canvas.toDataURL('image/webp');
    }
  }

  video.addEventListener('click', snapshot, false);

  navigator.getUserMedia({video: true}, function(stream) {
    video.src = window.URL.createObjectURL(stream);
    localMediaStream = stream;
  }, errorCallback);
</script>

MediaStreamTrack.getSources()

如果本机有多个摄像头/麦克风,这时就需要使用 MediaStreamTrack.getSources 方法指定,到底使用哪一个摄像头/麦克风。

MediaStreamTrack.getSources(function(sourceInfos) {
  var audioSource = null;
  var videoSource = null;

  for (var i = 0; i != sourceInfos.length; ++i) {
    var sourceInfo = sourceInfos[i];
    if (sourceInfo.kind === 'audio') {
      console.log(sourceInfo.id, sourceInfo.label || 'microphone');

      audioSource = sourceInfo.id;
    } else if (sourceInfo.kind === 'video') {
      console.log(sourceInfo.id, sourceInfo.label || 'camera');

      videoSource = sourceInfo.id;
    } else {
      console.log('Some other kind of source: ', sourceInfo);
    }
  }

  sourceSelected(audioSource, videoSource);
});

function sourceSelected(audioSource, videoSource) {
  var constraints = {
    audio: {
      optional: [{sourceId: audioSource}]
    },
    video: {
      optional: [{sourceId: videoSource}]
    }
  };

  navigator.getUserMedia(constraints, onSuccess, onError);
}

上面代码表示,MediaStreamTrack.getSources 方法的回调函数,可以得到一个本机的摄像头和麦克风的列表,然后指定使用最后一个摄像头和麦克风。

结束使用

https://s0developer0mozilla0org.icopy.site/zh-CN/docs/Web/API/MediaDevices/getUserMedia

https://s0developer0mozilla0org.icopy.site/zh-CN/docs/Web/API/MediaStream

https://s0developer0mozilla0org.icopy.site/zh-CN/docs/Web/API/MediaStream/getTracks

https://s0developer0mozilla0org.icopy.site/zh-CN/docs/Web/API/MediaStreamTrack

mediaStream.getTracks()[0].stop();

录音

获取音频

使用 WebRTC 的 getUserMedia,获取调起麦克风并获取数据

function record () {
    window.navigator.mediaDevices.getUserMedia({
        audio: true
    }).then(mediaStream => {
        console.log(mediaStream);
        beginRecord(mediaStream);
    }).catch(err => {
        // 如果用户电脑没有麦克风设备或者用户拒绝了,或者连接出问题了等
        // 这里都会抛异常,并且通过err.name可以知道是哪种类型的错误 
        console.error(err);
    })  ;
}

指定音频的格式

window.navigator.mediaDevices.getUserMedia({
    audio: {
        sampleRate: 44100, // 采样率
        channelCount: 2,   // 声道
        volume: 1.0        // 音量
    }
}).then(mediaStream => {
    console.log(mediaStream);
});

保存音频

出于实际考虑,一般不会一边录音一边播放音频,往往我们需要先把录音保存起来,之后再播放。

它是音频流的抽象,把这个流用来初始化一个 MediaStreamAudioSourceNode 对象,然后把这个节点 connect 连接到一个 JavascriptProcessorNode,在它的 onaudioprocess 里面获取到音频数据,然后保存起来,就得到录音的数据。

https://juejin.im/post/5b8bf7e3e51d4538c210c6b0#heading-2

API 怪异且不稳定,直接用第三方库进行音频处理

第三方库

地址:https://github.com/2fps/recorder

信令与视频通话

流程

WebRTC 是一个完全对等技术,用于实时交换音频、视频和数据,同时提供一个中心警告。如其他地方所讨论的,必须进行一种发现和媒体格式协商,以使不同网络上的两个设备相互定位。这个过程被称为信令,并涉及两个设备连接到第三个共同商定的服务器。通过这个第三方服务器,这两台设备可以相互定位,并交换协商消息。

在信令阶段需要完成的任务:

WebRTC 架构概述

用来创建 WebRTC 连接的 API 底层使用了一系列的网络协议和连接标准。这篇文章涵盖了这些标准。

SDP

会话描述协议 Session Description Protocol (SDP) 是一个描述多媒体连接内容的协议,例如分辨率,格式,编码,加密算法等。所以在数据传输时两端都能够理解彼此的数据。本质上,这些描述内容的元数据并不是媒体流本身。

img

媒体信息

通常某个浏览器所在的电脑,都会连接具体的多媒体设备(比如:麦克风、摄像头)。如果 A 电脑上的摄像头只支持 VP8,H264 格式,而另一台电脑上的摄像头只支持 H264、MPEG-4 格式,它俩要能正常播放彼此的视频,肯定会选择双方都能识别的 H264 格式。

img

网络信息

ICE

交互式连接设施 Interactive Connectivity Establishment (ICE) 是一个允许你的浏览器和对端浏览器建立连接的协议框架。在实际的网络当中,有很多原因能导致简单的从 A 端到 B 端直连不能如愿完成:

ICE 通过使用以下几种技术完成上述工作。

img

这是 mozilla 开发者官网上的一张图, 大致描述了 webrtc 的处理过程:

注:如果 A,B 之间无法直接穿透(即:无法建立点对点的 P2P 直连),将通过 TURN 服务器中转。

NAT

网络地址转换协议 Network Address Translation (NAT) 用来给你的(私网)设备映射一个公网的 IP 地址的协议。一般情况下,路由器的 WAN 口有一个公网 IP,所有连接这个路由器 LAN 口的设备会分配一个私有网段的 IP 地址(例如 192.168.1.3)。私网设备的 IP 被映射成路由器的公网 IP 和唯一的端口,通过这种方式不需要为每一个私网设备分配不同的公网 IP,但是依然能被外网设备发现。

一些路由器严格地限定了部分私网设备的对外连接。这种情况下,即使 STUN 服务器识别了该私网设备的公网 IP 和端口的映射,依然无法和这个私网设备建立连接。这种情况下就需要转向 TURN 协议。

STUN

NAT 的会话穿越功能 Session Traversal Utilities for NAT (STUN) (缩略语的最后一个字母是 NAT 的首字母) 是一个允许位于 NAT 后的客户端找出自己的公网地址,判断出路由器阻止直连的限制方法的协议。

客户端通过给公网的 STUN 服务器发送请求获得自己的公网地址信息,以及是否能够被(穿过路由器)访问。

An interaction between two users of a WebRTC application involving a STUN server.

TURN

一些路由器使用一种“对称型 NAT”的 NAT 模型。这意味着路由器只接受和对端先前建立的连接(就是下一次请求建立新的连接映射)。

NAT 的中继穿越方式 Traversal Using Relays around NAT (TURN) 通过 TURN 服务器中继所有数据的方式来绕过“对称型 NAT”。你需要在 TURN 服务器上创建一个连接,然后告诉所有对端设备发包到服务器上,TURN 服务器再把包转发给你。很显然这种方式是开销很大的,所以只有在没得选择的情况下采用。

基础示例:本地 1v1 对等连接

我们先来实现一个本地的对等连接,借此熟悉一下流程和部分 API。

<video id='A'  width="300" height="150"></video>
<video id="B" width="300" height="150"></video>
<button id='receive'>接受视频</button>

A 作为输出端,需要获取到本地流并添加到自己的 RTCPeerConnection;B 作为呼叫端,并没有输出的需求,因此只需要接收流。

步骤

  1. 初始化 RTC 端口

    // 初始化RTC端口
    let PeerConnection = 
      window.RTCPeerConnection ||
      window.mozRTCPeerConnection ||
      window.webkitRTCPeerConnection
    
    let peerA=peerB=null
    peerA = new PeerConnection()
    peerB = new PeerConnection()
    
  2. 端口 A 获取视频流,addStream 方法将 getUserMedia 方法中获取的流 (stream) 添加到 RTCPeerConnection 对象中,以进行传输

    const constraints = { audio: true, video: true }
    navigator.mediaDevices.getUserMedia(constraints)
    .then(function(mediaStream) {
      A.srcObject = mediaStream
      A.onloadedmetadata = function(e) {
        A.play()
      }
      // 获取到媒体流后,添加到端口A
      peerA.addStream(mediaStream)
    })
    .catch(function(err) { console.log(err.name + ": " + err.message); })
    
  3. 点击按钮的时候,端口 A 创建 offer sdp,并发送给 B,同时设置本地 sdp 描述符

    • createOffer()RTCPeerConnection 对象自带的方法,用来创建 offer
    • 创建成功后调用 setLocalDescription 方法将 localDescription 设置为 offer
    • localDescription 即为我们需要发送给应答方的 sdp
    receive.onclick = function(){
      // 发起RTC连接请求
      peerA.createOffer().then((offer) => {
        peerA.setLocalDescription(offer).then(
          answer(offer) // sendOffer
        )
      })
    }
    
  4. 端口 B 收到 A 发送的 sdp,做出应答,创建 answer sdp

    • offer sdp 是端口 B 的 remoteDescription 属性
    • 同时 answer sdp 是端口 A 的 remoteDescription 属性
    • offeranswer 都有 sdp 属性,包含着用于交换的 sdp 描述符
    async function answer(offer){
      // 接收端设置远程 offer sdp 描述
      peerB.setRemoteDescription(offer)
      // 接收端创建 answer
      let answer = await peerB.createAnswer()
      // 接收端设置本地 answer sdp 描述 
      peerB.setLocalDescription(answer)
      // 发起端 要接受 answer sdp 描述
      peerA.setRemoteDescription(answer)
    }
    
  5. 此时虽然 WebRTC 连接已经完成,但是通信双方还不能直接通信,因为发送的 ICE 还没有处理,通信双方还没有确定最优的连接方式。此时 onicecandidate 事件会不断的触发,用来寻找合适的 ICE,每次端口号都会变化和协议等 都可能变化

    // 监听 A 的ICE候选信息 如果收集到,就添加给 B 连接状态
    peerA.onicecandidate = (event) => {
      const can = event.candidate
      if (can) peerB.addIceCandidate(can)
    }
    // 监听 B 的ICE候选信息 如果收集到,就添加给 A 连接状态
    peerB.onicecandidate = (event) => {
      const can = event.candidate
      if (can) peerA.addIceCandidate(can)
    }
    

    image-20200305182607034

  6. 当协商稳定之后,端口 B 就会接收到视频流。onaddstream 事件用来监听通道中新加入的流,通过 event.stream 获取

    // 监听是否有媒体流接入,如果有就赋值给 rtcB 的 src
    peerB.onaddstream = (e) => {
      B.srcObject = event.stream
      B.onloadedmetadata = function(e) {
        B.play()
      }
    }
    

网络 1v1 对等连接

本地连接与网络连接的区别:

网络服务器我们使用 ws

候选目标

要通过服务器进行连接,我们先要有一个已经连接到服务器上的目标,因此需要先实现一个连接用户列表

每当有一个用户连接到 ws 服务器时,就向全部用户广播,并添加到 textarea 中显示

信令服务器

两个设备之间建立 WebRTC 连接需要一个信令服务器来实现双方通过网络进行连接。信令服务器的作用是作为一个中间人帮助双方在尽可能少的暴露隐私的情况下建立连接。

WebRTC 并没有提供信令传递机制,你可以使用任何你喜欢的方式如 WebSocket 或者 XMLHttpRequest 等等,来交换彼此的令牌信息。

重要的是信令服务器并不需要理解和解释信令数据内容。虽然它基于 SDP 但这并不重要:通过信令服务器的消息的内容实际上是一个黑盒。重要的是,当 ICE 子系统指示你将信令数据发送给另一个对等方时,你就这样做,而另一个对等方知道如何接收此信息并将其传递给自己的 ICE 子系统。你所要做的就是来回传递信息。内容对信令服务器一点都不重要。

聊天服务器

我们的 聊天服务器和客户端 使用 WebSocket API JSON 格式的字符串来传递数据。服务器支持多种消息格式来处理不同的任务,比如注册新用户、设置用户名、发送公共信息等等。

为了让服务器支持信令和 ICE 协商,我们需要升级代码,我们需要直接发送聊天系统到指定的用户而不是发送给所有人,并且保证服务器在不需要理解数据内容的情况下传递未被认可的任何消息类型。这让我们可以使用一台服务器来传递信令和消息而不是多台。

让我们看一下我们还需要做些什么让它支持 WebRTC 信令. 代码在 chatserver.js.中实现。

首先来看 sendToOneUser() 函数,如名所示它发送 JSON 字符串到指定的用户。

function sendToOneUser(target, msgString) {
  var isUnique = true;
  var i;

  for (i=0; i<connectionArray.length; i++) {
    if (connectionArray[i].username === target) {
      connectionArray[i].sendUTF(msgString);
      break;
    }
  }
}

这个函数遍历所有在线用户直到找到给定的用户名然后发送数据 msgString 一个 JSON 字符串对象,我们可以让它接收我们的原始消息对象,但是在当前这种情况下它的效率更高因为我们的消息已经字符串化了,我们达到了不需要进一步处理就可以发送消息的目的。

我们原来的 DEMO 不能发送消息到指定的用户,我们可以通过修改 WebSocket 消息处理句柄来实现这个功能,这需要在 connection.on() 尾部修改。

if (sendToClients) {
  var msgString = JSON.stringify(msg);
  var i;

  // If the message specifies a target username, only send the
  // message to them. Otherwise, send it to every user.
  if (msg.target && msg.target !== undefined && msg.target.length !== 0) {
    sendToOneUser(msg.target, msgString);
  } else {
    for (i=0; i<connectionArray.length; i++) {
      connectionArray[i].sendUTF(msgString);
    }
  }
}

代码会检查我们的数据是否提供了 target 属性. 这个属性包含了我们想要发送给的人的用户名。如果提供了 target 属性, 通过调用 sendToOneUser() 消息将只发送给指定的人. 否则的话将遍历在线列表发送给每一个人。

由于现行的代码可以发送任意类型的消息,所以我们不需要做任何的修改。现在我们的客户端可以发送任意消息给指定的用户。

我们需要做的在服务器这边,现在我们来考虑信令协议的设计与实现。

设计信令协议

现在我们要构建一套信息交换规则,我们需要一套协议来定义消息格式。实现这个有好多种办法,demo 里只是其中一种,并不是唯一。

例子中的服务器使用字符串化的 JSON 对象来和客户端通信,意味着我们的信令消息也将使用 JSON 格式,其内容指定消息类型和如何处理这些消息。

交换会话描述信息

开始处理信号的时候,用户的初始化操作会创建一个请求(offer) ,根据 SDP 协议其中会包含一个 session 描述符,并且需要把这个发送到我们称之为**接收者(callee)那里, 接受者需要返回一个包含描述符的应答(answer)**信息。我们的服务器使用 WebSocket 来传递 "video-offer" "video-answer" 两种类型的消息数据。这些消息包含以下属性:

到此为止双方都知道使用什么样的代码和参数进行通信了。尽管如此他们仍然不知道自己该如何传递媒体数据。 Interactive Connectivity Establishment (ICE) 协议该上场了。

交换 ICE 候选

两个节点需要交换 ICE 候选来协商他们自己具体如何连接。每一个 ICE 候选描述一个发送者使用的通信方法,每个节点按照他们被发现的顺序发送候选并且保持发送直到退出,即使媒体数据流已经开始传递也要如此。

使用 pc.setLocalDescription(offer) 添加本地描述符后一个 icecandidate 事件将被发送到 RTCPeerConnection

一旦两端同意了一个互相兼容的候选,该候选的 SDP 就被用来创建并打开一个连接,通过该连接媒体流就开始运转。如果之后他们同意了一个更好(通常更高效)的候选,流亦会按需变更格式。

虽然当前并未被支持,一个候选在媒体流已经开始运转之后理论上如果需要的话也可以降级至一个低带宽的连接。

每个 ICE 候选通过信令服务器发送一个 "new-ice-candidate" 类型的 JSON 信息来送给远程的另一端。每个候选信息包括以下字段:

每个 ICE 消息都建议提供一个通信协议(TCP 或 UDP)、IP 地址、端口号、连接类型(例如,指定的 IP 是对等机本身还是中继服务器),以及将两台计算机连接在一起所需的其他信息。这包括 NAT 或其他网络问题。

注意: 最需要注意的是: 你的代码在 ICE 协商期间唯一需要负责的是从 ICE 层接受外向候选并通过与另一端的信号连接发送他们,当你的 onicecandidate 控制器已经执行后, 同时从信令服务器接收 ICE 候选消息 (当接收到 "new-ice-candidate" 消息时) 然后通过调用 RTCPeerConnection.addIceCandidate() 发送他们到你的 ICE 层。 嗯,就是这样。

SDP 的内容基本上在所有情况下都是与你不相关的。在你真正知道自己在做什么之前,不要试图让事情变得更复杂。否则情况会非常混乱。

你的信令服务器现在需要做的就是发送它请求的消息。你的工作流还可能需要登录/身份验证功能,但这些细节都是大同小异的。

信令事务流程

信令过程涉及到使用中间层信令服务器在两个对等机之间交换消息。当然,具体的处理过程会有所不同,但一般来说,处理信令消息的关键点有以下几个:

假设 Naomi 和 Priya 正在使用聊天软件进行讨论,Naomi 决定在两人之间打开一个视频通话。以下是预期的事件顺序:

Diagram of the signaling process

ICE 候选交换过程

当每端的 ICE 层开始发送候选时,它会在链中的各个点之间进行交换,如下所示:

Diagram of ICE candidate exchange process

每一端从本地的 ICE 层接收候选时,都会将其发送给另一方;不存在轮流或成批的候选。一旦两端就一个候选达成一致,双方就都可以用此候选来交换媒体数据,媒体数据就开始流动。即使在媒体数据已经开始流动之后,每一端都会继续向候选发送消息,直到他们没有选择的余地。这样做是为了找到比最初选择的更好的选择。

如果条件发生变化,例如网络连接恶化,一个或两个对等方可能建议切换到较低带宽的媒体分辨率,或其他编解码器。这将触发新的候选交换,之后可能会发生另一种媒体格式和/或编解码器更改。

作为可选项, 查看 RFC 5245: Interactive Connectivity Establishment, section 2.6 ("Concluding ICE") 如果你想更深入地了解这一过程,就要在 ICE 层内部完成。你应该注意到,候选交换后,一旦 ICE 层满足要求,媒体数据就开始流动。所有这些都是在幕后处理端。我们的任务就是简单地通过信令服务器来回发送候选。

客户端应用

任何信号处理的核心是其消息处理。使用 WebSockets 来发送信号并不是必须的,但这是一种常见的解决方案。当然,您应该选择一种机制来交换适合你的应用程序的信号信息。

让我们更新聊天客户端以支持视频呼叫。

更新 HTML

我们客户端的 HTML 需要一个视频显示位置。也就是视频框和挂断电话的按钮:

<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" onclick="hangUpCall();" disabled>
      Hang Up
    </button>
  </div>
</div>

注意这两个 video 元素,一个用于观看自己,一个用于连接,还有 button 元素.

id 为 "received_video" 的 元素将显示从连接的用户接收的视频。我们指定了 autoplay 属性,确保一旦视频到达,它立即播放。这消除了在代码中显式处理回放的任何需要。

local_video 元素显示用户相机的预览;指定 muted 属性,因为我们不需要在此预览面板中听到本地音频。

最后,定义 "hangup-button" 来挂断一个呼叫,并将其配置为禁用启动(将此设置为未连接任何调用时的默认设置),并在单击时调用函数 hangUpCall() 。这个函数的作用是关闭调用,并向另一个对等端发送一个信号服务器通知,请求它也关闭。

JavaScript 代码

我们将把这段代码划分为多个功能区,以便更容易地描述它是如何工作的。该代码的主体位于 connect() 函数中:它在 6503 端口上打开一个 WebSocket 服务器,并建立一个处理程序来接收 JSON 对象格式的消息。此代码通常像以前那样处理文本聊天消息。

向信令服务器发送信息

在整个代码中,我们调用 sendToServer() 以便向信令服务器发送消息。此函数使用 WebSocket 连接执行其工作:

function sendToServer(msg) {
  var msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}

开始通话的交互

处理 "userlist" 消息的代码会调用 handleUserlistMsg()。在这里,我们在聊天面板左侧显示的用户列表中为每个连接的用户设置处理程序。此方法接收一个消息对象,其 users 属性是一个字符串数组,指定每个连接用户的用户名。

function handleUserlistMsg(msg) {
  var i;
  var listElem = document.querySelector(".userlistbox");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach(function(username) {
    var item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite, false);

    listElem.appendChild(item);
  });
}

在获得对 ul 的引用(其中包含变量 listElem 中的用户名列表)后,我们通过删除其每个子元素清空列表。

**注意:**显然,通过添加和删除单个用户而不是每次更改时都重新构建整个列表来更新列表会更有效,但对于本例而言,这已经足够好了。

然后我们使用 forEach() 迭代用户名数组。对于每个名称,我们创建一个新的 li 元素,然后使用 createTextNode() 创建一个包含用户名的新文本节点。该文本节点被添加为 li 元素的子节点。接下来,我们为列表项上的 click 事件设置一个处理程序,单击用户名将调用 invite() 方法,我们将在下一节中查看该方法。

开始一个通话

当用户单击要调用的用户名时,将调用 invite() 函数作为该事件的事件处理程序 click 事件:

var mediaConstraints = {
  audio: true, // We want an audio track
  video: true // ...and we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    var clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert("I'm afraid I can't let you talk to yourself. That would be weird.");
      return;
    }

    targetUsername = clickedUsername;

    createPeerConnection();

    navigator.mediaDevices.getUserMedia(mediaConstraints)
    .then(function(localStream) {
      document.getElementById("local_video").srcObject = localStream;
      myPeerConnection.addStream(localStream);
    })
    .catch(handleGetUserMediaError);
  }
}

这从一个基本的健全性检查开始:用户是否连在一起?如果没有 RTCPeerConnection ,他们显然无法进行呼叫。然后,从事件目标的 textContent 属性中获取单击的用户的名称,并检查以确保尝试启动调用的不是同一个用户。

然后我们将要调用的用户的名称复制到变量 targetUsername 中,并调用 createPeerConnection(),该函数将创建并执行 RTCPeerConnection 的基本配置。

创建 RTCPeerConnection 后,我们通过调用 MediaDevices.getUserMedia(),请求访问用户的相机和麦克风,该命令通过 Navigator.mediaDevices.getUserMedia 属性向我们公开。当成功完成返回的 promise 时,将执行我们的 then 处理程序。它接收一个 MediaStream 对象作为输入,该对象表示来自用户麦克风的音频和来自网络摄像机的视频流。

注意:我们可以通过调用 navigator.mediaDevices.enumerateDevices() 获取设备列表,根据所需条件筛选结果列表,然后使用所选设备deviceId 传入getUserMedia() mediaConstraints 对象的deviceId 字段中的值。事实上,除非必须要不然很少这样用,因为大部分工作都是由 getUserMedia()为你完成的。

我们通过设置元素的 srcObject 属性,将传入流附加到本地预览 video 元素。由于元素被配置为自动播放传入的视频,因此流开始在本地预览框中播放。

然后遍历流中的磁道,调用 addStream() 将每个磁道添加到 RTCPeerConnection。尽管连接尚未完全建立,但必须尽快开始向其发送媒体数据,因为媒体数据将帮助 ICE 层决定采取的最佳连接方式,这有助于协商过程。

一旦媒体数据连接到 RTCPeerConnection,就会在连接处触发事件 negotiationneeded 事件,以便启动 ICE 协商。

如果在尝试获取本地媒体流时发生错误,catch 子句将调用 handleGetUserMediaError(),根据需要向用户显示适当的错误。

处理 getUserMedia() 错误

如果 getUserMedia() 返回的 promise 失败,将执行 handleGetUserMediaError() 函数。

function handleGetUserMediaError(e) {
  switch(e.name) {
    case "NotFoundError":
      alert("Unable to open your call because no camera and/or microphone" +
            "were found.");
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      // Do nothing; this is the same as the user canceling the call.
      break;
    default:
      alert("Error opening your camera and/or microphone: " + e.message);
      break;
  }

  closeVideoCall();
}

除了一条错误信息外,所有情况下都会显示一条错误信息。在本例中,我们忽略 "SecurityError""PermissionDeniedError" 结果,处理拒绝授予使用媒体硬件的权限与用户取消呼叫的方法是相同的。

不管尝试获取流失败的原因是什么,我们调用 closeVideoCall() 函数关闭 RTCPeerConnection,并释放尝试调用过程中已分配的任何资源。此代码旨在安全地处理部分启动的调用。

创建端到端连接

调用方和被调用方都使用 createPeerConnection() 函数来构造它们的 RTCPeerConnection 对象及其各自的 WebRTC 连接端。当调用者试图启动调用时,由 invite() 调用;当被调用者从调用者接收到要约消息时,由 handleVideoOfferMsg() 调用。

function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
    iceServers: [     // Information about ICE servers - Use your own!
      {
        urls: "stun:stun.stunprotocol.org"
      }
    ]
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}

当使用 RTCPeerConnection() 构造函数时,我们将指定一个 RTCConfiguration- 兼容对象,为连接提供配置参数。在这个例子中,我们只使用其中的一个: iceServers。这是描述 ICE 层的 STUN 和/或 TURN 服务器的对象数组,在尝试在呼叫者和被呼叫者之间建立路由时使用。这些服务器用于确定在对等端之间通信时要使用的最佳路由和协议,即使它们位于防火墙后面或使用 NAT

注意:你应该始终使用你拥有的或你有特定授权使用的STUN/TURN服务器。这个例子是使用一个已知的公共服务器,但是滥用这些是不好的。

iceServers 中的每个对象至少包含一个 urls 字段,该字段提供可以访问指定服务器的 URLs。它还可以提供 usernamecredential 值,以便在需要时进行身份验证。

在创建了 RTCPeerConnection 之后,我们为对我们重要的事件设置了处理程序。

前三个事件处理程序是必需的;你必须处理它们才能使用 WebRTC 执行任何涉及流媒体的操作。其余的并不是严格要求的,但可能有用,我们将对此进行探讨。在这个例子中,还有一些其他的事件我们没有使用。下面是我们将要实现的每个事件处理程序的摘要:

开始协商

一旦调用者创建了其 RTCPeerConnection ,创建了媒体流,并将其磁道添加到连接中,如 开始一个通话所示,浏览器将向 RTCPeerConnection 传递一个 negotiationneeded 事件,以指示它已准备好开始与其他对等方协商。以下是我们处理 negotiationneeded 事件的代码:

function handleNegotiationNeededEvent() {
  myPeerConnection.createOffer().then(function(offer) {
    return myPeerConnection.setLocalDescription(offer);
  })
  .then(function() {
    sendToServer({
      name: myUsername,
      target: targetUsername,
      type: "video-offer",
      sdp: myPeerConnection.localDescription
    });
  })
  .catch(reportError);
}

要开始协商过程,我们需要创建一个 SDP 请求并将其发送给我们想要连接的对等端。此请求包括支持的连接配置列表,包括有关我们在本地添加到连接的媒体流(即,我们希望发送到呼叫另一端的视频)的信息,以及 ICE 层已经收集到的任何 ICE 候选。我们通过调用 myPeerConnection.createOffer() 创建此请求。

createOffer() 成功(执行 promise)时,我们将创建的请求信息传递到 myPeerConnection.setLocalDescription() ,它为调用方的连接端配置连接和媒体配置状态。

注意:从技术上讲, createOffer() 返回的字符串是 RFC 3264 请求。

我们知道描述是有效的,并且在满足 setLocalDescription() 返回的 promise 时已经设置好了。也就是说我们创建了一个包含本地描述(现在与请求相同)的新 "video-offer" 消息,然后通过我们的信令服务器将请求发送给被叫方。请求有以下要素:

如果在初始 createOffer() 或后面的任何实现处理程序中发生错误,则通过调用 reportError() 函数报告错误。

setLocalDescription() 的实现处理程序运行后,ICE 代理开始向其发现的每个潜在 RTCPeerConnection 配置发送 icecandidate 事件。我们的 icecandidate 事件处理程序负责将候选对象传输到另一个对等方。

会话协商

既然我们已经开始与另一个对等方进行协商并传输了一个请求,那么让我们来看一下在连接的被呼叫方会发生什么。被调用方接收该请求并调用 handleVideoOfferMsg() 函数来处理它。让我们看看被呼叫方如何处理 "video-offer" 消息。

处理请求

发送 ICE 候选

接收 ICE 候选

接收新的流数据

处理流的移除

结束通话

通话可能结束的原因有很多。一个通话可能已经结束,当一方或双方都挂断了电话。可能发生了网络故障,或者某个用户退出了浏览器,或者发生了系统崩溃。无论如何,一切美好的事物都必须结束。

挂机

结束通话

处理状态变更

还有许多其他事件可以设置监听器,用于通知代码各种状态更改。我们使用三种方法: iceconnectionstatechangeicegatheringstatechange,和 signalingstatechange

ICE 连接状态

ICE 信令状态

ICE 收集状态

Web RTC API

MediaStream 对象

getUserMedia 的 promise 返回值

id [String]: 对当前的 MS 进行唯一标识。所以每次刷新浏览器或是重新获取 MS,id 都会变动。

active [boolean]: 表示当前 MS 是否是活跃状态(就是是否可以播放)。

onactive: 当 active 为 true 时,触发该事件。

getAudioTracks()、getVideoTracks() 来查看获取到的流的某些信息

RTCPeerConnection 构造函数

概述

RTCPeerConnection 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。

全部 API 都支持 promise 语法

创建实例

let PeerConnection = 
    window.RTCPeerConnection ||
    window.mozRTCPeerConnection ||
    window.webkitRTCPeerConnection
let peer = new PeerConnection(iceServers)

我们看见 RTCPeerConnection 也同样接收一个参数 — iceServers,先来看看它长什么样:

{
  iceServers: [
    { url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服务
    {
      url: "turn:***",
      username: ***, // 用户名
      credential: *** // 密码
    }
  ]
}

参数配置了两个 url,分别是 STUN 和 TURN,这便是 WebRTC 实现点对点通信的关键,也是一般 P2P 连接都需要解决的问题:NAT 穿越。

createOffer()

生成一个 offer,它是一个带有特定的配置信息寻找远端匹配机器(peer)的请求。

参数

addStream(mediaSream)

removeStream(mediaStream)

将一个作为本地音频或视频源的媒体流 MediaStream 移除。如果本地端与远端协调已经发生了,那么需要一个新的媒体流,这样远端才可以停止使用它。

close()

关闭一个 RTCPeerConnection 实例所调用的方法。

iceConnectionState

我们可以通过 oniceconnectionstatechange 方法来监测 ICE 连接的状态

peer.oniceconnectionstatechange = (evt) => {
    console.log('ICE connection state change: ' + evt.target.iceConnectionState);
}

它一共有七种状态:

我们需要注意的是 completed 和 disconnected,一个是完成连接时触发,一个在断开连接时触发。

更新中

https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity

文章