基于 Peer.js 的 WebRTC 视频会议实现难点

WebRTC 协议带来的限制

  • 媒体流属性的随机化处理

    • RTC 协议要求接收方在接收到媒体流后复写 media track 上的 id, label, contentHint 等属性以保证流属性不会泄漏发送者的媒体设备信息, 并使流在 P2P 网络中唯一.

      这导致接收者无法区分流中的同类型 track. 例如发送者将相机, 屏幕共享两条视频轨道合在同一条流中发给接受者, 发送者不知道这两条流到接受者处时 Id 分别是什么, 接受者也无法区分哪个是屏幕共享, 哪个是视频流

    • 解决方法: 在传输多个 track 需要分离的时建立多条 P2P 连接 (虽然看起来很蠢)

  • FireFox 禁止两个相同节点之间无法建立多条连接

    • FirFox 禁止两个相同节点做多次媒体协, 也就是说 A 节点最多 call B 节点一次.

    • 解决方案: 如果需要建立两条流 (例如相机和屏幕共享流) 就让 A, B 互相 call 一次

  • 一旦连接建立, 不能添加或者删除 Track

    • 建立 RTC 连接的时候虽然我们传入的是一个流, 但是协议会获取流中的所有 Track, 然后传输这些 Track, 这就意味着协议并不会引用流对象, 我们为流加入或者删除 Track 无法对 RTC 产生影响.

      例如用户目前正在使用前置摄像头, 当用户切换到后置摄像头时, 如果我们删除流中的前置媒体流, 添加后置媒体流, RTC 连接并不会切换轨道, 反而会认为无法获取轨道信息

    • 解决方案: 在建立连接时创建最大可能使用数量的占位流, 在增减设备时使用 RTC API 中的 replaceTrack 方法替换占位流. 例如: 在传输摄像头和麦克风数据时, 我们可能需要采集麦克风, 摄像头, 背景虚化遮罩的 Track, 那么我们就需要使用一个占位 AudioTrack, 两个占位 VideoTrack. 当启动摄像头时候, 将占位 Video Track 切换为实际 Track, 在禁用摄像头时再切换回去

  • RTC 连接状态统计

    RTC 并不支持直接汇总全部 RTC 连接状态, 只支持统计某一个连接的连接状态. 开发者可以通过 RTCPeerConnection.getStats 获取对于一个连接的不同主题的报告. 常见的有:

    • 统计比特率:

      1. 获取当前连接发出或者接收到的总字节数
      2. 求和所有连接的收发的总字节数
      3. 比特率 = 8 * 两次检测时的字节数差 / 两次检测间隔

      获取收发字节数

      • 获取下行字节数: report.type === 'inbound-rtp' 的报告的 report.bytesReceived
      • 获取上行字节数: report.type === 'outbound-rtp' 的报告的 report.bytesReceived

      区分媒体类型

      • 视频: report.mediaType === 'video'
      • 音频: report.mediaType === 'audio'
    • 统计下行丢包率:

      一个连接的累计丢包: report.type === 'inbound-rtp' 报告的 report.packetsLost

      一个连接的从收包: report.type === 'inbound-rtp' 报告的 report.packageReceived

      总下行丢包率: 所有连接的丢包量求和 / (所有连接的丢包量求和 + 所有连接的收包量和)

    • 网络延迟:

      一个连接的延迟: report.currentRoundTripTime (单位: s)

      总延迟: 所有延迟求和 / 连接数量

    • 视频帧率:

      上行连接帧率: report.type === 'outbound-rtp' 报告的 report.framesPerSecond

      下行连接帧率: report.type === 'inbound-rtp' 报告的 report.framesPerSecond

      总帧率: 连接帧率求和 / 连接数

    • 视频分辨率:

      上行连接分辨率: report.type === 'outbound-rtp' 报告的 report.frameWidth, report.frameHeight

      下行连接分辨率: report.type === 'inbound-rtp' 报告的 report.frameWidth, report.frameHeight

    实现可以参考 https://github.com/KairuiLiu/conflux-client/blob/master/src/utils/report.ts

  • 用来调试的 RTC 连接状态报告: chrome://webrtc-internals/

Peer.js 库的限制

