Firefox 38 中的 WebRTC:多流和重新协商

编者注:自这篇文章在 2013 年发布以来,发生了很多变化……WebRTC 现在已在所有主流浏览器中广泛使用,但其 API 却略有不同。作为 Web 标准化过程的一部分,我们看到了改进,例如更精细的媒体控制(通过轨道而不是流)。查看此 MDN 上的简单 RTCDataChannel 示例,以获取更现代的示例。


基于 37 版中引入的 JSEP(JavaScript 会话建立协议)引擎重写,Firefox 38 版现在支持多流(单个 PeerConnection 中的相同类型的多个轨道)和重新协商(单个 PeerConnection 中的多个 offer/answer 交换)。与这类事情一样,存在一些注意事项和限制,但功能似乎非常稳定。

多流和重新协商功能

您可能会问,这些东西为什么有用?例如,现在您可以使用单个 PeerConnection(多流)处理群组视频通话,并在运行时添加/删除这些流(重新协商)。您还可以在现有视频通话中添加屏幕共享,而无需单独的 PeerConnection。以下是此新功能的一些优势

  • 简化您作为应用程序编写者的工作
  • 需要更少的 ICE(交互式连接建立 - 在浏览器之间建立连接的协议)轮次,并减少通话建立时间
  • 需要的端口更少,包括浏览器和 TURN 中继(如果使用捆绑,默认情况下已启用)上的端口

现在,很少有 WebRTC 服务使用多流(按照当前的规范,见下文)或重新协商。这意味着这些功能的真实世界测试极其有限,而且很可能存在 bug。如果您正在使用这些功能,并且遇到了困难,请不要犹豫,在 IRC 上向 #media 频道(irc.mozilla.org)提出问题,因为这有助于我们找到这些 bug。

此外,重要的是要注意,Google Chrome 目前对多流的实现将无法互操作;这是因为 Chrome 尚未实现多流规范(称为“统一计划” - 请查看他们在 Google Chromium Bug 跟踪器 中的进展)。相反,他们仍在使用较旧的 Google 建议(称为“计划 B”)。这两种方法相互不兼容。

相关的是,如果您维护或使用支持多流的 WebRTC 网关,那么它很有可能也使用“计划 B”,并且需要更新。现在是开始实施统一计划支持的好时机。(查看下面的附录以获取示例。)

构建一个简单的 WebRTC 视频通话页面

因此,让我们从一个具体的示例开始。我们将构建一个简单的 WebRTC 视频通话页面,允许用户在通话过程中添加屏幕共享。由于我们将快速深入探讨,您可能需要查看我们之前在 Hacks 上的文章 WebRTC 和早期 API,以了解基础知识。

首先,我们需要两个 PeerConnection

pc1 = new mozRTCPeerConnection();
pc2 = new mozRTCPeerConnection();

然后,我们请求访问摄像头和麦克风,并将得到的流附加到第一个 PeerConnection

let videoConstraints = {audio: true, video: true};
navigator.mediaDevices.getUserMedia(videoConstraints)
  .then(stream1) {
    pc1.addStream(stream1);
  });

为了简单起见,我们希望能够在一台机器上运行通话。但是,如今大多数计算机都没有两个摄像头和/或麦克风。仅仅进行单向通话并不令人兴奋。因此,让我们对另一个方向使用 Firefox 的内置测试功能

let fakeVideoConstraints = {video: true, fake: true };
navigator.mediaDevices.getUserMedia(fakeVideoConstraints)
  .then(stream2) {
    pc2.addStream(stream2);
  });

注意:您需要从第一个getUserMedia()调用的成功回调中调用这部分代码,这样您就不必使用布尔标志来跟踪两个getUserMedia()调用是否成功,然后再继续下一步。
Firefox 还具有内置的假音频源(您可以像这样打开它{audio: true, fake: true})。但听 8kHz 音调不如观看假视频源的不断变化的颜色那么令人愉快。

现在,我们已经准备好所有部件来创建初始 offer

pc1.createOffer().then(step1, failed);

现在,WebRTC 典型的 offer-answer 流程如下

function step1(offer) {
  pc1_offer = offer;
  pc1.setLocalDescription(offer).then(step2, failed);
}

function step2() {
  pc2.setRemoteDescription(pc1_offer).then(step3, failed);
}

