基础逻辑图
前置知识
WebRTC 处理过程
WebRTC 1 对 1 音视频实时通话过程示意图
- 图解
- WebRTC 终端:负责音视频采集、编解码、NAT穿越、音视频数据传输
- Signal 信令服务器:负责信令处理、如加入房间、离开房间、媒体协商消息的传递
- STUN/TURN服务器:负责获取WebRTC终端在公网的IP地址,以及NAT穿越失败后的数据中转
WebRTC 进行通话的大体过程
- 音视频采集
- 进入房间前,先进行检测设备可用性,当可用时则进行音视频数据的采集
- 采集后的处理
- 一方面:采集的数据会进行用作预览,即自己可以看到画面
- 另一方面:可以将其录制下来保存为文件、等视频通话结束后,上传到服务器后可以让用户回看之前的内容
- 获取数据就绪后 - 房间的创建和加入
- WebRTC终端向信令服务器发送「加入」的信令,Signal服务器收到消息后会进行创建房间的操作 - 推流端
- 另一端也会做差不多一样的事,不同点在于不是创建房间,而是加入房间 - 观看端
- 第二个终端(观看端)成功加入房间后,第一个用户(推流端)会收到「另一个用户已成功加入」的消息
- 获取数据就绪后 - 音视频数据传递
- 第一个终端(推流端)会创建“媒体连接”对象,即RTCPeerConnection,然后将采集到的音视频数据通过RTCPeerConnection对象进行编码,最终通过P2P传送给对方;
- P2P穿越失败后:为保障音视频数据正常,需要通过TURN服务器进行音视频数据中转
- 第二端(观看端)接收到数据后,会先将收到的数据进行解码,最后将其展示出来,这样就实现了一对一的单通
- 实现互通的方式:双方都需要通过RTCPeerConnection对象传输自己端的数据,然后另一端进行接收
- 拓展
- 音视频数据的相关约束
- advanced属性用于增加约束复杂度
- const constraints = {
width: {min: 640, ideal: 1280},
height: {min: 480, ideal: 720},
advanced: [
{width: 1920, height: 1280},
{aspectRatio: 4/3}
]
}; - advanced列表包含了两个约束集,第一个指定了width和height,第二个指定了aspectRatio。它表达的含义是“视频分辨率应该至少为640像素×480像素,能够达到1920像素×1280像素最好,如果达不到,就满足4/3的宽高比,如果还不能满足,就使用一个最接近1280像素×720像素的分辨率。
- 在进行音视频采集的时候,声音不正常(回声很响)可以配置参数进行
- 消除回音
- ,也可以在video标签上添加
- muted
- 进行解决
- echoCancellation: true, // 开启回音消除
- <video autoplay playsinline muted></video> //playsinline,表示在 HTML5 页面内播放视频,而不是使用系统播放器播放视频
- freeswitch或mcu可以做混音消除,在直播中freeswitch一般用来做服务器混音
- 信令的控制(如传输控制信息等)要通过专门的信令通道(如HTTP/HTTPS、WS/WSS)与信令服务器交互,dataStrem主要用来传输二进制的数据
- rtmp底层使用了TCP,WebRTC底层使用UDP,因此,在极端网络情况下没法实时通信
C++音视频学习资料免费获取方法:关注音视频开发T哥,点击「链接」即可免费获取2023年最新C++音视频开发进阶独家免费学习大礼包!
媒体流
媒体流:是信息的载体,代表了一个媒体设备的内容流(MediaStream),媒体流可以被采集、传输和播放,通常一个媒体流包含了多个媒体轨道,如音频轨、视频轨道; 媒体流由媒体轨道构成,而媒体轨道代表着一个可以提供媒体服务的媒体,如音频、视频等
构建媒体流
构造函数MediaStream()可以创建并返回一个新的MediaStream对象,可以创建一个空的媒体流或者复制现有的媒体流,也可以创建包含多个指定轨道的媒体流
- 创建一个空的媒体流
- let newStream = new MediaStream()
- 从Stream中复制媒体流
- let newStream = new MediaStream(stream)
- 创建包含多个指定轨道的媒体流
- let newStream = new MediaStream(tracks[])
MediaStream重要作用
- 可以作为录制或渲染的源
- 这样可以将stream中的内容录制成文件或者将stream中的数据通过浏览器的<video>进行渲染出来,
- 同一个MediaStream中的MediaStreamTrack数据会进行同步,不同MediaStream中的MediaStreamTrack之间不进行时间同步
- 如同一个MediaStream中的音频轨和视频轨会进行时间同步
MediaStream属性
- active只读
- 返回MediaStream的状态,类型为布尔,true表示处于活跃状态,false表示不活跃状态
- id只读
- 返回MediaStream的UUID,类型为字符串,长度为36个字符
MediaStream方法
MediaStream称为流,可以包括0个或多个MediaStreamTrack; MediaStreamTrack称为轨,表示单一类型的媒体源,如从摄像头采集到的视频数据就是一个MediaStreamTrack,从麦克风采集的音频数据又是另一个MediaStreamTrack
- addTrack()方法
- 向媒体流中加入新的媒体轨道
- stream.addTrack(track)
- 参数:Track媒体轨道,类型为MediaStreamTrack
- 返回值:无
- clone()
- 返回当前媒体流的副本(新的媒体流对象),副本具有不同且唯一的标识
- const newStream = stream.clone()
// sameId为false
const sameId = newStream.id === stream.id ? false:true - getAudioTracks()/getVideoTracks()
- 返回媒体种类为audio/video的媒体轨道对象数组,数组成员类型为MediaStreamTrack
- 数组的顺序是不确定的,每次调用都可能不同
- 返回值:媒体轨道对象数组
- const mediaStreamTracks = mediaStream.getAudioTracks()
- getTrackById()
- 返回指定ID的轨道对象,当未提供参数或未匹配到则返回null,当存在多个相同ID的轨道,返回匹配到的第一个轨道
- const track = MediaStream.getTrackById(id)
- 返回值:返回匹配的ID的MediaStreamTrack对象或者null
- 示例:stream.getTrackById("primary-audio-track").applyConstraints({ volume: 0.8 })
- getTracks()
- 返回所有媒体轨道对象数组,包括所有视频和音频轨道,顺序不确定
- const mediaStreamTracks = mediaStream.getTracks()
MediaStream事件
- addtrack事件
- 有新的媒体轨道加入时触发该事件,对应的事件句柄onaddtrack
- 触发条件
- RTCPeerConnection重新协商
- HTMLMediaElement.captureStream()返回新的媒体轨道
- removetrack事件
- 当有媒体轨道被移除时触发该事件,对应事假句柄onremovetrack
- 触发条件
- RTCPeerConnection重新协商
- HTMLMediaElement.captureStream()返回新的媒体轨道
- 也可以使用addEventListener()方法监听事件removetrack
音视频采集 - 架构图位置 → 音视频采集
常用API解析
- getUserMedia 方法
- 调用该API后即可在浏览器中访问音视频设备
- 基本格式为var promise = navigator.mediaDevices.getUserMedia(constraints);
- constraints,控制采集数据的相关属性,由三部分组成,视频相关属性、音频相关属性和设备相关属性
- 视频属性:分辨率、视频宽高比、帧率、前置/后置摄像头、视频缩放
- 音频属性:采样率、采样大小、是否开启回声消除、是否开启自动增益、是否开启降噪、目标延迟、声道数;
- 设备相关:设备ID、设备组ID
- 返回数据为一个
- Promise
- 对象
- getUserMedia调用成功后,可以通过Promise获得MediaStream对象,即可以从对应设备中获取到对应数据了
- getUserMedia调用失败后(如用户拒绝访问媒体设备或要访问的媒体设备不可用),则返回的Promise会得到PermissionDeniedError或NotFoundError等错误信息
- MediaDeviceInfo
- 表示每个输入/输出设备的信息,主要信息包括
- deviceID:设备的唯一标识
- label:设备名称
- 要想授予访问媒体设备的权限,需要使用HTTPS请求,不然label字段始终为空
- 也可以用作指纹识别机制的一部分,用来识别是否是合法用户
- kind:设备种类,用来区分是音频设备还是视频设备,是输出设备还是输入设备
- MediaDevices
- 该API提供了访问媒体设备以及截取屏幕的方法,它允许你访问任何硬件媒体设备
- 常用来获取可用的音视频列表
- MediaStreamConstraints
- 在getUserMedia方法中,入参constraints,其类型为MediaStreamConstraints,它可以指定MediaStream中包含哪些媒体轨(音视频轨),并且可以为这些媒体轨设置一些限制
- 示例配置
- // 示例一 同时采集音频和视频
const mediaStreamContrains = {
video: true,
audio: true,
};
//示例二 对每一条轨进行限制
const mediaStreamContrains = {
video: {
frameRate: { min: 20 }, // 帧率最小20 - 观看的流畅度
width: { min: 640, ideal: 1280 }, // 宽度最小640 理想1280
height: { min: 360, ideal: 720 },
aspectRatio: 16 / 9, // 宽高比16:9
},
audio: {
echoCancellation: true, // 开启回音消除
noiseSuppression: true, // 开启降噪
autoGainControl: true, // 自动增益功能
// latency 延迟大小,
// channalCount 声道数,
},
};
// 示例三 设置视频采集摄像头的前后
const mediaStreamContrains = {
video: {
facingMode: 'user', // 前置摄像头
// facingMode: 'environment' // 后置摄像头
// facingMode: 'left' // 前置左摄像头
// facingMode: 'right' // 前置右摄像头
},
audio: true,
};
简单采集案例
<!DOCTYPE html>
<html>
<head>
<title> WebRTC</title>
</head>
<body>
<h1>WebRTC </h1>
<video autoplay playsinline></video>
<!-- playsinline,表示在 HTML5 页面内播放视频,而不是使用系统播放器播放视频。 -->
<script src="./client.js"></script>
</body>
</html>
//client.js
const mediaStreamContains = {
video: true,
};
const localVideo = document.querySelector("video");
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
}
function handleLocalMediaStreamError(error) {
console.log("navigator.getUserMedia error: ", error);
}
navigator.mediaDevices
.getUserMedia(mediaStreamContains)
.then(gotLocalMediaStream)
.catch(handleLocalMediaStreamError);
音视频设备检测 - 架构图位置 → 音视频设备检测
音频约束
- volume:音量大小,数值是从0到1,0是禁音
- 自动增益:是指在原有录制的声音基础上是否给他增加这个音量 视频约束
- frameRate帧率:可以通过帧率来控制码流,帧率越大,码流就越大,视频越平滑
音视频设备的基本原理
音频设备
音频有采样率和采样大小的概念,这两个概念也是密不可分的;音频设备主要用来采集音频数据,采集音频数据的本质是模数转换(A/D),即将模拟信号转换为数字信号,其中的模数转换使用的采集定律称为奈奎斯特定理
- 采样大小太小时,当声音振幅过大,会对声音造成损失
- 奈奎斯特定理
- 定义:在进行模拟与数字信号的转换中,当采样率大于信号中最高评率的2倍时,采集之后的数字信号就完整地保留了原始信号中的信息
- 在采集信号时通常将音频输入设备的采集率设置在40HZ以上,这样可以完整的将原始信号保留下来,如常规的音乐一般采集率都是44.1K、48K等,可以达到音质的无损
- 音频设备的工作除了上述信号的采集外,还需要将采集到的数据进行量化、编码、最终形成数字信号;
视频设备
视频设备与音频设备很类似,当实物通过光透过镜头进入摄像机后,会通过视频设备的模数转换(A/D)模块,即光学传感器,将光转换为数字信号,即(RGB)数据; 获得RGB数据后,还需要通过DSP(Digital Signal Processer)进行优化处理,如白平衡、色彩饱和和自动增强等多需要进行处理; 获取到的RGB数据图像还只是临时数据,最终的图像还是需要进行压缩、传输;而编解码器一般使用的输入格式为YUV 1420;在摄像头内部还有一个专门的模块用于将RGB图像转换为YUV格式的数据; YUV是一种色彩编码方法,是将Y与UV进行分离,即亮度信息(Y)和色彩信息(UV),这样即使没有UV信息也可以显示一张完整的图像,只不过是黑白的 - 解决彩色电视和黑白电视的兼容问题;
获取音视频设备列表
通过MediaDevices的enumerateDevices()方法就可以获取到媒体输入和输出设备列表,如麦克风、相机、耳机等设备
- 属性说明
- deviceId:设备ID,通过该编号可以从WebRTC的音视频设备中找到该设备
- groupID:组ID,两个设备是在同一个硬件上则属于同一组,如音频的输入和输出设备就是集成到一起的,其groupID是一致的
- label:设备描述信息,有三种,音频输入和输出设备、视频输入设备;视频输出设备是由显示器完成的,显示器属于默认设备,因此不需要通过音视频设备管理器进行管理
- kind:设备类型,取值为audioinput、audiooutput、videoinput三者之一
- // 如果遍历设备失败, 则回调该函数
function handleError(error) {
console.log("err:", error);
}
// 如果得到音视频设备, 则回调该函数
function gotDevices(deviceInfos) {
// …
// 遍历所有设备信息
for (let i = 0; i !== deviceInfos.length; ++i) {
// 取每个设备信息
const deviceInfo = deviceInfos[i];
// …
}
// …
}
// 遍历所有音视频设备
navigator.mediaDevices.enumerateDevices().then(gotDevices).catch(handleError);
检测摄像头设备变化 - devicechange
- devicechange和navigator.mediaDevices
// 方法1:使用addEventListener监听事件
navigator.mediaDevices.addEventListener('devicechange', (event) => {
updateDeviceList();
});
// 方法2:使用ondevicechange事件句柄
navigator.mediaDevices.ondevicechange = (event) => {
updateDeviceList();
}
排查设备问题 - 单个设备一项一项的进行检查
- 调用getUserMediaAPI只采集视频数据将其展示出来,用户可以看到时表明设备没有问题
- 再次调用getUserMediaAPI只采集音频数据,由于音频不好直接展示,所以需要调用JS的AudioContext对象,将采集到的音频数据计算后,绘制到页面上,这样当音频数据变化时就说明音频设备是正常的
const mediaStreamContains = {
video: true,
audio: true,
};
const localVideo = document.querySelector("video");
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
}
function handleLocalMediaStreamError(error) {
console.log("navigator.getUserMedia error: ", error);
}
navigator.mediaDevices
.getUserMedia(mediaStreamContains)
.then(gotLocalMediaStream)
.catch(handleLocalMediaStreamError);
// 判断浏览器是否支持这些 API
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
console.log("enumerateDevices() not supported.");
} else {
// 枚举 cameras and microphones.
navigator.mediaDevices
.enumerateDevices()
.then(function (deviceInfos) {
// 打印出每一个设备的信息
console.log(deviceInfos,'deviceInfos=========')
})
.catch(function (err) {
console.log(err.name + ": " + err.message);
});
}
浏览器拍照及相关处理 - 架构图位置 → 音视频采集
存在的问题
- 如何从采集到的视频中获取到图片
- 如何将采集到的图片保存为文件
- 如何对获取到的图片进行滤镜处理
基础知识
非编码帧
播放器在播放视频文件时会按照一定的时间间隔连续的播放从视频文件中解码后的视频帧,这样视频就动起来了,原理类似于白纸上画同一个有稍微差别的物体然后快速翻动看到的效果类似; 播放从摄像头里获取的视频帧也一样,不同点在于摄像头获取的本来就是非编码视频帧,所以不存在解码一说了;
- 播放的视频帧之间的间隔是非常小的,如果每秒20帧算,则每帧的间隔是50ms
- 播放器播的是非编码帧(解码后的帧),这些非编码帧就是一幅幅独立的图像
- 从摄像头里采集的或通过解码器解码后的帧都是非编码帧,非编码帧格式一般是YUV格式或是RGB格式
编码帧
编码帧是通过编码器(H264/H265、VP8/VP9)压缩后的帧称为编码帧
- H264编码帧包括
- I帧:关键帧,压缩率低,可以单独解码成一幅完整的图像
- P帧:参考帧,压缩率较高,解码时依赖于前面已解码的数据
- B帧:前后参照帧,压缩率最高,解码时不光依赖前面已经解码的帧,还依赖它后面的P帧,换句话说就是B帧后面的P帧要优先于它进行解码,然后才能B帧解码
在进行拍照操作时,还是依赖于视频流进行处理的,通过video标签将从摄像头里获取到的视频流播放出来,在进行播放视频流时将video的srcObject设置为视频数据流即可; video中的src是播放多媒体文件的,srcObject是播放实时流的,旧的浏览器是没有srcObject的,需要进行转换
let video = document.querySelector("#live-video");
// 旧的浏览器可能没有srcObject
if ("srcObject" in video) {
video.srcObject = that.localStream;
} else {
video.src = window.URL.createObjectURL(that.localStream);
}
如何拍照
利用canvas进行展示捕获到的视频帧,同时也用到了一些canvas的API进行实现,如drawImage方法,其参数可以指定捕获的是图片还是视频元素
drawImage
- 语法:
- canvas.getContext('2d').drawImage(image, dx, dy, dWidth, dHeight)
- image:可以是一幅图片或HTMLVideoElement
- dx,dy:图片的起点x、y坐标
- dWidth/dWidth:图片的宽高
- 图片处理逻辑
- -webkit-filter: Name
- blur:模糊度 -webkit-filter: blur(3px)
- grayscale:灰度(黑白)-webkit-filter: grayscale(1)
- invert:反转 -webkit-filter: invert(1)
- sepia:深褐色 -webkit-filter: sepia(1)
- 其他逻辑处理可以使用WebGL,具体可以搜索其API进行实现
逻辑实现
<!DOCTYPE html>
<html>
<head>
<title> WebRTC</title>
</head>
<body>
<h1>WebRTC </h1>
<video autoplay playsinline></video>
<!-- playsinline,表示在 HTML5 页面内播放视频,而不是使用系统播放器播放视频。 -->
<canvas id="canvas_photo"></canvas>
<button id="TakePhoto">拍照</button>
<button id="SavePhoto">下载</button>
<script src="js/client.js"></script>
</body>
</html>
// 对采集的数据做一些限制
const mediaStreamContains = {
// video: true,
// video: {
// width: 500,
// height: 320,
// frameRate: 15, // 帧率最小15
// },
// audio: false,
};
const localVideo = document.querySelector("video");
// 播放视频流
function gotLocalMediaStream(mediaStream) {
localVideo.srcObject = mediaStream;
}
// 处理异常
function handleLocalMediaStreamError(error) {
console.log("navigator.getUserMedia error: ", error);
}
// 采集视频数据流
navigator.mediaDevices
.getUserMedia(mediaStreamContains)
.then(gotLocalMediaStream)
.catch(handleLocalMediaStreamError);
// 判断浏览器是否支持这些 API
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
console.log("enumerateDevices() not supported.");
} else {
// 枚举 cameras and microphones.
navigator.mediaDevices
.enumerateDevices()
.then(function (deviceInfos) {
// 打印出每一个设备的信息
console.log(deviceInfos, "deviceInfos=========");
})
.catch(function (err) {
console.log(err.name + ": " + err.message);
});
}
// 获取图片
document.querySelector("button#TakePhoto").onclick = function () {
// 获得HTML中的canvas标签,设置其宽高
var photo = document.querySelector("canvas#canvas_photo");
photo.width = 640;
photo.height = 480;
// 利用drawImage方法抓取视频流中当前正在显示的图片
photo.getContext("2d").drawImage(localVideo, 0, 0, photo.width, photo.height);
};
// 保存图片
function downLoad(url) {
var oA = document.createElement("a");
// 设置下载的文件名,默认是“下载”
oA.download = "photo";
oA.href = url;
document.body.appendChild(oA);
oA.click();
// 下载之后把创建的元素删除
oA.remove();
}
document.querySelector("button#SavePhoto").onclick = function () {
var canvas = document.querySelector("canvas");
downLoad(canvas.toDataURL("image/jpeg"));
};
保存采集到的音视频数据 - 架构图位置 → 录制
分类
服务端录制
优点在于不用担心客户因自身原因导致的录制失败的问题,也不用担心设备负荷的问题; 缺点是实现复杂度很高,在实现录制的视频上传服务器时会有自动缩减分辨率等情况,用户体验可能会有影响
客户端录制
优点是方便录制方操控,实现相对简单,另外清晰度等都是比较出色的; 缺点是:录制对设备要求较高,录制失败率高,客户端录制会开启第二路编码器,会更加损耗性能;
基本原理 - 客户端录制为主
存在的问题
- 录制后音视频流的存储格式是什么
- 录制后的存储格式会对后期的回放至关重要
- 可以录制成原始数据和其他某种的媒体格式
- 录制下来的音视频流如何播放
- 可选的有普通播放器和私有播放器
- 取决于业务场景,多人互动等类型就需要使用私有播放器了,包括用浏览器播放也需要提供网页播放器
- 启动录制后多久可以播放
- 边录边看,录制完立即生成回放:一般是开始录制几分钟之后观众才可以看到,以确保突发事件
- 录完后过一段时间才可观看:录制结束后需要对音视频做一些剪辑、转码、清晰度制作等操作
数据类型和存储的处理方式
- 录制原始数据
- 录制效率高,不容易出错,来什么数据录制什么数据
- 录制方法:将音频数据和视频数据分别存放在不同的二进制文件中,文件中的每一块数据都可以进行结构化描述
- struct data
int media_type; // 数据类型,0: 音频 1: 视频
int64_t ts; // timestamp,记录数据收到的时间
int data_size; // 数据大小
char* data; // 指定具体的数据
} media_data; - 在录制结束后,再将录制好的音视频二进制文件整合成某种多媒体文件,如FLV、Mp4等
- 弊端在于:录制完成后用户需要等一段时间才可以看到录制的视频,因为还需要进行音视频合流、输出多媒体文件等
- 录制成其他的多媒体格式
- FLV格式他别适合处理这种流式数据,因为FLV媒体文件本身就是流式的,可以在FLV文件的任何位置进行读写操作,这样就避免了二进制数据的存储、还有之后的合流、转码等麻烦事
- 采用FLV进行录制时,可以将直播的视频按照N分钟为单位录制成一段一段的FLV,然后录完一段播一段,这样就实现了边录边播的效果了
- FLV弊端是:FLV只能同时存在一路视频和一路音频,而不能存在多路视频的情况;再者FLV录制的前提是数据音视频流是按照顺序采集到的,但实际上音视频数据经过UDP这种不可靠协议传输后,不会保证数据到达的先后顺序,所以还需要做音视频数据的排序工作,当然还有其他的各种工作了;
- WebRTC解决了一切问题
如何录制本地视频
MediaRecorder
基础语法
let mediaRecorder = new MediaRecorder(stream[, options]);
- stream:通过getUserMedia获取的本地视频流或通过RTCPeerConnection获取的远程视频流;
- options,指定视频格式、编解码器、码率等相关信息,如mimeType:'video/webm;codecs=vp8'
- 特殊的事件:
- ondataavailable
- 事件
- 当MediaRecoder捕获到数据时就会触发该事件,通过该事件才可以将音视频数据录制下来
- 在进行录制时,可以设置一个毫秒级别的时间片,这样录制的媒体数据会按照设置的值分割成一个个单独的区块,否则默认的方式是录制一个很大的整块内容,分成一块块的区块会提高效率和可靠性,如果是一整块数据,后续读写效率会变差,且失败率也会增加;
- this.mediaRecorder.pause(); //暂停录制
- this.mediaRecorder.resume(); //恢复录制
- this.mediaRecorder.stop(); //结束录制
步骤解析
- 录制音视频流
- 通过将捕获到的音视频数据保存下来,然后借助MediaRecoder创建的录制对象将音视频录制成指定的媒体格式文件
- 保存数据时直接将数据push到buffer中,底层实际是使用Blob对象
- 录制回放文件
- 和播放录制内容类似,通过将保存的buffer生成Blob对象,然后再根据BloB对象生成URL设置到Video标签上进行播放
- 下载录制好的文件
- 通过将保存的buffer生成Blob对象,然后再根据BloB对象生成URL,然后赋值到A标签的href上,设置download属性后实现下载
代码逻辑
<!DOCTYPE html>
<html>
<head>
<title> WebRTC</title>
</head>
<body>
<h1>WebRTC </h1>
<video autoplay playsinline></video>
<video id="recvideo"></video>
<hr>
<button id="record">Start Record</button>
<button id="recplay">Play</button>
<button id="download">Download</button>
<script src="js/client.js"></script>
</body>
</html>
复制代码
// 将音视频数据录制下来
var buffer;
// 当该函数被触发后,将数据压入到 blob 中
function handleDataAvailable(e) {
if (e && e.data && e.data.size > 0) {
buffer.push(e.data);
}
}
function startRecord() {
buffer = [];
// 设置录制下来的多媒体格式
var options = {
mimeType: "video/webm;codecs=vp8",
};
// 判断浏览器是否支持录制
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.error(`${options.mimeType} is not supported!`);
return;
}
try {
// 创建录制对象
// mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder = new MediaRecorder(localVideo.srcObject, options);
} catch (e) {
console.error("Failed to create MediaRecorder:", e);
return;
}
// 当有音视频数据来了之后触发该事件
mediaRecorder.ondataavailable = handleDataAvailable;
// 开始录制
mediaRecorder.start(10);
}
document.querySelector("button#record").onclick = function () {
startRecord();
};
document.querySelector("button#recplay").onclick = function () {
let recvideo = document.querySelector("video#recvideo");
var blob = new Blob(buffer, { type: "video/webm" });
recvideo.src = window.URL.createObjectURL(blob);
recvideo.srcObject = null;
recvideo.controls = true;
recvideo.play();
};
document.querySelector("button#download").onclick = function () {
var blob = new Blob(buffer, { type: "video/webm" });
var url = window.URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.style.display = "none";
a.download = "aaa.webm";
a.click();
};
共享桌面 - 架构图位置 → 音视频采集
基本原理
桌面也可以当做是一种特殊的视频来处理
- 抓屏、压缩编码、传输、解码、显示、控制
- 共享者
- 通过特定时长的多次屏幕抓取,与上次屏幕抓取进行对比,取出差值后压缩,然后将压缩后的数据通过传输模块传输到观看端,观看端接收到数据后进行解码,这样就可以将数据显示出来
- 当本次抓屏与上次抓屏变化率超过80%时,就进行全屏的帧内压缩
- 远程控制端
- 当用户通过鼠标点击共享桌面的某个位置时,会首先计算出鼠标实际点击位置,然后将其作为参数通过信令发送给共享端;
- 共享端接收到信令后,会模拟本地鼠标,调用相关的API完成最终的API
涉及到的协议
RDP(Remote Desktop Protocal)协议
是Windows系统下的共享桌面协议
VNC(Virtual Network Console)协议 - 通用
远程桌面控制协议,可以实现不同操作系统上共享远程桌面,例如TeamViewer、RealVNC都是使用的该协议
远程桌面协议分为桌面数据处理和信令控制两部分
- 桌面数据:包括桌面的抓取(采集)、编码(压缩)、传输、解码和渲染
- 信令控制:包括键盘事件、鼠标事件和接收到这些事件消息后的相关处理等
信令状态机
端到端通信前需要先将客户端和信令服务器打通,从而为端到端交换信息做好准备,最简单的方法就是通过状态机实现
- 基本原理:每次发送/接收一个信令后,客户端都要根据状态机当前的状态做出相应的逻辑处理;
- 例如当客户端启动时,处于init状态,此状态下只能向服务器发送join消息,待服务器返回joined消息后,客户端的状态机发生变化成为joined状态,此时才可以开展后续的工作
- 客户端状态机状态
- init - 客户端刚启动
- 用户只能向服务器发送join消息,只有服务器返回joined消息且客户端接收到该消息,才说明用户已成功加入房间,此时客户端状态更新为joined
- joined
- 该状态下客户端的多种选择
- 用户离开房间:客户端回到初始状态 - init 状态
- 客户端收到第二个用户加入的消息(other_joined消息),则切换到join_conn状态,该状态下两个用户就可以进行通信了
- 客户端收到第二个用户离开的消息(即bye消息),则需要将其状态切换为join_unbind,join_unbind状态和joined状态基本是一致的,可以通过这两种状态值判断出用户之前的状态
- joined_unbind
- 客户端收到第二个用户离开的消息会变成该状态
- 客户端是该状态时,当收到other_join消息时会变成join_conn状态
- joined_conn
- 客户端收到第二个用户加入的消息会变成该状态,此时用户间可以进行通信
- 客户端是该状态时,当收到bye消息时会变成joined_unbind状态
- 客户端状态机实现
var state = init;
// 连接信令服务器并根据信令更新状态机
function conn() {
// 建立socket.io 连接
socket = io.connect();
//注册事件 - 注册信令消息joined、otherjoin、full、left和bye消息
// 收到joined 消息
socket.on("joined ", (roomid, id) => {
state = "joined "; // 变更状态
// 创建连接
createPeerConnection();
bindTracks();
});
// 收到otherjoin 消息
socket.on("otherjoin ", (roomid) => {
state = "joined_conn "; // 更改状态
call();
});
// 收到full 消息
socket.on("full", (roomid, id) => {
hangup();
socket.disconnect(); // 关闭连接
state = "init"; // 回到初始化状态
});
// 收到用户离开的消息
socket.on("left", (roomid, id) => {
hangup();
socket.disconnect();
state = "init"; // 回到初始化状态
});
socket.on("bye", (room, id) => {
state = "joined_unbind ";
hangup();
});
// 向服务端发送join 消息
// 服务器返回消息后客户端会在之前注册的消息事件中执行与之对应的回调事件
// join成功后客户端就可以有信令驱动运转了
roomid = getQueryVariable("room");
socket.emit("join", roomid);
}
conn(); // 与信令服务器建立连接
如何共享桌面
抓取桌面
- let promise = navigator.mediaDevices.getDisplayMedia(constraints);
- 可选参数:constraints,MediaStreamConstraints约束条件,用于指定共享屏幕的约束条件
- 返回值是一个Promise,调用成功时则可以得到媒体流,调用失败返回一个DOMException
- 相关规定(出于安全考虑,屏幕共享会造成隐私泄露)
- 每次调用getDisplayMedia()方法都需要弹出授权提示框,通过授权后不会保存授权状态
- getDisplayMedia()方法必须由用户触发,且当前document上下文是激活的状态
在采集视频数据时(let promise = navigator.mediaDevices.getUserMedia(constraints);),可以同时对音频进行限制的,而在桌面采集的参数里是不可以对音频进行限制了,即不能在采集桌面的同时采集音频
function shareDesktop(){
if(isPC()){
navigator.mediaDevices.getDisplayMedia({video:true}).then(getDeskStream).catch(handleError)
return true
}
return false
}
/**
* 功能: 判断是Android 端还是iOS 端。
*
* 返回值: true , 说明是Android 端;
* false , 说明是iOS 端。
*/
function IsAndroid () {
var u = navigator.userAgent , app = navigator.appVersion;
var isAndroid = u.indexOf('Android ') > -1 || u.indexOf('Linux ') > -1;
var isIOS = !!u.match (/\(i[^;]+;( U;)? CPU.+Mac OS X/);
if (isAndroid) {
// 这个是Android 系统
return true;
}
if (isIOS) {
// 这个是iOS 系统
return false;
}
}
function IsPC() {
var userAgentInfo = navigator.userAgent;
var Agents = ["Android", "iPhone","SymbianOS", "Windows Phone","iPad", "iPod"];
var flag = true;
for (var v = 0; v < Agents.length; v++) {
if (userAgentInfo.indexOf(Agents[v]) > 0) {
flag = false;
break;
}
}
return flag;
}
关联数据与播放器展示
将抓取到的桌面数据与Video标签进行关联起来,从而实现数据从播放器里显示出来
var deskVideo = document.querySelector("video#deskVideo");
// 得到桌面数据流
function getDeskStream(stream){
localStream = stream;
deskVideo.srcObject = stream;
}
录制桌面
约束条件
- cursor:在流中如何显示鼠标光标
- always:一直显示(默认)
- motion:移动鼠标时显示
- never:不显示
- displaySurface:指定用户可以选择的屏幕内容
- application:应用程序(默认)
- browser:浏览器标签页
- monitor:显示器
- Window:某个应用程序窗口
- logicalSurface:是否开启逻辑显示面
- true(默认)
语法
- 构建
- MediaRecorder
- const mediaRecorder = new MediaRecorder(stream,[options])
- 参数:Stream,MediaStream对象,录制源,类型为MediaRecorderOptions
- 可选参数:options
- mediaType:指定录制流的编码格式,可以通过MediaRecorder.isTypeSupported()检查浏览器是否支持指定的编码格式
- audioBitsPerSecond/videoBitsPerSecond:指定录制流的音视频码率
- bitsPerSecond:用于代替上述两者,指定录制流的音视频码率
- audioBitrateMode:指定音频码率模式,取值为cbr或vbr,即固定码率编码和可变码率编码
- 默认码率是2.5Mbps
- requestData()方法触发dataavailable事件
- mediaRecorder.requestData(),需要周期性调用
- 当前录制状态需要是recording,否则会抛出错误
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 ) {
this.recordedChunks.push(event.data);
}
};
this.recorderIntervalHandler = setInterval(() => {
this.mediaRecorder.requestData();
}, 1000);
- dataavailable事件
- 该事件用于处理录制数据,对应时间句柄是:ondataavailable
- 触发该事件常见情况
- 获取不到媒体数据,媒体流终止
- 调用了MediaRecorder.stop()方法,将所有未处理的录制数据写入BloB,停止录制
- 调用了MediaRecorder.start()方法,每隔指定时间触发一次该事件
- 调用了mediaRecorder.requestData()方法,将所有未处理的录制数据写入BloB,继续录制
- 设置回调处理函数 - MediaRecorder.ondataavailable = (event) => {...} - MediaRecorder.addEventListener('dataavailable',(event)=>{...})
- 其他
- 获取当前录制状态
- MediaRecorder.state:inactive(没有进行录制)、recording(录制中)、paused(录制已开始,当前处于暂停中)
- mediaRecorder.start(timeslice) //开始录制
- MediaRecorder.onstart = (event) => {...}
- MediaRecorder.addEventListener('start',(event)=>{...})
- timeslice:设置录制缓冲区大小,单位为毫秒,当指定大小的缓存区写满后会触发dataavailable事件,并重新创建一个BloB对象,未指定时则写入同一文件,直到调用requestData()方法才会重新创建新的BloB对象
- mediaRecorder.pause(); //暂停录制
- MediaRecorder.onpause = (event) => { ... }
- MediaRecorder.addEventListener('pause', (event) => { ... })
- this.mediaRecorder.resume(); //恢复录制
- MediaRecorder.onresume = (event) => { ... }
- MediaRecorder.addEventListener('resume', (event) => { ... })
- this.mediaRecorder.stop(); //结束录制
- MediaRecorder.onstop = (event) => { ... }
- MediaRecorder.addEventListener('stop', (event) => { ... })
function getRecorder(stream) {
const options = {
audioBitsPerSecond : 128000,
videoBitsPerSecond : 2500000,
mimeType : 'video/mp4'
};
let mediaRecorder = null;
try {
mediaRecorder = new MediaRecorder(stream,options);
} catch(e) {
console.error('Exception while creating MediaRecorder: ' + e);
}
return mediaRecorder;
}
实现思路
首先通过getDisplayMedia方法获取到本地桌面数据,然后将数据传递给MediaRecorder对象,并实现ondataavailable事假,最终将音视频流录制下来
document.querySelector("button#deskVideoRecord").onclick = function () {
startRecordDesk();
};
let deskBuffer;
function handleDeskDataAvailable(e){
if(e&&e.data&&e.data.size>0){
deskBuffer.push(e.data)
}
}
function startRecordDesk(){
let buffer = [];//用户缓存桌面数据,最终将数据存储到文件中
let options = {
mimeType: 'video/web,;codesc=vp8'
}
if(!MediaRecorder.isTypeSupported(options.mimeType)){
console.error(`${options.mimeType} is not support`);
return
}
try{
mediaRecorder = new MediaRecorder(localStream,options)
} catch(e){
console.error(`failed to create MediaRecorder:${e}`);
return
}
// 当捕获到桌面数据后,该事件触发
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start(10)
}
实现关闭/打开音视频
将远端的声音禁音
- 播放端控制
- 不让播放器播放出来
- 虽然音频没有被使用,但还是会占用宽带资源,造成宽带浪费
- 实现方式:在video标签上添加muted属性即可,此时虽然将远端的音视频流赋值给了video但只有视频显示出来,声音没有显示
- <video id=remote autoplay muted playsinline/>
- document.querySelector('video#remote').muted=false;//将音频播放出来
- 不给播放器传数据,将接收到的远端音视频数据丢弃
- 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];
} - 发送端控制
- 停止音频采集
- 所有接受该音频的用户都收不到音频
- 通过信令服务器实现本地控制远端禁音
- 只需要在navigator.mediaDevices.getUserMedia()的入参配置中设置audio: false即可停止音频采集,所有获取到的流中就只有视频数据了
- 停止音频发送
- 停止向某个用户发送音频流,实现定向禁音
- 还是需要依赖信令服务器实现本地与远端的信令通信
- 实现方式是:获取到本地媒体流后进行Track判断,如果是音频流就不进行绑定,关闭通道,这样对方就收不到音频数据了,从而达到远端禁音的效果
- 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);
}
});
}
将自己声音禁音
- 只需要停止对本地音视频数据的采集
- 所有人都听不到自己的声音
- 与发送端控制→停止音频采集一致,只需要设置constraints中的audio:false即可
关闭远端的视频
- 播放端控制
- 不给播放器传数据
- 播放器不支持关闭视频播放的功能
- 发送端控制
- 不建议使用停止采集
- 所有端都看不到数据了
- 停止音频发送
- 停止向某个用户发送视频流,实现定向禁音
- 实现与远端声音禁音类似
关闭自己的视频
- 关闭所有视频流的推送
- 不将本地视频数据与RTCPeerConnection对象进行绑定
- 不应该关闭视频的采集
- 原因是:视频除了远端播放外还有本地预览,只要视频设备可用,本地预览就一直在
- 为了节省流量,尽量采用远端进行控制
影响音视频服务质量的因素
RTCP报文
- RR(Reciever Report)
- 是接收方发的,记录的是RTP流从接收到现在一共收了多少包,多少字节的数据
- SR(Sender Report)
- 是发送方发的,记录的是RTP流从发送到现在一共发了多少包,发了多少数据,丢包率是多少
- webRTC中的输入/输出RTP流报告中的统计数据都是通过RTCP协议中的SR和RR消息计算来的
- 通过将上述两个报文进行交换,各端就知道自己网络好坏情况了,计算出每秒的传输速率、丢包率等信息了
- 使用RTCP交换信息有一个原则:RTCP信息在整个数据流的传输中占宽带的百分比不超过5%
- 使用RTCP交换SR/RR信息时,如果SR和RR包丢失了是不会对数据准确性造成影响的,因为每次传输都是全量的,丢失只会丢失这一次的值,在下一次又会全量带过来
物理链路质量
- 丢包:造成接收端无法组包、解码
- 延迟:数据在双方传输时在物理链路上花费的时间较长,延迟在200ms是最好的,500ms以内还可以接受,800ms有较为明显的滞后,超过1s就尴尬了
- 抖动:指数据一会快一会慢,不稳定,不过在webRTC内部的JitterBuffer(可简单理解为缓冲区)可以解决这个问题
传输速率
在实时通信中,与传输速率相关的两个码率:音视频压缩码率和传输控制码率
- 音视频压缩码率:单位时间内音视频被压缩后的数据大小,或者理解为压缩后每秒的采样率;
- 与视频的清晰度是反比:即压缩率越高,清晰度越低
- 压缩分为有损压缩和无损压缩:有损压缩就是数据压缩后无法还原回原来的样子,无损压缩是指压缩后还可以恢复到原来的样子,如ZIP、RAR、GZ等压缩文件就是无损压缩
- 传输控制码率:是指对网络传输速度的控制,需要将传输码率控制在可接受的范围内,如传输网速控制在计算好的宽带范围内
- 传输码率计算:要发送的网络包都是1500字节,每秒发100个包,传输码率就是100*1.5k = 150K字节,转换成宽带就是150KB*8=1.2M
- 1M宽带实际每秒只能传输128个Byte(1Mbit = 1024Kbit = 1024/8 KByte = 128KByte)
分辨率与帧率
- 分辨率越高,视频越清晰、数据量也就越大
- 如果想降低码率,最直接的办法就是降低分辨率,当然也可以降低帧率来实现降低码率(效果不太明显)
- 对于1帧未压缩过的视频帧,假如分辨率为1280720,存储成RGB格式,一帧的数据为128072038(3表示RGB三种颜色,8表示将Byte转换为bit)约等于22Mb,存储为YUV420P格式约等于11Mb(12807201.5*8)
传输速率控制
在webRTC中,由于webRTC是实时传输的,当发现音视频数据的延迟太大,且数据又不能及时发出去,他会采取主动丢数据的方式以达到传输的要求,因此通过直接控制传输速度来控制速率是有分险的,因此一般采用压缩码率来实现控制速率
- 通过控制码率实现控制速率
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);
});
webRTC数据统计
webRTC可以检测很多方面,如接受了多少包,发了多少包、丢了多少包、每路流的流量是多少、视频的帧率、接收和发送了记录流等信息; 当然收发包的统计还可以通过RTCRtpSender的getStats方法和RTCRtpReceiver的getStats方法也可以获取对应的收发包统计信息
getStats方法
通过该API可以获取到上述的信息了,用于获取各种统计信息,如收发包统计信息、候选者、证书、编解码器等统计信息
简介
- 语法:promise = rtcPeerConnection.getStats(selector)
- 参数selector是可选的,当为null时就收集的是所有相关的统计信息,也可以设置一个MediaStreamTrack类型的参数,这样就只收集对应Track的相关统计信息了
- 统计信息有:
- id:对象的唯一标识,是字符串
- timestamp:时间戳,用来标识该条report是什么时间产生的
- type:类型,是RTCStatsType
- 编解码器相关 - RTCCodecStats
- payloadType; // 数据负载类型
- codecType; // 编解码类型(AAC、OPUS、H264、VP8/VP9)
- transportId; // 传输 ID
- clockRate; // 采样时钟频率
- channels; // 声道数,主要用于音频
- 输入RTP流相关 - RTCInboundRtpStreamStats
- frameWidth/frameHeight; // 帧宽度/高
- framesPerSecond;// 每秒帧数
- bytesReceived; // 接收到的字节数
- packetsDuplicated; // 重复的包数
- nackCount; // 丢包数
- jitterBufferDelay; // 缓冲区延迟
- framesReceived; // 接收的帧数
- framesDropped; // 丢掉的帧数
- 输出RTP流相关 - RTCOutboundRtpStreamStats
- retransmittedPacketsSent; // 重传包数
- retransmittedBytesSent; // 重传字节数
- targetBitrate; // 目标码率
- frameWidth/frameHeight; // 帧的宽度/高
- framesPerSecond; // 每秒帧数
- framesSent; // 发送的总帧数
- nackCount; // 丢包数
- var pc = new RTCPeerConnection(null);
pc.getStats()
.then((reports) => {
// 得到相关的报告
reports.forEach((report) => {
// 遍历每个报告
console.log(report);
});
})
.catch((err) => {
console.error(err);
});
// 从 PC 上获得 sender 对象
var sender = pc.getSenders()[0];
// 调用 sender 的 getStats 方法
sender.getStats().then((reports) => {
// 得到相关的报告
reports.forEach((report) => {
// 遍历每个报告
if (report.type === "outbound-rtp") {
// 如果是 rtp 输出流
}
});
});
JS二进制数据存储
ArrayBuffer
ArrayBuffer对象表示通用的、固定长度的二进制数据缓冲区,可以直接用来存储图片、视频等内容。在物理内存中并不存在这样的地址,需要使用其封装类进行实例化后才可以进行访问;
let buffer = new ArrayBuffer(16);//创建一个长度为16的buffer
let view = new Unit32Array(buffer)//生成具体类型的新对象时才可以进行访问
let buffer = new Unit8Array([255,255,255,255]).buffer;
let dataView = new DataView(buffer)//生成具体类型的新对象时才可以进行访问
ArrayBufferView
Bolb
作者:FE杂志社 链接:https://juejin.cn/post/7178881863123992632 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。