音视频概念铺垫

视频

视频的播放原理:多张图片在短时间内播放,人眼就会认为是一段连贯的动作,以前的胶片电影,还有小时候玩过的快速翻页就能看动画的小书……

视频的一些属性

  • 分辨率:屏幕是由一个个像素点组成的,我们常见的1080p,是指屏幕竖直方向有1080个像素,共有1920列,一共207万像素。2K,2560x1440,共369万像素。
  • 比特率:码率,也叫比特率,帧率是1S播放多少帧,类比一下,比特率就是1s的视频有多少bit。这个参数决定了视频是否清晰。

常见视频格式

常见的视频格式主要有:.mov、.avi、.mpg、.vob、.mkv、.rm、.rmvb等,之所以会有这么多种视频格式,是因为他们使用了不同的方式来封装视频,所以他们具有各自的特色。

他们的主要特点如下

视频文件格式 视频封装格式 释义 发行公司
.avi AVI(Audio Video Interleave) 图像质量好,但体积过于庞大,压缩标准不统一,存在高低版本兼容问题。 MicroSoft.1992
.wmv WMV(Windows Media Video) 可边下载边播放,很适合网上播放和传输 MicroSoft.2003
.mpg .mpeg .mpe .dat .vob .asf .3gp .mp4 MPEG(Moving Picture Experts Group) 有三个压缩标准,分别是 MPEG-1、MPEG-2、和 MPEG-4,它为了播放流式媒体的高质量视频而专门设计的,以求使用最少的数据获得最佳的图像质量。 运动图像专家组.1998
.mkv Matroska 一种新的视频封装格式,它可将多种不同编码的视频及 16 条以上不同格式的音频和不同语言的字幕流封装到一个 Matroska Media 文件当中。 Matroska.2002
.rm、.rmvb Real Video 用户可以使用 RealPlayer 根据不同的网络传输速率制定出不同的压缩比率,从而实现在低速率的网络上进行影像数据实时传送和播放。 Real Networks
.mov QuickTime File Format 默认的播放器是苹果的 QuickTime。这种封装格式具有较高的压缩比率和较完美的视频清晰度等特点,并可以保存 alpha 通道。 Apple.1998
.flv Flash Video 由 Adobe Flash 延伸出来的一种网络视频封装格式。这种格式被很多视频网站所采用。最常见的是用来搭配rmtp Adobe.2005

「视频封装格式」= 视频 + 音频 +「视频编解码方式」 等信息的容器。

常见的解码方式

  • .H26x系列 由国际电传视讯联盟远程通信标准化组织(ITU-T)主导,包括 H.261、H.262、H.263、H.264、H.265。

    • .H261 用于老的视频会议或视频电话系统,之后的所有标准都基于它设计的。
    • .H262 等同于 MPEG-2 第二部分,使用在 DVD、SVCD 和大多数数字视频广播系统和有线分布系统中。
    • .H263 主要用于视频会议、视频电话和网络视频相关产品。比它之前的视频编码标准在性能上有了较大的提升。尤其是在低码率端,它可以在保证一定质量的前提下大大的节约码率。
    • .H264 等同于 MPEG-4 第十部分,也被称为高级视频编码(Advanced Video Coding,简称 AVC),是一种视频压缩标准,一种被广泛使用的高精度视频的录制、压缩和发布格式。该标准引入了一系列新的能够大大提高压缩性能的技术,并能够同时在高码率端和低码率端大大超越以前的诸标准。
    • .H265 高效率视频编码(High Efficiency Video Coding,简称 HEVC)是一种视频压缩标准,是 H.264 的继任者。HEVC 被认为不仅提升图像质量,同时也能达到 H.264 两倍的压缩率(等同于同样画面质量下比特率减少了 50%),可支持 4K 分辨率甚至到超高画质电视,最高分辨率可达到 8192×4320(8K 分辨率),这是目前发展的趋势。
  • MPEG系列 由国际标准组织机构(ISO)下属的运动图象专家组(MPEG)开发。

    • MPEG-1 第二部分,主要使用在 VCD 上,有些在线视频也使用这种格式。该编解码器的质量大致上和原有的 VHS 录像带相当。
    • MPEG-2 第二部分,等同于 H.262,使用在 DVD、SVCD 和大多数数字视频广播系统和有线分布系统中。
    • MPEG-4 第二部分,可以使用在网络传输、广播和媒体存储上。比起 MPEG-2 第二部分和第一版的 H.263,它的压缩性能有所提高。
    • MPEG-4 第十部分,等同于 H.264,是这两个编码组织合作诞生的标准。

音频

