文章

webrtc 学习

基本概念

音视频采集基本概念

  • 摄像头。用于捕捉(采集)图像和视频。
  • 帧率。现在的摄像头功能已非常强大,一般情况下,一秒钟可以采集 30 张以上的图像,一些好的摄像头甚至可以采集 100 张以上。我们把摄像头一秒钟采集图像的次数称为帧率。帧率越高,视频就越平滑流畅。然而,在直播系统中一般不会设置太高的帧率,因为帧率越高,占的网络带宽就越多。
  • 分辨率。摄像头除了可以设置帧率之外,还可以调整分辨率。我们常见的分辨率有 2K、1080P、720P、420P 等。分辨率越高图像就越清晰,但同时也带来一个问题,即占用的带宽也就越多。所以,在直播系统中,分辨率的高低与网络带宽有紧密的联系。也就是说,分辨率会跟据你的网络带宽进行动态调整。
  • 宽高比。分辨率一般分为两种宽高比,即 16:9 或 4:3。4:3 的宽高比是从黑白电视而来,而 16:9 的宽高比是从显示器而来。现在一般情况下都采用 16:9 的比例。
  • 麦克风。用于采集音频数据。它与视频一样,可以指定一秒内采样的次数,称为采样率。每个采样用几个 bit 表示,称为采样位深或采样大小。
  • 轨(Track)。WebRTC 中的“轨”借鉴了多媒体的概念。火车轨道的特性你应该非常清楚,两条轨永远不会相交。“轨”在多媒体中表达的就是每条轨数据都是独立的,不会与其他轨相交,如 MP4 中的音频轨、视频轨,它们在 MP4 文件中是被分别存储的。
  • 流(Stream)。可以理解为容器。在 WebRTC 中,“流”可以分为媒体流(MediaStream)和数据流(DataStream)。其中,媒体流可以存放 0 个或多个音频轨或视频轨;数据流可以存 0 个或多个数据轨。

音视频设备的基本原理

音频设备

音频有采样率和采样大小的概念,实际上这两个概念就与音频设备密不可分。音频输入设备的主要工作是采集音频数据,而采集音频数据的本质就是模数转换(A/D),即将模似信号转换成数字信号。模数转换使用的采集定理称为奈奎斯特定理,其内容如下:

在进行模拟/数字信号的转换过程中,当采样率大于信号中最高频率的 2 倍时,采样之后的数字信号就完整地保留了原始信号中的信息。人类听觉范围的频率是 20Hz~20kHz 之间。对于日常语音交流(像电话),8kHz 采样率就可以满足人们的需求。但为了追求高品质、高保真,需要将音频输入设备的采样率设置在 40kHz 以上,这样才能完整地将原始信号保留下来。例如我们平时听的数字音乐,一般其采样率都是 44.1k、48k 等,以确保其音质的无损。

采集到的数据再经过量化、编码,最终形成数字信号,这就是音频设备所要完成的工作。在量化和编码的过程中,采样大小(保存每个采样的二进制位个数)决定了每个采样最大可以表示的范围。如果采样大小是 8 位,则它表示的最大值是就是 2^8-1,即 255;如果是 16 位,则其表示的最大数值是 65535。

视频设备

至于视频设备,则与音频输入设备很类似。当实物光通过镜头进行到摄像机后,它会通过视频设备的模数转换(A/D)模块,即光学传感器, 将光转换成数字信号,即 RGB(Red、Green、Blue)数据。 获得 RGB 数据后,还要通过 DSP(Digital Signal Processer)进行优化处理,如自动增强、白平衡、色彩饱和等都属于这一阶段要做的事情。 通过 DSP 优化处理后,就得到了 24 位的真彩色图片。因为每一种颜色由 8 位组成,而一个像素由 RGB 三种颜色构成,所以一个像素就需要用 24 位表示,故称之为24 位真彩色。 另外,此时获得的 RGB 图像只是临时数据。因最终的图像数据还要进行压缩、传输,而编码器一般使用的输入格式为 YUV I420,所以在摄像头内部还有一个专门的模块用于将 RGB 图像转为 YUV 格式的图像。 那什么是 YUV 呢?YUV 也是一种色彩编码方法,主要用于电视系统以及模拟视频领域。它将亮度信息(Y)与色彩信息(UV)分离,即使没有 UV 信息一样可以显示完整的图像,只不过是黑白的,这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。

非编码帧(解码帧)与编码帧

非编码帧

我们知道,在几张空白的纸上画同一个物体,并让物体之间稍有一些变化,然后连续快速地翻动这几张纸,它就形成了一个小动画。 音视频播放器就是利用这样的原理来播放音视频文件的。当你要播放某个视频文件时,播放器会按照一定的时间间隔连续地播放从音视频文件中解码后的视频帧,这样视频就动起来了。同理,播放从摄像头获取的视频帧也是如此,只不过从摄像头获取的本来就是非编码视频帧,所以就不需要解码了。

