WebRTC简介
WebRTC是一个由Google发起的实时通信解决方案,
其中包含音视频采集、编解码、数据传输、音视频展示等功能。
虽然其名为WebRTC,但是实际上它不仅支持Web之间的音视频通讯,还支持Android和iOS端。
底层技术
- 图像引擎(VideoEngine)
- VP8编解码
- jitter buffer:动态抖动缓冲
- Image enhancements:图像增益
- 声音引擎(VoiceEngine)
- iSAC/iLBC/Opus等编解码
- NetEQ语音信号处理
- 回声消除和降噪
- 会话管理(Session Management)
- iSAC音效压缩
- VP8 Google自家WebM项目的影片编解码器
- APIs(Native C++ API,Web API)
WebRTC 虽然底层实现极其复杂,但是面向开发者的API还是非常简洁的,主要分为三个方面:
- Network Stream API
- MediaStream 媒体数据流
- MediaStreamTrack 媒体源
- RTCPeerConnection
- RTCPeerConnection 允许用户在两个浏览器之间直接通讯
- RTCIceCandidate ICE协议的候选者
- RTCIceServe
- DataChannel
Network Stream API
主要有两个API:MediaStream与MediaStreamTrack。
MediaStreamTrack 代表一种单类型数据流(VideoTrack或AudioTrack),
一个MediaStreamTrack代表一条媒体轨道,这给我们提供了混合不同轨道实现多种特效的可能性。
MediaStream 是一个完整的音视频流,可以包含多个 MediaStreamTrack 对象,
它的主要作用是协同多个媒体轨道同时进行播放,这就是我们平时说的音画同步。
eg:
LocalMediaStream 表示来自本地媒体捕获设备(如网络摄像头、麦克风等)的媒体流。
要创建和使用本地流,web应用程序必须通过 getUserMedia() 函数请求用户访问。
一旦应用程序完成,它可以通过调用 LocalMediaStream 上的 stop() 函数来撤销自己的访问权限。
RTCPeerConnection
上面我们只是成功的拿到了MediaStream流媒体对象,但是仍然仅限于本地查看。
如何将流媒体与对方互相交换(实现音视频通话)?
答案是我们必须建立点对点连接(peer-to-peer),这就是RTCPeerConnection要做的事情。
在此之前,我们得了解一个概念:信令服务器。
两台公网上的设备要互相知道对方是谁,需要有一个中间方去协商交换它们的信息。
信令服务器干的就是这个事情 —— 牵线搭桥。
一旦建立了对等连接,就可以将媒体流(临时定义的 MediaStream 对象)直接发送到远程浏览器。
DataChannel
每个流实际上代表一个单向逻辑通道,提供顺序传送的概念。
消息序列可以有序或无序发送。消息传递顺序仅保留给在同一流上发送的所有有序消息。
但是,DataChannel API 已被设计为双向的,这意味着每个 DataChannel 都是由传入和传出SCTP流的捆绑组成的。
当在实例化的 PeerConnection 对象上首次调用 CreateDataChannel() 函数时,将执行 DataChannel 设置(即创建SCTP关联)。
随后每次对 CreateDataChannel() 函数的调用都只会在现有SCTP关联内创建一个新的 DataChannel。
数据处理和传输过程
WebRTC 对外提供两个线程:Signal和Worker,前者负责信令数据的处理和传输,后者负责媒体数据的处理和传输。
WebRTC 对内有一系列线程各司其职,相互协作完成数据流管线。
以一个video数据的处理流程为例,
Capture线程从摄像头采集原始数据,接下来到达Worker线程,
Worker线程起搬运工的作用,没有对数据做特别处理,而是转发到Encoder线程,
Encoder线程调用具体的编码器(如VP8、H264)对原始数据进行编码,编码后的输出进一步进行RTP封包形成RTP数据包,
然后RTP数据包发送到Pacer线程进行平滑发送,Pacer线程会把RTP数据包推送到Network线程,最终发送到网络Internet中。
音视频录制原理
音视频播放原理
获取摄像头的视频流
MediaStream 接口用于表示媒体数据流。(流可以是输入或输出,也可以是本地或远程)
单个 MediaStream 可以包含零个或多个轨道。(每个轨道都有一个对应的 MediaStreamTrack 对象)
MediaStreamTrack 表示包含一个或多个通道的内容,其中,通道之间具有定义的已知的关系。
MediaStream 中的所有轨道在渲染时是同步的。
下图显示了由单个视频轨道和两个不同的音频(左声道和右声道)轨道组成的 MediaStream。
平时我们在开发时总是习惯于定义 {video: true, audio: true} 这两个参数,然后通过写css样式控制展示视频窗口。
但其实API本来带有一种约束,可以初始化视频的宽高比,面向照相机的模式(正面或背面),音频和视频帧率等等。
navigator.mediaDevices
.getUserMedia({
audio: true,
video: {
width: 1280,
height: 720
}
})
.then(stream => {
console.log(stream);
});
复制代码
如果想实现录屏(屏幕共享)的话,就是获取媒体的参数改一下,比如将摄像头改成屏幕:
navigator.mediaDevices
.getUserMedia({
video: {
mediaSource: 'screen'
}
})
.then(stream => {
console.log(stream);
});
复制代码
这个目前只有火狐浏览器支持,(而Chrome和Edge是采用另外的方式,见下文)
然后就会弹一个框询问要录制的应用窗口,如下图所示:
约束的详细用法可以看这篇博客: getUserMedia() Video Constraints
Ok~ 这部分内容非常简单,下面是一个简单的Demo:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1><code>getUserMedia()</code> very simple demo</h1>
<video></video>
<script>
navigator.getUserMedia =
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia;
const localVideo = document.querySelector('video');
// MediaStreamConstraints 用于指定请求哪种轨道(音频,视频或两者)
const constraints = { audio: false, video: true };
function successCallback(stream) {
localVideo.srcObject = stream;
localVideo.play();
}
function errorCallback(error) {
console.error('navigator.getUserMedia error: ', error);
}
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia(constraints)
.then(successCallback)
.catch(errorCallback);
} else {
navigator.getUserMedia(constraints, successCallback, errorCallback);
}
</script>
</body>
</html>
复制代码
上面我们了解了屏幕分享的API,感觉跟我们常用的“屏幕共享”好像。
那么可不可以用此进行一个屏幕录制呢?
“纸上得来终觉浅,觉知此事要躬行。”看着挺简单的一个东西,没有落实都算说大话。
首先画上三个按钮:
<button @click="start" :disabled="disabled.start">开始录制</button>
<button @click="stop" :disabled="disabled.stop">结束录制</button>
<button @click="download" :disabled="disabled.download">下载文件</button>
复制代码
添加上简单的样式:
button {
margin: 0 1em 1em 0;
padding: 0.5em 1.2em 0.6em 1.2em;
border: none;
border-radius: 4px;
background-color: #d84a38;
font-family: 'Roboto', sans-serif;
font-size: 0.8em;
color: white;
cursor: pointer;
}
button:hover {
background-color: #c03434;
}
button[disabled] {
background-color: #c03434;
pointer-events: none;
}
复制代码
初始化数据:
data() {
return {
// 本地流
stream: null,
// 媒体录制
mediaRecorder: null,
// 数据块
chunks: [],
// 录制结果
recording: null,
// 按钮禁用
disabled: {
start: false,
stop: true,
download: true
}
}
},
复制代码
需要的方法:
methods: {
// 获取屏幕分享的权限
openScreenCapture() {
...
},
// 开始屏幕分享录制
async start() {
....
},
// 停止屏幕分享录制
stop() {
...
},
// 下载录制的视频内容
download() {
...
}
}
复制代码
ok~ 下面进入每个方法内部看看都需要些什么操作。
首先我们要获取屏幕分享的权限,
由于每个浏览器的实现不同,所以这里需要做个兼容处理。
// 获取屏幕分享的权限
openScreenCapture() {
if (navigator.getDisplayMedia) {
return navigator.getDisplayMedia({ video: true });
} else if (navigator.mediaDevices.getDisplayMedia) {
return navigator.mediaDevices.getDisplayMedia({ video: true });
} else {
return navigator.mediaDevices.getUserMedia({
video: { mediaSource: 'screen' },
});
}
},
复制代码
当点击“开始录制”按钮后,依次设置三个按钮的禁用状态,
如果之前录制的内容没有清空,那么就用revokeObjectURL方法移除。
获取屏幕分享权限后,实例化一个MediaRecorder对象进行录制存储。
监听dataavailable,当有可用数据时,将其push进数据块中进行存储。
// 开始屏幕分享录制
async start() {
this.disabled.start = true;
this.disabled.stop = false;
this.disabled.download = true;
if (this.recording) {
window.URL.revokeObjectURL(this.recording);
}
// 获取屏幕分享权限
this.stream = await this.$options.methods.openScreenCapture();
// 实例化一个MediaRecorder对象
this.mediaRecorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'});
// 监听可用数据
this.mediaRecorder.addEventListener('dataavailable', event => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
});
// 开始录制
this.mediaRecorder.start(10);
},
复制代码
当点击“停止录制”按钮后,需要将数据块保存到一个内存URL中方便后续下载使用。
// 停止屏幕分享录制
stop() {
this.disabled.start = true;
this.disabled.stop = true;
this.disabled.download = false;
// 停止录制
this.mediaRecorder.stop();
// 释放MediaRecorder
this.mediaRecorder = null;
// 停止所有流式视频轨道
this.stream.getTracks().forEach(track => track.stop());
// 释放getDisplayMedia或getUserMedia
this.stream = null;
// 获取当前文件的一个内存URL
this.recording = window.URL.createObjectURL(new Blob(this.chunks, {type: 'video/webm'}));
},
复制代码
当点击“下载文件”按钮时,更新下载元素的链接href,并自动触发点击事件进行弹窗提示下载。
// 下载录制的视频内容
download() {
this.disabled.start = false;
this.disabled.stop = true;
this.disabled.download = true;
const downloadLink = document.querySelector('a#download');
downloadLink.href = this.recording;
// download 规定作为文件名来使用的文本
downloadLink.download = 'screen-recording.webm';
downloadLink.click();
}
复制代码
当进行完以上操作后,发现确实将屏幕分享的导出一段视频了。
但是…
这个视频是没有声音的,音量按钮处为一个“静音”标识,且不能调节音量大小。
getDisplayMedia 默认只支持视频轨道,不支持音频轨道,
反正我试了几个浏览器均不支持音频轨道,没能验证有的博客写的如下开启音频轨道:
navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
})
复制代码
不过,在经过上一章节的学习,我们知道MediaStream是由多个MediaStreamTrack组成的,
那么应该就可以给当前getDisplayMedia获取的视频轨道,再加上一个音频轨道,组成一个MediaStream。
其他部分不用更改,只用在MediaRecorder录制这个MediaStream之前,将其进行改造即可。
...
// 获取麦克风权限
const audioTrack = await navigator.mediaDevices.getUserMedia({ audio: true });
// 获取屏幕分享权限
this.stream = await this.$options.methods.openScreenCapture();
// 给MediaStream添加音频轨道
this.stream.addTrack(audioTrack.getAudioTracks()[0]);
// 实例化一个MediaRecorder对象
this.mediaRecorder = new MediaRecorder(this.stream, {mimeType: 'video/webm'});
...
复制代码
此刻,就变成一个有声音的真正的屏幕分享了 ~