声音信号转为数字信号

  • 采样:把时间连续的模拟信号在时间轴上离散化,在某些特定的时刻获取声音信号幅值,其时间间隔称为采样周期,倒数为采用频率;
  • 量化:把采样后连续取值的每个样本转换为离散值表示,即对样本进行A/D转换(模数转换);量化后的样本用二进制数来表示,二进制位数即为量化精度(如用1个字节表示,样本的取值范围是0-255,则精度是1/256);
  • 编码:以上处理后得到的数字形式的信息,为了便于存储、处理和传输,进行压缩处理。

数字信号的主要参数

  • 采样频率:表示每秒内采样的次数,常用为44.1KHz、22.05KHz、11.05KHz;
  • 量化位数:度量声音波形幅度的精度,一般为8位、12位或16位;
  • 声道数目:N声道一次产生N组声音波形数据(基于不同的位置)。

如果一段10s的音频,其采用频率是44.KHz,量化精度是16位,采用双声道,则其数据量为44.1Kx16bx2x10s。

常见音频格式

音频格式 特点 音质\压缩 发行公司
MP3 最常见的音频封装格式,能够在音质丢失很小的情况下把文件压缩到更小的程度,每分钟音频大约在1M左右;缺点是没有高频部分 较好\高 Fraunhofer-Gesellschaft.1991
WMA 具有比MP3更好的压缩率,大小大约是MP3的一半,可以防止拷贝和限制播放次数,防盗版方面具有独特的优势 一般\高 MicroSoft
WAV 最早的数字音频格式,支持多种音频位数、采样频率和声道,采用44.1kHz的采样频率,音质与CD相差无几,需要存储空间大 好\低 MicroSoft.1991
AAC 是MPEG-2规范的一部分,压缩能力远超MP3,AAC可以在比MP3文件缩小30%的前提下提供更好的音质 较好\极高 Fraunhofer IIS-A、杜比和AT&T共同开发
MP3Pro MP3格式的升级版本,在保持相同的音质下同样可以把声音文件的文件量压缩到原有MP3格式的一半大小 较好\极高 瑞典Coding科技公司
VQF 相同情况下压缩后VQF的文件体积比MP3小30%~50%,但VQF未公开技术标准,至今未能流行开来。 较好\极高 YAMAHA和NTT共同开发
FLAC 无损音频压缩编码,不会破坏原有的音质 极好\一般 MicroSoft.1991
APE 流行的数字音乐文件格式之一,APE是一种无损压缩音频技术。与FLAC相比,体积较小。编码速度偏慢 极好\高 Matthew T. Ashland
MID 数字化乐器接口,常见的MIDI键盘等编曲乐器都靠这个格式来传输 YAMAHA、ROLAND、KAWAI等
OGG 新的音频压缩格式,支持多声道,完全免费,目前最好的有损格式之一 一般/

小知识:20kHz是人耳能够听到的声音信号的带宽,根据采样定理,要通过数字信号(如CD,mp3,wav等音频文件)恢复出原始的声音信号,采样速率至少为带宽的2倍,即40kHz。而使用44kHz比40kHz多了10%,是因为这样能够简化耳机中的滤波器设计,且使得滤波能够滤除更多的噪音,从而提高耳机的音质,让你基本听不到杂音。

音视频通讯原理

NAT

网络地址转换协议。由于IPv4地址过于稀缺,不足以分配给每一台设备一个IP,本就不多的IP地址被列强占据大部分,再扣去保留地址,剩下的寥寥无几。不过好在有NAT,可以支撑我们能在因特网中冲浪🏄🏻。

所有同一个局域网内部的设备共用一个公网IP,在内部通过映射来区分不同设备的网络请求。

从表面上来看,NAT可以分为三大类型:静态NAT(基本)、动态地址NAT(基本)、地址端口转换NAPT(变种)

  • 静态NAT:将内部私网地址与公网地址进行一对一的转换,每个内部地址的转换都是确定的
  • 动态NAT:将内部私网地址与公网地址进行一对一的转换,但是动态地址转换是从合法的地址池中动态选择一个未使用的地址来对内部私有地址进行转换。和静态NAT的明显区别是动态NAT有多个公网IP
  • 地址端口转换NAPT:也是一种动态转换,而且内部多个地址被转换成同一个合法公网地址,使用不同的端口号来区分不同的设备和进程。

基本型NAT可以直接连接,变种NAT是无法直连的。