通过上面的描述,你应该能得到以下两点信息:

  • 播放的视频帧之间的时间间隔是非常小的。如按每秒钟 20 帧的帧率计算,每帧之间的间隔是 50ms。
  • 播放器播的是非编码帧(解码后的帧),这些非编码帧就是一幅幅独立的图像。

从摄像头里采集的帧或通过解码器解码后的帧都是非编码帧。非编码帧的格式一般是 YUV 格式或是 RGB 格式。

编码帧

相对于非编码帧,通过编码器(如 H264/H265、VP8/VP9)压缩后的帧称为编码帧。这里我们以 H264 为例,经过 H264 编码的帧包括以下三种类型。

  • I 帧:关键帧。压缩率低,可以单独解码成一幅完整的图像。
  • P 帧:参考帧。压缩率较高,解码时依赖于前面已解码的数据。
  • B 帧:前后参考帧。压缩率最高,解码时不光依赖前面已经解码的帧,而且还依赖它后面的 P 帧。换句话说就是,B 帧后面的 P 帧要优先于它进行解码,然后才能将 B 帧解码。

拍照的过程其实是从连续播放的一幅幅画面中抽取正在显示的那张画面。

共享桌面的基本原理

桌面也可以当作一种特殊的视频数据来看待

  • 对于共享者,每秒钟抓取多次屏幕(可以是 3 次、5 次等),每次抓取的屏幕都与上一次抓取的屏幕做比较,取它们的差值,然后对差值进行压缩;如果是第一次抓屏或切幕的情况,即本次抓取的屏幕与上一次抓取屏幕的变化率超过 80% 时,就做全屏的帧内压缩,其过程与 JPEG 图像压缩类似(有兴趣的可以自行学习)。最后再将压缩后的数据通过传输模块传送到观看端;数据到达观看端后,再进行解码,这样即可还原出整幅图片并显示出来。
  • 对于远程控制端,当用户通过鼠标点击共享桌面的某个位置时,会首先计算出鼠标实际点击的位置,然后将其作为参数,通过信令发送给共享端。共享端收到信令后,会模拟本地鼠标,即调用相关的 API,完成最终的操作。一般情况下,当操作完成后,共享端桌面也发生了一些变化,此时就又回到上面共享者的流程了

对于共享桌面,很多人比较熟悉的可能是RDP(Remote Desktop Protocal)协议,它是 Windows 系统下的共享桌面协议;还有一种更通用的远程桌面控制协议,VNC(Virtual Network Console),它可以实现在不同的操作系统上共享远程桌面,像 TeamViewer、RealVNC 都是使用的该协议。

以上的远程桌面协议一般分为桌面数据处理与信令控制两部分。

  • 桌面数据:包括了桌面的抓取 (采集)、编码(压缩)、传输、解码和渲染。
  • 信令控制:包括键盘事件、鼠标事件以及接收到这些事件消息后的相关处理等。

webrtc

WebRTC(Web 实时通信)是一种使 Web 应用程序和站点能够捕获和选择性地流式传输音频或视频媒体,以及在浏览器之间交换任意数据的而无需中间件的技术。WebRTC 的一系列标准使得在不需要用户安装插件或任何其他第三方软件的情况下,可以实现点对点数据共享和电话会议。

基本架构

上图大概可以分为 4 部分,即两个 WebRTC 终端(上图中的两个大方框)、一个 Signal(信令)服务器和一个 STUN/TURN 服务器。

  • WebRTC 终端:负责音视频采集、编解码、NAT 穿越、音视频数据传输。
  • Signal 服务器:负责信令处理,如加入房间、离开房间、媒体协商消息的传递等。
  • STUN/TURN 服务器:负责获取 WebRTC 终端在公网的 IP 地址,以及 NAT 穿越失败后的数据中转。

基于 WebRTC 进行音视频通话的基本流程

当一端(WebRTC 终端)进入房间之前,它首先会检测自己的设备是否可用。如果此时设备可用,则进行音视频数据采集。采集到的数据一方面可以做预览,也就是让自己可以看到自己的视频;另一方面,可以将其录制下来保存成文件,等到视频通话结束后,上传到服务器让用户回看之前的内容。

在获取音视频数据就绪后,WebRTC 终端要发送 “加入” 信令到 Signal 服务器。Signal 服务器收到该消息后会创建房间。在另外一端,也要做同样的事情,只不过它不是创建房间,而是加入房间了。待第二个终端成功加入房间后,第一个用户会收到 “另一个用户已经加入成功” 的消息。

此时,第一个终端将创建 “媒体连接” 对象,即 RTCPeerConnection,并将采集到的音视频数据通过 RTCPeerConnection 对象进行编码,最终通过 P2P 传送给对端。当然,在进行 P2P 穿越时很有可能失败。所以,当 P2P 穿越失败时,为了保障音视频数据仍然可以互通,则需要通过 TURN 服务器进行音视频数据中转。

