基于 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
获取对于一个连接的不同主题的报告. 常见的有:统计比特率:
- 获取当前连接发出或者接收到的总字节数
- 求和所有连接的收发的总字节数
- 比特率 =
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 支持
microphone
与camera
这两个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;
- 创建