在NAPT中,又可以将NAT分为四类:全锥型NAT、地址受限锥型NAT、端口受限锥型NAT、对称NAT(基本的NAT已经非常少见了,NAPT才是我们常说的NAT,这里的NAT其实都是NAPT)

  • 全锥型NAT:所有从同一个内网的(IP,端口)发送出来的请求都会被映射到同一个外网(IP,端口),且任何一个外网主机都可以通过访问映射后的公网地址,实现访问位于内网的主机设备功能。外网主机可以主动连接内网主机。

    image-20210907202726150

  • 地址受限锥型NAT:所有从同一个内网的(IP,端口)发送出来的请求都会被映射到通过一个外网(IP,端口),但与全锥型不同点在于:生成的映射表项与目的IP有关,只有符合要求的目的IP(要访问的公网服务器IP)才可以通讯。此NAT还有个特点:不能主动连接内网中的主机地址,连接必须由内网地址发起。比起全锥型NAT多了地址限制。

    image-20210907202749544

  • 端口受限锥型NAT:所有从同一个内网的(IP,端口)发送出来的请求都会被映射到通过一个外网(IP,端口),但是在地址受限锥型NAT基础上增加了端口的限制。

    地址受限锥型NAT时,只有内网主机主动连接的公网主机才可与之进行通讯,而不用担心端口号是否与请求的端口相同。端口受限锥型NAT除了IP限制外,增加了端口限制。

    image-20210907202814932

  • 对称NAT:所有从同一个内网(IP,端口)发送到同一个目的IP和端口的请求都会被映射到同一个IP和端口。(SIP,Sport, DIP, Dport)只要有一个发生变化都会使用不同的映射条目,即此NAT映射与报文四元组绑定。(较其他类型而言安全性较高,多用于3G、4G、公共WIFI等)

    image-20210908093554547

由于NAT的存在,我们可以在网络世界中遨游,也正是由于NAT的存在,我们无法直接在两台设备之间进行数据通讯,所以就有了——“打洞”。

P2P打洞

所谓的打洞就是STUN,Session Traversal Utilities for NAT,NAT会话穿越应用程序。STUN是一种网络协议,它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间建立UDP通信。

当然并不是所有的NAT后面的设备都可以进行打洞,先来看表,至于原因,再了解打洞原理之后就明白了。

本端(NAT类型) 对端(NAT类型) 是否可以打洞
全锥型 全锥型
全锥型 地址限制锥型
全锥型 端口限制锥型
全锥型 对称型
地址限制锥型 地址限制锥型
地址限制锥型 端口限制锥型
地址限制锥型 对称型
端口限制锥型 端口限制锥型
端口限制锥型 对称型
对称型 对称型

关于UDP打洞的原理,先来看图,后面的许多内容都会基于这张图进行延伸。

image-20210908170527062

B机器想要和C机器之间进行数据交换,起大致过程如下

  1. B、C机器都要在STUN服务器上进行注册,将各自的公网地址保存在STUN服务器;
  2. B、C机器分别从STUN获取对端的ip+端口;
  3. B机器向C的地址发送数据包,此时NAT2并不知道这条报文该发到局域网中的哪一台设备,所以丢弃此条数据,虽然这条数据并没有发送成功,但是,在NAT1中已经打开了B到C的口子,接受到来自C的数据都转发到B;
  4. C机器重复步骤3;
  5. 此时,B、C两台机器都打通了与对端都打通了与对端进行通讯的口子,这时一条穿越NAT的隧道已经打通,两端可以进行数据通讯。

在了解了打洞的过程之后,再来看一下不能打洞的情况(为了看起来方便省去了NAT层,其实是有NAT的),A所在的是端口限制锥型NAT,B所在的是对称型NAT。

image-20210909092340319

  1. 首先双方向STUN注册自己,然后获取对端的地址;
  2. 分别向对端发送数据报,之前的NAT分类中说到了对称NAT的特点,所以B向A设备发送数据时的地址和注册在STUN中的地址是不一致的,此时B所在的NAT允许{A, b}和{M, h}之间进行数据交互;A设备向B在STUN注册的地址发送数据,此时A所在的NAT允许{A, b}和{M, n}之间进行数据交互;
  3. 所以现在的情况是,A、B所在NAT均因接收到的消息与白名单中端口不匹配而被丢弃

所以这种情况下是无法进行打洞的,同样的道理,双方均为对称型NAT时也是无法进行通讯的。这两种情况无法使用P2P直接连接,但是可以通过一台中继服务器(TURN)进行数据转发,这就要求TURN具有较高的处理能力和网络带宽。

image-20210911114733740

WebRTC连接过程

经过前面几部分的铺垫,你应该对P2P音视频互动的过程有了一个大概的了解,有可能你会觉得过程比较繁琐,甚至涉及到了网络底层。但是,不要担心,WebRTC已经帮我们做了很多的事情,让我们在音视频开发时变得轻而易举。那么WebRTC到底是什么呢?

WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。

