获取屏幕分享权限并添加音频轨道

2,359 阅读6分钟

WebRTC简介

WebRTC是一个由Google发起的实时通信解决方案,

其中包含音视频采集、编解码、数据传输、音视频展示等功能。

虽然其名为WebRTC,但是实际上它不仅支持Web之间的音视频通讯,还支持Android和iOS端。

image.png

底层技术

  • 图像引擎(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中。

音视频录制原理

image.png

音视频播放原理

image.png

获取摄像头的视频流

MediaStream 接口用于表示媒体数据流。(流可以是输入或输出,也可以是本地或远程)

单个 MediaStream 可以包含零个或多个轨道。(每个轨道都有一个对应的 MediaStreamTrack 对象)

MediaStreamTrack 表示包含一个或多个通道的内容,其中,通道之间具有定义的已知的关系。

MediaStream 中的所有轨道在渲染时是同步的。

下图显示了由单个视频轨道和两个不同的音频(左声道和右声道)轨道组成的 MediaStream。

image.png

平时我们在开发时总是习惯于定义 {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是采用另外的方式,见下文)

然后就会弹一个框询问要录制的应用窗口,如下图所示:

image.png

约束的详细用法可以看这篇博客: 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();
}

当进行完以上操作后,发现确实将屏幕分享的导出一段视频了。

但是…

这个视频是没有声音的,音量按钮处为一个“静音”标识,且不能调节音量大小。

image.png

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'});
...

此刻,就变成一个有声音的真正的屏幕分享了 ~

image.png