peer.js 项目目前并不是特别活跃, 挤压了很多 issue. 可以考虑切换到 simple-peer

  • 建立必须使用 avtive 流完成 call()answer() #323

    • 通过 call() 建立连接时必须提供一条状态为 avtive 的流, 否则默认失效.

      通过 answer() 响应连接时必须提供一条状态为 active 的流, 否则发起方无法进入 on(stream) 事件

    • 合理性: WebRTC 协议限制不可为流增删 track, 因此如果你传入一个空流, 那么这个连接将永远为空

    • 不合理性: 如果 caller 确实没有媒体信息就无法发起连接, 即使他只是想单向获取对方的媒体流

    • 解决方法: 构建一条空的 active 流用来占位,

      export const createEmptyAudioTrack = (): MediaStreamTrack => {
        const ctx = new AudioContext();
        const oscillator = ctx.createOscillator();
        const destination = ctx.createMediaStreamDestination();
        oscillator.connect(destination);
        oscillator.start();
        const track = destination.stream.getAudioTracks()[0];
        return Object.assign(track, { enabled: false }) as MediaStreamTrack;
      };
      
      export const createEmptyVideoTrack = (): MediaStreamTrack => {
        const canvas = Object.assign(document.createElement('canvas'), {
          width: 1,
          height: 1,
        });
        canvas.getContext('2d')!.fillRect(0, 0, 1, 1);
        const stream = canvas.captureStream();
        const track = stream.getVideoTracks()[0];
        return Object.assign(track, { enabled: false }) as MediaStreamTrack;
      };
      
      fakeVidioTrack = createEmptyVideoTrack();
      fakeAudioTrack = createEmptyAudioTrack();
      mediaStream = new MediaStream([audioTrack, videoTrack]);
  • 只有 call() 方法可以传输 metadata, answer() 方法不可以

    • call() 的时候可以在第二参数中加入 {meatadata: any} 向接受者发一些上下文, 但是接受者无法通过 answer() 返回上下文
    • 解决方法: 建立专门的数据流传输数据. 同时注意数据流连接与媒体流连接支持的方法不同, 数据流连接在发送数据前不仅要检查流 open, 还要检查流 connected (因为数据流是先建立, 然后多次互换数据, 而媒体流是在发送数据 call 的时候建立连接)