WebRTC并不是Google原来自己的技术。在2010年,Google以大约6820万美元收购了VoIP软件开发商Global IP Solutions公司,并因此获得了该公司拥有的WebRTC技术。如今,互联网的音频、视频通信服务技术一般都是私有技术,如Skype, 需要通过安装插件或者桌面客户端来实现通信功能。Google希望Web开发人员能够直接在浏览器中创建视频或语音聊天应用,Global IP Solutions公司之前已经针对Android、Windows Mobile、iPhone制作了基于WebRTC的移动客户端。Google此次将WebRTC开源出来,就是希望浏览器厂商能够将该技术直接内嵌到浏 览器中,从而方便Web开发人员。

概念补充

除了之前提到的概念之外,再来补充几个相关的概念

交互式连接设施

交互式连接设施(Interactive Connectivity Establishment, ICE):允许你的浏览器和对端浏览器建立连接的协议框架。在实际的网络当中,有很多原因能导致简单的从A端到B端直连不能如愿完成。这需要绕过阻止建立连接的防火墙,给你的设备分配一个唯一可见的地址(通常情况下我们的大部分设备没有一个固定的公网地址),如果路由器不允许主机直连,还得通过一台服务器转发数据。你可能已经发现这个ICE就是我们之前STUN、TURN等连接方式的汇总,正式由于ICE框架,大大简化了我们的开发工作,ICE会按照优先级自动选择连接方式;

信令服务器

信令服务器(signal server):交换Peer之间的可通讯地址,通过STUN获取到自己的通讯地址之后,注册在信令服务器上就可以交换设备之间的通讯地址,从而完成P2P连接;

会话描述协议

会话描述协议(Session Description Protocol, SDP):描述多媒体连接内容的协议,例如分辨率,格式,编码,加密算法等。在开始P2P连接之前需要先协商双方共同支持的会话协议。当用户对另一个用户启动WebRTC调用时,将创建一个称为提议(offer)的特定描述。 该描述包括有关呼叫者建议的呼叫配置的所有信息。 接收者然后用应答(answer)进行响应,这是他们对呼叫结束的描述。 以这种方式,两个设备彼此共享以便交换媒体数据所需的信息;一个SDP描述的内容是这样的(“=”两边不能有空格)

v=0
o=- 212360934117607227 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
a=rtpmap:111 opus/48000/2
......
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 121 127 120 125 107 108 109 35 36 124 119 123 118 114 115 116
a=rtpmap:96 VP8/90000
......

一个SDP描述由一个会话和多个媒体描述组成,会话描述是v=开始到第一段媒体描述(1-4行),媒体描述是从m=到下一个媒体描述之前(5-6行——音频、8-9行——视频)。

会话级描述

会话描述字段比较多,最主要的有4个字段

  • v=:UDP的版本号,不包含次版本

  • o=:是一个会话发起者的描述

    o=

    • <username>:用户名,当不关心用户名时,可以用 “-” 代替 ;
    • <session id> :数字串,在整个会话中,必须是唯一的,建议使用 NTP 时间戳;
    • <version>:版本号,每次会话数据修改后,该版本值会递增;
    • <network type> :网络类型,一般为“IN”,表示“internet”;
    • <address type>:地址类型,一般为 IP4;
    • <address>:IP 地址
  • Session Name:表示一个会话

  • t=:开始时间和结束时间的描述,t=<start time> <stop time>,两个事件均为0时表示持久会话

WebRTC中的SDP

WebRTC下对SDP做了一些修改,在原有的基础上增加了一些其他描述

// 音频使用9端口收发; 
// UDP / TLS / RTP / SAVPF 表示使用 dtls / srtp 协议对数据加密传输;
m = audio 9 UDP / TLS / RTP / SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
// 网络描述, webRTC不使用
c = IN IP4 0.0.0.0
// 设置rtcp地址和端口, webRTC不使用
a = rtcp: 9 IN IP4 0.0.0.0
// 安全验证信息
a=ice-ufrag:duP8
a=ice-pwd:/7pIrSvgESATKPZUVzHhLQ0E
a=ice-options:trickle
// 音频流媒体描述
a=rtpmap:111 opus/48000/2
a=rtcp-fb:111 transport-cc
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
a=rtpmap:9 G722/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:106 CN/32000
a=rtpmap:105 CN/16000
a=rtpmap:13 CN/8000
a=rtpmap:110 telephone-event/48000
a=rtpmap:112 telephone-event/32000
a=rtpmap:113 telephone-event/16000
a=rtpmap:126 telephone-event/8000
媒体描述
  • m=:表示一个会话,m=<media> <port> <transport> <fmt list>
    • <media>:媒体类型,比如 audio/video 等;
    • <port>:端口;
    • <transport>:传输协议,有两种——RTP/AVP 和 UDP;
    • <fmt list>:媒体格式,即数据负载类型 (Payload Type) 列表。
  • a=*:a=<type>或者a=<type>:<value>,type有两种类型rtpmap和fmap