对于此示例,我们采取了快捷方式:我们不是将信令消息通过实际的信令中继传递,而是简单地将信息传递到两个 PeerConnection 中,因为它们都在同一页面上本地可用。有关实际使用 FireBase 作为中继来连接两个浏览器的解决方案,请参阅我们之前在 Hacks 上的文章 WebRTC 和早期 API

function step3() {
  pc2.createAnswer().then(step4, failed);
}

function step4(answer) {
  pc2_answer = answer;
  pc2.setLocalDescription(answer).then(step5, failed);
}

function step5() {
  pc1.setRemoteDescription(pc2_answer).then(step6, failed);
}

function step6() {
  log("Signaling is done");
}

剩下的一块是连接远程视频,一旦我们收到它们。

pc1.onaddstream = function(obj) {
  pc1video.mozSrcObject = obj.stream;
}

为我们的 PeerConnection 2 添加类似的克隆。请记住,这些回调函数非常简单 - 它们假设我们只接收一个流,并且只有一个视频播放器可以连接它。一旦我们添加屏幕共享,示例将变得更加复杂。

有了这个,我们应该能够建立一个简单的通话,包括来自真实设备的音频和视频,从 PeerConnection 1 发送到 PeerConnection 2,反之亦然,一个显示缓慢变化颜色的假视频流。

实施屏幕共享

现在,让我们进入正题,将屏幕共享添加到已建立的通话中。

function screenShare() {
  let screenConstraints = {video: {mediaSource: "screen"}};

  navigator.mediaDevices.getUserMedia(screenConstraints)
    .then(stream) {
      stream.getTracks().forEach(track) {
        screenStream = stream;
        screenSenders.push(pc1.addTrack(track, stream));
      });
    });
}

要使屏幕共享正常工作,需要两件事

  1. 只有通过 HTTPS 加载的页面才允许请求屏幕共享。
  2. 您需要将您的域名附加到about:config 中的用户首选项media.getusermedia.screensharing.allowed_domains,以将其列入屏幕共享的白名单。

对于screenConstraints,您也可以使用“window”或“application”来代替“screen”,如果您只想共享整个屏幕的一部分。
我们在这里使用getTracks()从 getUserMedia 调用得到的流中获取和存储视频轨道,因为我们稍后需要记住该轨道,以便能够从通话中删除屏幕共享。或者,在这种情况下,您可以使用之前使用的addStream()函数将新流添加到 PeerConnection。但如果您想要以不同的方式处理视频和音频轨道(例如),addTrack()函数会给您提供更大的灵活性。在这种情况下,您可以通过getAudioTracks()getVideoTracks()函数分别获取这些轨道,而不是使用getTracks()函数。

将流或轨道添加到已建立的 PeerConnection 后,需要将此信号发送到连接的另一端。为此,将调用onnegotiationneeded回调。因此,您应该在添加轨道或流之前设置回调。这里的美妙之处在于 - 从这一点开始,我们可以简单地重复使用我们的信令调用链。因此,生成的屏幕共享功能如下所示

function screenShare() {
  let screenConstraints = {video: {mediaSource: "screen"}};

  pc1.onnegotiationneeded = function (event) {
    pc1.createOffer(step1, failed);
  };

  navigator.mediaDevices.getUserMedia(screenConstraints)
    .then(stream) {
      stream.getTracks().forEach(track) {
        screenStream = stream;
        screenSenders.push(pc1.addTrack(track, stream));
      });
    });
}

现在,接收端也需要知道屏幕共享的流是否成功建立。为此,我们需要稍微修改我们最初的onaddstream函数

pc2.onaddstream = function(obj) {
  var stream = obj.stream;
  if (stream.getAudioTracks().length == 0) {
    pc3video.mozSrcObject = obj.stream;
  } else {
    pc2video.mozSrcObject = obj.stream;
  }
}

这里需要特别注意的是:使用多流和重新协商,onaddstream可能会被多次调用。在我们的小示例中,onaddstream在第一次建立连接时被调用,并且 PeerConnection 2 开始接收来自真实设备的音频和视频。然后,在添加来自屏幕共享的视频流时,它会被再次调用。
我们在这里只是假设屏幕共享中没有音频轨道,以区分这两种情况。可能还有更简洁的方法。

请参阅附录,以更详细地了解这里发生的事情。

由于用户可能不想在通话结束之前共享其屏幕,因此让我们添加一个函数来删除它。

function stopScreenShare() {
  screenStream.stop();
  screenSenders.forEach(sender) {
    pc1.removeTrack(sender);
  });
}