媒体流的处理与兼容性问题

  • 媒体权限的获取与浏览器兼容性问题

    在首次 navigator.mediaDevices.getUserMedia() 浏览器会提示用户授权. 但是这会导致设备列表只有在获取用户设备时才刷新. 不方便 UI 展示. 建议采用如下方式:

    • 判断是否授权:

      • 现代化的方法: 使用 navigator.permissions

        const permissionName = name === 'microphone' // 或者 'camera';
        navigator.permissions
              .query({ name: permissionName as unknown as PermissionName })
              .then((permissionStatus) => permissionStatus.state === 'granted')

        但是该 API 存在兼容性问题, 只有 Chrome 与 Safari 支持 microphonecamera 这两个 permissionName, FireFox 会报错

      • 兜底方法: 检查 navigator.mediaDevices.enumerateDevices() 中某类 type 是否没有或者 Label 为空. 如果是则证明要么用户没有这类设备, 要么没有授权. 该函数不会让浏览器弹出授权

      • 总结:

        function checkPermission(name: 'audio' | 'video') {
          const permissionName = name === 'audio' ? 'microphone' : 'camera';
          try {
            return navigator.permissions
              .query({ name: permissionName as unknown as PermissionName })
              .then((permissionStatus) => permissionStatus.state === 'granted')
              .catch(() => {
                return navigator.mediaDevices
                  .enumerateDevices()
                  .then((devices) =>
                    devices.some(
                      (device) => device.kind === `${name}input` && device.label !== ''
                    )
                  );
              });
          } catch (_) {
            return navigator.mediaDevices
              .enumerateDevices()
              .then((devices) =>
                devices.some(
                  (device) => device.kind === `${name}input` && device.label !== ''
                )
              );
          }
        }

    • 申请授权:

      function requestPermission(name: 'audio' | 'video') {
        return navigator.mediaDevices
          .getUserMedia({ video: name === 'video', audio: name === 'audio' })
          .then((stream) => {
            stream.getTracks().forEach((track) => track.stop());
          })
          .catch(() => {});

    • 监控授权状态变化

      try {
        navigator.permissions
          .query({ name: 'camera' as unknown as PermissionName })
          .then((permissionStatus) => {
            permissionStatus.onchange = () => {
              refreshMediaDevice('video', setState);
            };
          })
          .catch(() => {});
        navigator.permissions
          .query({ name: 'microphone' as unknown as PermissionName })
          .then((permissionStatus) => {
            permissionStatus.onchange = () => {
              refreshMediaDevice('audio', setState);
            };
          })
          .catch(() => {});
      } catch (error) {
        console.info('[ERROR] on getting permission ', error);
      }
    • 监控设备变化

      navigator.mediaDevices.addEventListener('devicechange', cb)
  • 指定音频输出设备

    注意: 仅桌面 Chrome 与 FireFox 浏览器支持支持, TS 也没有这个方法

    // @ts-ignore
    audioElement?.setSinkId?.(speaker.deviceId);
  • 指定音视频采集设备

    const constraints: MediaStreamConstraints = {
      [type]: { deviceId: { exact: deviceId } },
    };
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
  • 音频轨道音量统计

    注意: 音量取值是 \([0,1]\), 当麦克风离得比较远时采集的音量一直很小, 进度条展示出来一直处于低点. 可以采用 softmax 对结果做变化, 让用户可以明显看到音量变化

    注意: AudioContext 并不能指定音频播放设备, 因此不能用它来顺便播放音频.

    原理:

    • 创建 AnalyserNode 用于实时获取音频信号的时间域和频率域数据 (默认使用 FFT 采样)
    • 使用 getByteTimeDomainData 获取最近的时域数据, 值代表振幅值 (取值 0-256, 128 为静音)
    • 每个样本值减去 128, 使得波形数据中心化到 0 (静音为 0)
    • 将每个去中心化后的值求平方, 得到每个样本的能量, 计算所有样本能量的平均值
    • 计算平均能量的平方根, 得到均方根值, (当音量最大时, 均方根为 255)

    实现:

    import React, { useEffect, useRef, useState } from 'react';
    import ProgressBar from '../progress';
    
    const SpeakerVolume: React.FC<{
      element?: HTMLAudioElement;
      relitive?: boolean;
    }> = ({ element, relitive = false }) => {
      const [volume, setVolume] = useState(0);
      // fucking react re-render on dev mode
      const animationFrameRef = useRef<number>();
      const audioContextRef = useRef<AudioContext>();
      const mediaStreamAudioSourceNodeRef = useRef<MediaElementAudioSourceNode>();
      const analyserNodeRef = useRef<AnalyserNode>();
      const linked = useRef(false);
      const maxVolumeRef = useRef(0);
    
      useEffect(() => {
        if (!element || linked.current) return;
        maxVolumeRef.current = 0;
        linked.current = true;
        audioContextRef.current = new AudioContext();
        mediaStreamAudioSourceNodeRef.current =
          audioContextRef.current.createMediaElementSource(element);
        analyserNodeRef.current = audioContextRef.current.createAnalyser();
        mediaStreamAudioSourceNodeRef.current.connect(analyserNodeRef.current);
        const dataArray = new Uint8Array(analyserNodeRef.current.frequencyBinCount);
    
        const onFrame = () => {
          if (analyserNodeRef.current) {
            analyserNodeRef.current.getByteTimeDomainData(dataArray);
            let sum = 0;
            for (let i = 0; i < dataArray.length; i++) {
              sum += (dataArray[i] - 128) * (dataArray[i] - 128);
            }
            const realVolume = Math.sqrt(sum / dataArray.length) / 255;
            maxVolumeRef.current = Math.max(maxVolumeRef.current, realVolume);
            if (relitive) setVolume(realVolume / maxVolumeRef.current);
            else setVolume(realVolume);
          }
          animationFrameRef.current = window.requestAnimationFrame(onFrame);
        };
        window.requestAnimationFrame(onFrame);
    
        return () => {
          mediaStreamAudioSourceNodeRef.current?.disconnect?.();
          analyserNodeRef.current?.disconnect?.();
          cancelAnimationFrame(animationFrameRef.current!);
          linked.current = false;
          setVolume(0);
        };
      }, [element]);
    
      return <ProgressBar progress={volume} />;
    };
    
    export default SpeakerVolume;