现在我们来完善一下之前网关那里的示意图,添加上这里的几个概念

image-20210917115942879

收集候选地址

所谓的收集候选地址,就是通过之前说到的STUN服务器来获取自身的可通讯地址。

类型 别名 如何传给对端 用法
主机候选项 host 信令服务器 从网卡中获取的本地传输地址,如果此地址位于NAT之后,则为内网地址
服务器反射候选项 srflx 信令服务器 从发送给Stun服务器的Binding检查中获取的传输地址。如果此地址位于NAT之后,则为最外层NAT的公网地址
对端反射候选项 prflx Stun Binding请求 从对端发送的Stun Binding请求获取的传输地址。这是一种在连接检查期间新发生的候选项
中继候选项 relay 信令服务器 媒体中继服务器的传输地址。通过使用TURN Allocate请求获取

交换候选地址

A通过信令服务器把第一步收集到的候选地址发送给B,B也将收集到的候选地址发送给A,A在接收到B的所有候选地址之后会将自身候选地址与对端候选地址进行全排列,存储到状态表中。比如A此时的host是192.168.0.100:60000、srflx是11.102.30.3:30110,B的host是192.168.0.15:10001、srflx是1.10.108.25:30110,状态表如下

本地网卡地址 对端地址 状态
192.168.1.105:60001 192.168.0.204:40001 未进行过Stun检查
172.16.40.6:60003 192.168.0.204:40001 未进行过Stun检查
192.168.1.105:60001 11.92.14.8:50002 未进行过Stun检查
172.16.40.6:60003 11.92.14.8:50002 未进行过Stun检查
192.168.1.105:60001 192.168.0.181:40003 未进行过Stun检查
172.16.40.6:60003 192.168.0.181:40003 未进行过Stun检查

此时所有的记录状态都是未进行STUN检查,下一步就会进行每条记录的STUN检查。

STUN检查

STUN检查的过程我们之前有过介绍,如有遗忘可以再温习一下,传送门

连接,启动媒体

STUN检查结束之后开始准备连接,此时P2PTransportChannel中的状态表已经记录了每条记录所需要花费的成本(涉及到很多因素,比如发出Stun请求到收到应答经过了的时间……)。

当有视频Rtp数据要发送时,检查状态表的第一条记录,如果判断出它的状态是发送就绪,就会用此Connection进行发送。否则直接放弃这个发送任务。也就是说,媒体模块的任务不会受连接状态的影响,只是在要发送是检查状态,如果未连接则放弃发送。

为确保NAT映射和过滤规则不在媒体会话期间超时,ICE会不断通过使用中的候选项对发送Stun连接检查。通俗来说也就是“轮询”,如果STUN的响应超时,则会在增加成本,体现在状态表中就是优先级降低,即P2PTransportChannel状态表是实时的。

WebRTC常用相关API

推荐使用WebRTC兼容库: adapter.js,用来抹平各个浏览器之间的差异

RTCPeerConnection相关

constructor构造函数

通过调用构造函数,返回一个RTCPeerConnection实例,表示本端与对端的一条连接。

const pc = new RTCPeerConnection(?configuration)

为了提高代码的健壮性,可以从多个属性检测构造函数:window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection

configuration(可选):新建连接的参数,可见MDN文档,常见的是添加iceServers,如

const configuration = {iceServers: [{urls: 'stuns:stun.example.org'}]};
const pc = new RTCPeerConnection(configuration);

不过我们一般使用手动候选ice添加居多

Event事件

onaddstream:收到addstream 事件时调用的事件处理器,当MediaStream 被远端机器添加到这条连接时,该事件会被触发。

onicecandidate:收到 icecandidate 事件时调用的事件处理器。当一个 RTCICECandidate 对象被添加时(setLocalDescription),这个事件被触发。

ontrack:收到track事件时调用,可以从event中获取视频流。

Method方法

createOffer:生成一个offer,它是一个带有特定的配置信息寻找远端匹配机器(peer)的请求。这个方法的前两个参数分别是方法调用成功以及失败的回调函数,可选的第三个参数是用户对视频流以及音频流的定制选项。用于发起方

createAnswer:在协调一条连接中的两端offer/answers时,根据从远端发来的offer生成一个answer。这个方法的前两个参数分别是方法调用成功以及失败时的回调函数,可选的第三个参数是生成的answer的可供选项。用于应答方

setLocalDescription:改变与连接相关的本地描述,分别将offer和answer作为参数传入。例如