这样,当音视频数据来到对端后,对端首先将收到的音视频数据进行解码,最后再将其展示出来,这样就完成了一端到另一端的单通。如果双方要互通,那么,两方都要通过 RTCPeerConnection 对象传输自己一端的数据,并从另一端接收数据。

代码示例

获取默认音视频设备流,获取设备列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Camera and Audio Access</title>
    <style>
        .filter {
            margin-top: 10px;
        }
    </style>
</head>
<body>
    <h1>Access Your Camera and Microphone</h1>
    <video id="video" autoplay playsinline></video>
    <canvas id="canvas" style="display: none;"></canvas>
    <button id="capture" style="display: block; margin-top: 10px;">Take Photo</button>
    <select id="filter" class="filter">
        <option value="none">None</option>
        <option value="grayscale(100%)">Grayscale</option>
        <option value="sepia(100%)">Sepia</option>
        <option value="invert(100%)">Invert</option>
        <option value="brightness(150%)">Brightness</option>
        <option value="contrast(200%)">Contrast</option>
    </select>
    <button id="download" style="display: block; margin-top: 10px;">Download Photo</button>
    <img id="photo" alt="Captured Photo" style="display: block; margin-top: 10px;">
    <p id="error" style="color: red;"></p>
    <h2>Available Media Devices</h2>
    <ul id="device-list"></ul>

    <script>
        // Get the video element and other DOM elements
        const videoElement = document.getElementById('video');
        const canvasElement = document.getElementById('canvas');
        const photoElement = document.getElementById('photo');
        const captureButton = document.getElementById('capture');
        const downloadButton = document.getElementById('download');
        const filterSelect = document.getElementById('filter');
        const errorElement = document.getElementById('error');
        const deviceListElement = document.getElementById('device-list');

        // Request access to camera and microphone
        async function getMedia() {
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    video: true, // Request video
                    audio: true  // Request audio
                });

                // Display the video stream
                videoElement.srcObject = stream;

                // After granting access, list available media devices
                listMediaDevices();
            } catch (err) {
                // Handle errors
                console.error('Error accessing media devices:', err);
                errorElement.textContent = `Error: ${err.message}`;
            }
        }

        // List all available media devices
        async function listMediaDevices() {
            try {
                const devices = await navigator.mediaDevices.enumerateDevices();

                // Clear the device list
                deviceListElement.innerHTML = '';

                // Add each device to the list
                devices.forEach(device => {
                    const listItem = document.createElement('li');
                    listItem.textContent = `${device.kind}: ${device.label || 'Unnamed Device'} (ID: ${device.deviceId})`;
                    deviceListElement.appendChild(listItem);
                });
            } catch (err) {
                console.error('Error enumerating devices:', err);
                errorElement.textContent = `Error listing devices: ${err.message}`;
            }
        }

        // Apply filter to video element
        filterSelect.addEventListener('change', () => {
            videoElement.style.filter = filterSelect.value;
        });

        // Capture photo from video stream
        captureButton.addEventListener('click', () => {
            const context = canvasElement.getContext('2d');
            canvasElement.width = videoElement.videoWidth;
            canvasElement.height = videoElement.videoHeight;
            context.filter = filterSelect.value;
            context.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
            const dataUrl = canvasElement.toDataURL('image/png');
            photoElement.src = dataUrl;
        });

        // Download the captured photo
        downloadButton.addEventListener('click', () => {
            if (photoElement.src) {
                const link = document.createElement('a');
                link.href = photoElement.src;
                link.download = 'photo.png';
                link.click();
            } else {
                alert('Please capture a photo first!');
            }
        });

        // Call the function to get media devices after user interaction
        getMedia();
    </script>
</body>
</html>

MediaDeviceInfo 表示的是每个输入 / 输出设备的信息。包含以下三个重要的属性:

  • deviceID,设备的唯一标识;
  • label,设备名称;
  • kind,设备种类,可用于识别出是音频设备还是视频设备,是输入设备还是输出设备。

设备检测的方法

如果我们能从指定的设备上采集到音视频数据,那说明这个设备就是有效的设备。我们在排查设备问题的时候,就可以利用上面的方法,对每个设备都一项一项进行检测,即先排查视频设备,然后再排查音频设备。因此,需要调用两次 getUserMedia API 进行设备检测。

  • 第一次,调用 getUserMedia API 只采集视频数据并将其展示出来。如果用户能看到自己的视频,说明视频设备是有效的;否则,设备无效,可以再次选择不同的视频设备进行重新检测。
  • 第二次,如果用户视频检测通过了,再次调用 getUserMedia API 时,则只采集音频数据。由于音频数据不能直接展示,所以需要使用 JavaScript 中的 AudioContext 对象,将采集到的音频计算后,再将其绘制到页面上。这样,当用户看到音频数值的变化后,说明音频设备也是有效的。
本文由作者按照 CC BY 4.0 进行授权