我们保留了对原始流的引用,以便能够对其调用stop()来释放我们从用户那里获得的 getUserMedia 权限。我们screenShare()函数中的addTrack()调用返回了一个 RTCRtpSender 对象,我们将其存储起来,以便可以将其传递给removeTrack()函数。

所有代码,连同一些额外的语法糖,都可以在我们的 MultiStream 测试页面 上找到。

如果您要构建一个允许通话双方添加屏幕共享的东西,这是一个比我们的演示更现实的场景,您将需要处理特殊情况。例如,多个用户可能会意外地尝试同时添加另一个流(例如屏幕共享),您可能会遇到一个新的重新协商的边缘情况,称为“眩光”。这是当 WebRTC 会话的两端同时决定发送新的 offer 时发生的事情。我们目前还不支持“回滚”会话描述类型,该类型可用于从眩光中恢复(请参阅 Jsep 草案Firefox bug)。防止眩光的最佳临时解决方案可能是通过您的信令通道宣布用户进行了将引发另一轮重新协商的操作。然后,在本地调用createOffer()之前,等待远端的确认。

附录

这是 Firefox 39 在添加屏幕共享时,重新协商 offer SDP 的示例

v=0
o=mozilla...THIS_IS_SDPARTA-39.0a1 7832380118043521940 1 IN IP4 0.0.0.0
s=-
t=0 0
a=fingerprint:sha-256 4B:31:DA:18:68:AA:76:A9:C9:A7:45:4D:3A:B3:61:E9:A9:5F:DE:63:3A:98:7C:E5:34:E4:A5:B6:95:C6:F2:E1
a=group:BUNDLE sdparta_0 sdparta_1 sdparta_2
a=ice-options:trickle
a=msid-semantic:WMS *
m=audio 9 RTP/SAVPF 109 9 0 8
c=IN IP4 0.0.0.0
a=candidate:0 1 UDP 2130379007 10.252.26.177 62583 typ host
a=candidate:1 1 UDP 1694236671 63.245.221.32 54687 typ srflx raddr 10.252.26.177 rport 62583
a=sendrecv
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=ice-pwd:3aefa1a552633717497bdff7158dd4a1
a=ice-ufrag:730b2351
a=mid:sdparta_0
a=msid:{d57d3917-64e9-4f49-adfb-b049d165c312} {920e9ffc-728e-0d40-a1b9-ebd0025c860a}
a=rtcp-mux
a=rtpmap:109 opus/48000/2
a=rtpmap:9 G722/8000/1
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=setup:actpass
a=ssrc:323910839 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}
m=video 9 RTP/SAVPF 120
c=IN IP4 0.0.0.0
a=candidate:0 1 UDP 2130379007 10.252.26.177 62583 typ host
a=candidate:1 1 UDP 1694236671 63.245.221.32 54687 typ srflx raddr 10.252.26.177 rport 62583
a=sendrecv
a=fmtp:120 max-fs=12288;max-fr=60
a=ice-pwd:3aefa1a552633717497bdff7158dd4a1
a=ice-ufrag:730b2351
a=mid:sdparta_1
a=msid:{d57d3917-64e9-4f49-adfb-b049d165c312} {35eeb34f-f89c-3946-8e5e-2d5abd38c5a5}
a=rtcp-fb:120 nack
a=rtcp-fb:120 nack pli
a=rtcp-fb:120 ccm fir
a=rtcp-mux
a=rtpmap:120 VP8/90000
a=setup:actpass
a=ssrc:2917595157 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}
m=video 9 RTP/SAVPF 120
c=IN IP4 0.0.0.0
a=sendrecv
a=fmtp:120 max-fs=12288;max-fr=60
a=ice-pwd:3aefa1a552633717497bdff7158dd4a1
a=ice-ufrag:730b2351
a=mid:sdparta_2
a=msid:{3a2bfe17-c65d-364a-af14-415d90bb9f52} {aa7a4ca4-189b-504a-9748-5c22bc7a6c4f}
a=rtcp-fb:120 nack
a=rtcp-fb:120 nack pli
a=rtcp-fb:120 ccm fir
a=rtcp-mux
a=rtpmap:120 VP8/90000
a=setup:actpass
a=ssrc:2325911938 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}

请注意,每个轨道都有自己的 m-section,由 msid 属性标识。