const offer = await peer.createOffer();
await peer.setLocalDescription(offer);

const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);

setRemoteDescription:改变与连接相关的对端描述,将接收到对端的SDP作为参数传入

addIceCandidate:手动添加候选ICE,当本机当前页面的 RTCPeerConnection 接收到一个从远端页面通过信号通道发来的新的 ICE 候选地址信息,本机可以通过调用addIceCandidate() 来添加一个 ICE 代理。

close:关闭ICE代理,结束任何正在进行的 ICE 处理和任何活动的流。

createDataChannel:创建一个可以发送任意数据的数据通道(data channel)。常用于后台传输内容, 例如: 图像, 文件传输, 聊天文字, 游戏数据更新包, 等等。

const channel = pc.createDataChannel(label, ?options)

  • label:通道标识
  • options:配置项
    • ordered:信息到达顺序是否和发出顺序一致,默认为true;
    • negotiated:默认情况(false)下,一方使用createDataChannel创建通道,另一方使用ondatachannel事件监听,双方进行协商;或者(true)双方调用createDataChannel,使用协定的id;
    • id:创建通道的ID,用于双方协定通道,取值范围0-65534;
    • maxRetransmits:尝试在不可靠模式下重传数据的次数,默认为空;
    • maxPacketLifeTime:不可靠模式下传输消息的最大毫秒数,默认为空;
const dataChannel = RTCPeerConnection.createDataChannel(label, ?options);
channel.onopen = function(event) {
    channel.send('Hi back!'); // 发送
  }
  channel.onmessage = function(event) { // 接收
    console.log(event.data);
  }
}

addTrack:将一个新的媒体音轨添加到一组音轨中,这些音轨将被传输给另一个对等点。

// 获取视频流
let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
// 将视频流添加到track
stream.getTracks().forEach(track => {
  peer.addTrack(track, stream);
});

getStats:获取WebRTC的状态,发送/接收包的数量、丢包数……可以传入参数( MediaStreamTrack )来控制要获取的数据类型

const peer = new PeerConnection();
setInterval(async () => {
  const res = await peer.getStats()
  res.forEach(report => console.log(report))
}, 1000)

image-20211013135817441

除了RTCPeerConnection实例上有getStats方法,sender和reveiver上也有getStats方法,他们获取的report只有发送/接收的部分,是整体与局部的关系

peer.getSenders()[0].getStats()

media相关

Event事件

ondevicechange:每当媒体设备(例如相机,麦克风或扬声器)连接到系统或从系统中移除时,devicechange事件就会被触发,这时可以使用enumerateDevices来更新设备列表

Method方法(需要授权)

enumerateDevices:请求一个可用的媒体输入和输出设备的列表,例如麦克风,摄像机,耳机设备等。 返回的 Promise完成时,会带有一个描述设备的数组。

navigator.mediaDevices.enumerateDevices().then(device => {
    console.log(device)
})

image-20211009151209987

如果两个设备的groupId相同,说明两个设备是同一台,比如带麦克风的耳机,能检测到输入设备和输出设备,他们两个的groupId相同

getDisplayMedia:提示用户选择捕获的屏幕,返回值是一条视频轨道和可选的音频轨道(参数是媒体流约束,具体描述可见MDN

// 已经在HTML中声明video标签
const localVideo = document.querySelector("video");

/**
 * @description: 向video标签中注入视频流
 * @param {*} mediaStream
 * @return {*}
 */
function gotLocalMediaStream(mediaStream) {
  console.log(mediaStream);
  localVideo.srcObject = mediaStream;
}

function screenShare() {
  // 屏幕捕捉
  navigator.mediaDevices.getDisplayMedia({
    video: {
      cursor: "always", // 总是显示光标
      width: 1920, // 宽度
      height: 1080, // 高度
      frameRate: 60 // 帧率
    },
    audio: true
  })
    .then(gotLocalMediaStream)
}

image-20211009155724918

这里如果是捕获全屏和应用窗口会无法获取声音,捕获浏览器内的标签是可以获取声音的,这里不清楚具体原因,待日后再研究一下

getDisplayMedia:在获取用户授权后,调度用户的摄像机和麦克风,返回视频轨道和音频轨道(均可以来自虚拟源),参数同样是媒体流约束

function photo() {
  navigator.mediaDevices
    .getUserMedia({
      video: { // 视频
        width: 640,
        height: 480,
        frameRate: 15,
        facingMode: 'enviroment', // 设置为后置摄像头
        /*'user': 前置摄像头
          'environment': 后置摄像头
          'left': 视频源面向用户但在他们的左边,例如一个对准用户但在他们的左肩上方的摄像机
          'right': 视频源面向用户但在用户的右边,例如,摄像机对准用户但在他们的右肩上*/
        deviceId: undefined // 设备id
      },
      audio: true // 音频
    })
    .then(gotLocalMediaStream)
    .catch((error) => console.log("navigator.getUserMedia error: ", error));
}

MediaRecorder相关

constructor构造函数

创建一个新的MediaRecorder对象,对指定的MediaStream 对象进行录制,支持的配置项包括设置容器的MIME 类型 (例如"video/webm" 或者 "video/mp4")和音频及视频的码率或者二者同用一个码率

Const mediaRecorder = new MediaRecorder(stream, ?options);

  • Stream: 要录制的流
  • Options:
    • mimeType: 为新构建的 MediaRecorder 指定录制容器的MIME类型. 在应用中通过调用 MediaRecorder.isTypeSupported() 来检查浏览器是否支持此种mimeType
    • audioBitsPerSecond: 指定音频的比特率。
    • videoBitsPerSecond: 指定视频的比特率。
    • bitsPerSecond: 指定音频和视频的比特率。此属性可以用来指定上面两个属性。如果上面两个属性只有其中之一和此属性被指定,则此属性可以用于设定另外一个属性。

Event事件

ondataavailable:当要录制的流有数据时触发

onstart/onpause/onresume/onstop:当开始、暂停、继续、停止事件触发时执行

Method方法

isTypeSupported(static):返回一个Boolean 值,来表示设置的MIME type 是否被当前用户的设备支持。

pause:暂停录制

resume:继续暂停处继续录制

start:开始录制,调用时可以通过给timeslice参数设置一个毫秒值,如果设置这个毫秒值,那么录制的媒体会按照你设置的值进行分割成一个个单独的区块, 而不是以默认的方式录制一个非常大的整块内容。

stop:停止录制,返回一个录制的Blob。

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(window.stream, options);
  } catch (e) {
    console.error('Failed to create MediaRecorder:', e);
    return;
  }
  //当有音视频数据来了之后触发该事件
  mediaRecorder.ondataavailable = handleDataAvailable;
  //开始录制
  mediaRecorder.start(10);
}

实战:一对一音视频通讯(带录屏)

服务端与信令

只有虽然说WebRTC支持P2P,但是需要有一台信令服务器来交换双方的SDP,现在我们就来用Node实现一个信令服务器。

这里选用阿里开源的MidwayJS服务端框架,你可以选择更加轻量级的Express或者Koa框架,都可以通过第三方中间件来实现相同的功能,我这里选择Midway完全是个人偏好。

如果感兴趣可以看一下Midway的文档,或者你钟爱Express可以使用Express+Express-ws来实现完全相同的效果。

客户端能力:

  • 提供页面HTML;
  • SDP交换;

使用ejs来进行页面渲染

使用模板引擎中间件来进行ejs模板的渲染工作,将路由根目录绑定到页面渲染

这一块相对简单,可以在使用的node框架的官网或者社区找到中间件的文档,根据文档进行配置,然后就可以开始编码工作了(代码非常简单,因为中间件帮我们处理了大部分事情)

@Provide()
@Controller('/')
export class HomeController {
  @Inject()
  ctx: Context;

  @Get('/')
  async home() {
    await this.ctx.render('home.ejs');
  }
}

使用SocketIO作为socker-server来进行SDP交换

socketIO作为一个非常出名的socket框架,其内部提供了非常丰富的API,足够支撑起我们日常开发的大部分需要,这里具体的配置方法就不详写了,可以去官网了解,或许以后我会出相关的教程。

@Provide()
@WSController('/')
export class IndexSocketController {
  @Inject()
  ctx: Context;

  @App()
  socket: Application;

  @OnWSConnection()
  @WSEmit('connection')
  // 连接时触发,向客户端提交connection事件
  async onConnectionMethod() {
    this.ctx.logger.info('on client connect', this.ctx.id);
    return 'connection';
  }

  @OnWSMessage('join')
    // 收到消息标识为join时执行
  async joinRoom(roomId: string, user: string) {
    if ((await this.ctx.to(roomId).allSockets()).size >= 2) {
      // 如果房间内的用户数量大于等于二,不可以再加入新的成员
      // 并向客户端提交full事件
      this.ctx.emit('full', `${roomId} is full!`);
    } else {
      await this.ctx.join(roomId);
      this.ctx.to(roomId).emit('join', `user[ ${user} ] join this room!`);
      // 加入房间之后向房间内其他成员提交join事件
      // 向用户自身提交joined事件
      this.ctx.emit('joined', `join in ${roomId}!`);
    }
  }