正如您从 BUNDLE 属性中看到的,Firefox 提供将新的视频流(具有不同的 msid 值)放入同一个捆绑传输中。这意味着,如果回答者同意,我们可以开始通过已经建立的传输发送视频流。我们不必再进行 ICE 和 DTLS 轮次。在 TURN 服务器的情况下,我们还可以节省另一个中继资源。

假设,如果使用方案 B(Chrome 使用的方式),之前的报价看起来是这样的

v=0
o=mozilla...THIS_IS_SDPARTA-39.0a1 7832380118043521940 1 IN IP4 0.0.0.0
s=-
t=0 0
a=fingerprint:sha-256 4B:31:DA:18:68:AA:76:A9:C9:A7:45:4D:3A:B3:61:E9:A9:5F:DE:63:3A:98:7C:E5:34:E4:A5:B6:95:C6:F2:E1
a=group:BUNDLE sdparta_0 sdparta_1
a=ice-options:trickle
a=msid-semantic:WMS *
m=audio 9 RTP/SAVPF 109 9 0 8
c=IN IP4 0.0.0.0
a=candidate:0 1 UDP 2130379007 10.252.26.177 62583 typ host
a=candidate:1 1 UDP 1694236671 63.245.221.32 54687 typ srflx raddr 10.252.26.177 rport 62583
a=sendrecv
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=ice-pwd:3aefa1a552633717497bdff7158dd4a1
a=ice-ufrag:730b2351
a=mid:sdparta_0
a=rtcp-mux
a=rtpmap:109 opus/48000/2
a=rtpmap:9 G722/8000/1
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=setup:actpass
a=ssrc:323910839 msid:{d57d3917-64e9-4f49-adfb-b049d165c312} {920e9ffc-728e-0d40-a1b9-ebd0025c860a}
a=ssrc:323910839 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}
m=video 9 RTP/SAVPF 120
c=IN IP4 0.0.0.0
a=candidate:0 1 UDP 2130379007 10.252.26.177 62583 typ host
a=candidate:1 1 UDP 1694236671 63.245.221.32 54687 typ srflx raddr 10.252.26.177 rport 62583
a=sendrecv
a=fmtp:120 max-fs=12288;max-fr=60
a=ice-pwd:3aefa1a552633717497bdff7158dd4a1
a=ice-ufrag:730b2351
a=mid:sdparta_1
a=rtcp-fb:120 nack
a=rtcp-fb:120 nack pli
a=rtcp-fb:120 ccm fir
a=rtcp-mux
a=rtpmap:120 VP8/90000
a=setup:actpass
a=ssrc:2917595157 msid:{d57d3917-64e9-4f49-adfb-b049d165c312} {35eeb34f-f89c-3946-8e5e-2d5abd38c5a5}
a=ssrc:2917595157 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}
a=ssrc:2325911938 msid:{3a2bfe17-c65d-364a-af14-415d90bb9f52} {aa7a4ca4-189b-504a-9748-5c22bc7a6c4f}
a=ssrc:2325911938 cname:{72b9ff9f-4d8a-5244-b19a-bd9b47251770}

请注意,只有一个视频 m-section,具有两个不同的 msid,它们是 ssrc 属性的一部分,而不是在它们自己的 a 行中(这些被称为“源级”属性)。

关于 Nils Ohlmeier

自 2002 年以来一直在研究实时通信

更多由 Nils Ohlmeier 撰写的文章…

关于 Byron Campen

更多由 Byron Campen 撰写的文章…


4 条评论

  1. Matthew Fredrickson

    恭喜你们完成了这项工作!我知道这是一项艰巨的任务。期待着弄清楚方案 B 与统一方案之间的区别。

    Matthew Fredrickson

    2015 年 3 月 25 日 下午 08:35

  2. Bruce Williams

    不错的文章,谢谢!

    顺便问一下,下面是 Mozilla JS 的特殊语法,还是打错了?

    stream.getTracks().forEach(track) {

    而不是

    stream.getTracks().forEach(function (track) {

    2015 年 4 月 6 日 下午 13:25

    1. Nils Ohlmeier

      说实话,WordPress 的两个编辑让我很难在代码示例中保持“=>”正确。正如您在演示页面的源代码中看到的,它应该是
      stream.getTracks().forEach(track => {

      2015 年 4 月 6 日 下午 15:18

      1. Bruce Williams

        当然!反复出现的“代码在安全过滤删除某些字符后看起来很奇怪”问题。

        2015 年 4 月 7 日 上午 08:57

本文的评论已关闭。