  @OnWSMessage('quit')
  @WSEmit('result')
  async quitRoom(roomId: string, user: string) {
    await this.ctx.leave(roomId);
    this.ctx.to(roomId).emit('quit', `user[ ${user} ] quit this room!`);
    return 'quit success';
  }

  @OnWSMessage('call')
  async call(roomId, data) {
    this.ctx.to(roomId).emit('sdp', data);
  }
}

(用注解开发真的爽“死”了)

image-20211017141932316

客户端

了解了之前的WebRTC相关API,并且有了信令服务器的加持,我们就可以来进行视频互动的开发了。

为了简化,我们直接就使用模板引擎来渲染视图,不再去单独使用前端框架创建项目了。部分代码借鉴了github的一个开源项目

首先来列举一下客户端的能力

  1. 基本的音视频通话;
  2. 录屏;

音视频聊天

进行音视频聊天又分为几个步骤,首先需要初始化ICE

// 初始化ICE
const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
!PeerConnection && message.error('浏览器不支持WebRTC!');
const peer = new PeerConnection();

peer.ontrack = e => {
  if (e && e.streams) {
    // message 是自定义的日志工具
    message.log('收到对方音频/视频流数据...');
    remoteVideo.srcObject = e.streams[0];
  }
};

peer.onicecandidate = e => {
  if (e.candidate) {
    message.log('搜集并发送候选人');
    socket.emit('call', roomId, JSON.stringify({
      type: 'ice',
      iceCandidate: e.candidate
    }));
  } else {
    message.log('候选人收集完成!');
  }
};

然后需要进行交换SDP,这里需要使用Socket来完成

const socket = io('http://localhost:7001')

socket.on('connect_error', () => {
  // 连接socket失败
  message.error('socket通道初始化失败')
})

// 消息处理
socket.on('connection', () => {
  // 连接之后加入房间
  socket.emit('join', roomId, username)
})
socket.on('sdp', e => {
  const { type, sdp, iceCandidate } = JSON.parse(e)
  if (type === 'answer') {
    peer.setRemoteDescription(new RTCSessionDescription({ type, sdp }));
  } else if (type === 'ice') {
    peer.addIceCandidate(iceCandidate);
  } else if (type === 'offer') {
    const resolve = confirm('接到视频请求,是否接听')
    resolve && startLive(new RTCSessionDescription({ type, sdp }));
  }
})

当双方都添加了对端的SDP之后就可以开始视频互动了,这里同一封装为startLive方法

async function startLive(offerSdp) {
  button.style.display = 'none'
  let stream;
  try {
    message.log('尝试调取本地摄像头/麦克风');
    stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    message.log('摄像头/麦克风获取成功!');
    localVideo.srcObject = stream;
  } catch {
    message.error('摄像头/麦克风获取失败!');
    return;
  }

  message.log(`------ WebRTC 流程开始 ------`);
  message.log('将媒体轨道添加到轨道集');
  stream.getTracks().forEach(track => {
    peer.addTrack(track, stream);
  });

  if (!offerSdp) {
    message.log('创建本地SDP');
    const offer = await peer.createOffer();
    await peer.setLocalDescription(offer);

    message.log(`传输发起方本地SDP`);
    socket.emit('call', roomId, JSON.stringify(offer));
  } else {
    message.log('接收到发送方SDP');
    await peer.setRemoteDescription(offerSdp);

    message.log('创建接收方(应答)SDP');
    const answer = await peer.createAnswer();
    message.log(`传输接收方(应答)SDP`);
    socket.emit('call', roomId, JSON.stringify(answer));
    await peer.setLocalDescription(answer);
  }
}

效果展示

showWebRtc

录屏

实现了视频聊天之后我们来添加录屏的功能

之前我们已经介绍了录屏相关的API,这里我们将其添加进我们现有的代码中即可

let buffer, mediaRecorder;

//当该函数被触发后,将数据压入到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)) {
    message.error(`${options.mimeType} is not supported!`);
    return;
  }
  try {
    //创建录制对象
    // stream 需要从将视频流保留到全局
    mediaRecorder = new MediaRecorder(stream, options);
  } catch (e) {
    message.error('Failed to create MediaRecorder:', e.message);
    return;
  }
  //当有音视频数据来了之后触发该事件
  mediaRecorder.ondataavailable = handleDataAvailable;
  //开始录制
  mediaRecorder.start(10);
}

// 停止录制
function stopRecord() {
  mediaRecorder.stop();
}

// 将录制的视频下载到本地
function downloadRecord() {
  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 = new Date().getTime() + '.webm';
  a.click();
}

这里只是做了演示,需要根据需求定制录屏的内容

多人音视频互动原理

Medooze

实战:多人音视频互动

直播系统架构


前端小白