
本文将教你如何通过声网视频 SDK 在 iOS 平台上实现一个视频通话应用。为此你需要先注册一个声网开发者账号,开发者每个月可获得 10000 分钟的免费使用额度,可实现各类实时音视频场景。
可能有些人,还不了解我们要实现的功能最后是怎样的。所以我们在 GitHub 上提供一个开源的基础视频通话示例项目,在开始开发之前你可以通过该示例项目体验视频通话的体验效果。
Agora 在 https://github.com/AgoraIO/Basic-Video-Call/tree/master/One-to-One-Video 上提供开源的实时音视频通话示例项目 Agora-iOS-Tutorial-Objective-C-1to1 与 Agora-iOS-Tutorial-Swift-1to1 。 
我们在这里要实现的是一对一的视频通话。你可以理解为是两个用户通过加入同一个频道,实现的音视频的互通。而这个频道的数据,会通过声网的 Agora SD-RTN 实时网络来进行低延时传输的。 下图展示在 App 中集成 Agora 视频通话的基本工作流程: 
声网 Agora SDK 的兼容性良好,对硬件设备和软件系统的要求不高,开发环境和测试环境满足以下条件即可:
• macOS 11.6 版本 • Xcode Version 13.1
• iPhone7 (iOS 15.3)
• 注册一个声网账号,进入后台创建 AppID 、获取 Token , • 下载声网官方最新的视频通话 SDK ;(视频通话 SDK 链接: https://docs.agora.io/cn/Video/downloads?platform=iOS )
a) 如需创建新项目, Xcode 里,打开 Xcode 并点击 Create a new Xcode project 。(创建 iOS 项目链接: https://developer.apple.com/documentation/xcode/creating-an-xcode-project-for-an-app ) b) 选择平台类型为 iOS 、项目类型为 Single View App ,并点击 Next 。 c) 输入项目名称( Product Name )、开发团队信息( Team )、组织名称( Organization Name )和语言( Language )等项目信息,并点击 Next 。 注意:如果你没有添加过开发团队信息,会看到 Add account… 按钮。点击该按钮并按照屏幕提示登入 Apple ID ,完成后即可选择你的 Apple 账户作为开发团队。 d) 选择项目存储路径,并点击 Create 。
选择如下任意一种方式获取最新版 Agora iOS SDK 。
方法一:使用 CocoaPods 获取 SDK a) 开始前确保你已安装 Cocoapods 。参考 Getting Started with CocoaPods 安装说明。( Getting Started with CocoaPods 安装说明链接: https://guides.cocoapods.org/using/getting-started.html#getting-started ) b) 在终端里进入项目根目录,并运行 pod init 命令。项目文件夹下会生成一个 Podfile 文本文件。 c) 打开 Podfile 文件,修改文件为如下内容。注意将 Your App 替换为你的 Target 名称。
方法二:从官网获取 SDK a) 前往 SDK 下载页面,获取最新版的 Agora iOS SDK ,然后解压。(视频通话 SDK 链接: https://docs.agora.io/cn/Video/downloads?platform=iOS ) b) 根据你的需求,将 libs 文件夹中的动态库复制到项目的 ./project_name 文件夹下( project_name 为你的项目名称)。 c) 打开 Xcode ,进入 TARGETS > Project Name > Build Phases > Link Binary with Libraries 菜单,点击 + 添加如下库(如:)。在添加 AgoraRtcEngineKit.framework 文件时,还需在点击 + 后点击 Add Other…,找到本地文件并打开。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RMM224bm-1663063848743)(upload://gOFc9CzhWzmOk7Ef4Dh0QVyu5kp.png)]
共需要添加 11 个库文件: i. AgoraRtcEngineKit.framework ii. Accelerate.framework iii. AudioToolbox.framework iv. AVFoundation.framework v. CoreMedia.framework vi. CoreML.framework vii. CoreTelephony.framework viii. libc++.tbd ix. libresolv.tbd x. SystemConfiguration.framework xi. VideoToolbox.framework 注意: 如需支持 iOS 9.0 或更低版本的设备,请在 Xcode 中将对 CoreML.framework 的依赖设为 Optional 。
d) 打开 Xcode ,进入 TARGETS > Project Name > General > Frameworks, Libraries, and Embedded Content 菜单。 e) 点击 + > Add Other… > Add Files 添加对应动态库,并确保添加的动态库 Embed 属性设置为 Embed & Sign 。添加完成后,项目会自动链接所需系统库。
注意:
Xcode 进入 TARGETS > Project Name > General > Signing 菜单,选择 Automatically manage signing ,并在弹出菜单中点击 Enable Automatic 。 
添加媒体设备权限 根据场景需要,在 info.plist 文件中,点击 + 图标开始添加如下内容,获取相应的设备权限:

// Objective-C // ViewController.h // 导入 AgoraRtcKit 类 // 自 3.0.0 版本起,AgoraRtcEngineKit 类名更换为 AgoraRtcKit // 如果获取的是 3.0.0 以下版本的 SDK ,请改用 #import <AgoraRtcEngineKit/AgoraRtcEngineKit.h> #import <AgoraRtcKit/AgoraRtcEngineKit.h> // 声明 AgoraRtcEngineDelegate ,用于监听回调 @interface ViewController : UIViewController <AgoraRtcEngineDelegate> // 定义 agoraKit 变量 @property (strong, nonatomic) AgoraRtcEngineKit *agoraKit; // Swift // ViewController.swift // 导入 AgoraRtcKit 类 // 自 3.0.0 版本起,AgoraRtcEngineKit 类名更换为 AgoraRtcKit // 如果获取的是 3.0.0 以下版本的 SDK ,请改用 import AgoraRtcEngineKit import AgoraRtcKit class ViewController: UIViewController { ... // 定义 agoraKit 变量 var agoraKit: AgoraRtcEngineKit? } // Objective-C // AppID.m // Agora iOS Tutorial Objective-C #import <Foundation/Foundation.h> NSString *const appID = <#Your App ID#>; // Swift // AppID.swift // Agora iOS Tutorial let AppID: String = Your App ID 本节介绍如何使用 Agora 视频 SDK 在你的 App 里实现视频通话的几个小贴士:
根据场景需要,为你的项目创建视频通话的用户界面。我们推荐你在项目中添加元素:本地视频窗口、远端视频窗口。 你可以参考以下代码创建一个基础的用户界面。
// Objective-C // ViewController.m // 导入 UIKit #import <UIKit/UIKit.h> @interface ViewController () // 定义 localView 变量 @property (nonatomic, strong) UIView *localView; // 定义 remoteView 变量 @property (nonatomic, strong) UIView *remoteView; @end @implementation ViewController ... - (void)viewDidLoad { [super viewDidLoad]; // 调用初始化视频窗口函数 [self initViews]; // 后续步骤调用 Agora API 使用的函数 [self initializeAgoraEngine]; [self setupLocalVideo]; [self joinChannel]; } // 设置视频窗口布局 - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; self.remoteView.frame = self.view.bounds; self.localView.frame = CGRectMake(self.view.bounds.size.width - 90, 0, 90, 160); } - (void)initViews { // 初始化远端视频窗口 self.remoteView = [[UIView alloc] init]; [self.view addSubview:self.remoteView]; // 初始化本地视频窗口 self.localView = [[UIView alloc] init]; [self.view addSubview:self.localView]; } // Swift // ViewController.swift // 导入 UIKit import UIKit class ViewController: UIViewController { ... // 定义 localView 变量 var localView: UIView! // 定义 remoteView 变量 var remoteView: UIView! override func viewDidLoad() { super.viewDidLoad() // 调用初始化视频窗口函数 initView() // 后续步骤调用 Agora API 使用的函数 initializeAgoraEngine() setupLocalVideo() joinChannel() } // 设置视频窗口布局 override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() remoteView.frame = self.view.bounds localView.frame = CGRect(x: self.view.bounds.width - 90, y: 0, width: 90, height: 160) } func initView() { // 初始化远端视频窗口 remoteView = UIView() self.view.addSubview(remoteView) // 初始化本地视频窗口 localView = UIView() self.view.addSubview(localView) } } 现在,我们已经将 Agora iOS SDK 集成到项目中了。接下来我们要在 ViewController 中调用 Agora iOS SDK 提供的核心 API 实现基础的视频通话功能。你可以在 Agora-iOS-Tutorial-Objective-C-1to1/Agora-iOS-Tutorial-Swift-1to1 示例项目的 VideoChatViewController.m/VideoChatViewController.swift 文件中查看完整的源码和代码逻辑。

a) 初始化 AgoraRtcEngineKit 对象 在调用其他 Agora API 前,需要创建并初始化 AgoraRtcEngineKit 对象。调用 sharedEngineWithAppId 方法,传入获取到的 App ID ,即可初始化 AgoraRtcEngineKit 。
// Objective-C - (void)initializeAgoraEngine { // 输入 App ID 并初始化 AgoraRtcEngineKit 类。 self.agoraKit = [AgoraRtcEngineKit sharedEngineWithAppId:appID delegate:self]; } // Swift func initializeAgoraEngine() { // 输入 App ID 并初始化 AgoraRtcEngineKit 类。 agoraKit = AgoraRtcEngineKit.sharedEngine(withAppId: AppID, delegate: self) } 你还可以根据场景需要,在初始化时注册想要监听的回调事件,如本地用户加入频道,及解码远端用户视频首帧等。
b) 设置本地视图 成功初始化 AgoraRtcEngineKit 对象后,需要在加入频道前设置本地视图,以便在通话中看到本地图像。参考以下步骤设置本地视图: · 调用 enableVideo 方法启用视频模块。 · 调用 setupLocalVideo 方法设置本地视图。
// Objective-C // 启用视频模块。 [self.agoraKit enableVideo]; - (void)setupLocalVideo { AgoraRtcVideoCanvas *videoCanvas = [[AgoraRtcVideoCanvas alloc] init]; videoCanvas.uid = 0; videoCanvas.view = self.localVideo; videoCanvas.renderMode = AgoraVideoRenderModeHidden; // 设置本地视图。 [self.agoraKit setupLocalVideo:videoCanvas]; } // Swift // 启用视频模块。 agoraKit.enableVideo() func setupLocalVideo() { let videoCanvas = AgoraRtcVideoCanvas() videoCanvas.uid = 0 videoCanvas.view = localVideo videoCanvas.renderMode = .hidden // 设置本地视图。 agoraKit.setupLocalVideo(videoCanvas) } c) 加入频道 频道是人们在同一个视频通话中的公共空间。完成初始化和设置本地视图后(视频通话场景),你就可以调用 joinChannelByToken 方法加入频道。你需要在该方法中传入如下参数:
更多的参数设置注意事项请参考 joinChannelByToken 接口中的参数描述。
// Objective-C - (void)joinChannel { // 加入频道。 [self.agoraKit joinChannelByToken:token channelId:@"demoChannel1" info:nil uid:0 joinSuccess:^(NSString *channel, NSUInteger uid, NSInteger elapsed) { }]; } // Swift func joinChannel() { // 加入频道。 agoraKit.joinChannel(byToken: Token, channelId: "demoChannel1", info:nil, uid:0) { [unowned self] (channel, uid, elapsed) -> Void in} self.isLocalVideoRender = true self.logVC?.log(type: .info, content: "did join channel") } isStartCalling = true } d) 设置远端视图 视频通话中,通常你也需要看到其他用户。在加入频道后,可通过调用 setupRemoteVideo 方法设置远端用户的视图。
远端用户成功加入频道后,SDK 会触发 firstRemoteVideoDecodedOfUid 回调,该回调中会包含这个远端用户的 uid 信息。在该回调中调用 setupRemoteVideo 方法,传入获取到的 uid ,设置远端用户的视图。
// Objective-C // 监听 firstRemoteVideoDecodedOfUid 回调。 // SDK 接收到第一帧远端视频并成功解码时,会触发该回调。 // 可以在该回调中调用 setupRemoteVideo 方法设置远端视图。 - (void)rtcEngine:(AgoraRtcEngineKit *)engine firstRemoteVideoDecodedOfUid:(NSUInteger)uid size: (CGSize)size elapsed:(NSInteger)elapsed { if (self.remoteVideo.hidden) { self.remoteVideo.hidden = NO; } AgoraRtcVideoCanvas *videoCanvas = [[AgoraRtcVideoCanvas alloc] init]; videoCanvas.uid = uid; videoCanvas.view = self.remoteVideo; videoCanvas.renderMode = AgoraVideoRenderModeHidden; // 设置远端视图。 [self.agoraKit setupRemoteVideo:videoCanvas]; } // Swift // 监听 firstRemoteVideoDecodedOfUid 回调。 // SDK 接收到第一帧远端视频并成功解码时,会触发该回调。 // 可以在该回调中调用 setupRemoteVideo 方法设置远端视图。 func rtcEngine(_ engine: AgoraRtcEngineKit, firstRemoteVideoDecodedOfUid uid:UInt, size:CGSize, elapsed:Int) { isRemoteVideoRender = true let videoCanvas = AgoraRtcVideoCanvas() videoCanvas.uid = uid videoCanvas.view = remoteVideo videoCanvas.renderMode = .hidden // 设置远端视图。 agoraKit.setupRemoteVideo(videoCanvas) } e) 离开频道 根据场景需要,如结束通话、关闭 App 或 App 切换至后台时,调用 leaveChannel 离开当前通话频道。
// Objective-C - (void)leaveChannel { // 离开频道。 [self.agoraKit leaveChannel:^(AgoraChannelStats *stat) } // Swift func leaveChannel() { // 离开频道。 agoraKit.leaveChannel(nil) isRemoteVideoRender = false isLocalVideoRender = false isStartCalling = false self.logVC?.log(type: .info, content: "did leave channel") } f) 销毁 AgoraRtcEngineKit 对象 最后,离开频道,我们需要调用 destroy 销毁 AgoraRtcEngineKit 对象,释放 Agora SDK 使用的所有资源。
// Objective-C // ViewController.m // 将以下代码填入你定义的函数中 [AgoraRtcEngineKit destroy]; // Swift // ViewController.swift // 将以下代码填入你定义的函数中 AgoraRtcEngineKit.destroy() 至此,完成,运行看看效果。拿两部 iOS 手机安装编译好的 App ,加入同一个频道名,如果 2 个手机都能看见本地和远端视频图像,说明你成功了。
如果你在开发过程中遇到问题,可以访问论坛提问与声网工程师交流https://rtcdeveloper.agora.io/
]]>审校| 泰一
变分辨率在弱网场景的实际应用中非常常见,网络状况不好的时候降低分辨率可以降低码率,减少块效应,网络好的时候增加分辨率可以提升清晰度及主观体验。
目前主流的视频编码标准,比如 H.264 、H.265 ,在编码过程中如果要进行分辨率切换,则必须要先编码一个 I 帧,而 I 帧只能使用帧内预测,编码效率低下。这在弱网变分辨率的时候就容易造成卡顿。下图中展示了每秒钟切换分辨率的码率波动效果,高低两个分辨率,每秒钟切换一次。

上图中横坐标表示编码的帧数,纵坐标表示每帧的大小,图中最高的 4 个尖峰表示从低分辨率切换到高分辨率时编的 I 帧,在这 4 个尖峰中间的较低尖峰是从高分辨率切换到低分辨率编码的 I 帧。可见编码 I 帧带来的码率波动还是非常明显的,这在弱网下就很有可能造成如下图所示的卡顿。
https://v.youku.com/v_show/id_XNTEzMTY3MzU5Ng==.html
视频中左一的男士在伸手刚接到左三女士递出的传单之时进入弱网,切换分辨率,产生了卡顿。
新一代的压缩标准,如 VP9 、AV1 、VVC/H.266 等都支持在做帧间预测的时候当前帧和其参考帧使用不同的分辨率,其基本思想是对参考帧做重采样 (re-sampling) 以使得其和当前帧的分辨率匹配,从而进行帧间预测,以实现分辨率切换的时候不用编 I 帧的目的。
阿里云 RTC codec 的变分辨率编码 (resolution change coding, 以下简称 RCC) 也使用和上述标准类似的基本思想,通过参考帧重采样等手段使得之前已编码的其他分辨率的参考帧也能为当前帧所用,维持帧间的参考链不断,充分利用帧间信息冗余提升压缩效率,省去编码效率低下的 I 帧。
本文对阿里云 RTC codec 的 RCC 特性进行测试,使用 6 个视频会议序列(背景不动,运动幅度较小),和 5 个运动程度较大的序列,高低两个分辨率,一秒钟切换一次,只评价分辨率切换帧的码率和视频质量,因为对于后续的帧,使用 RCC 与否,编码方式并没有变化。
对于视频会议序列,相同视频质量下码率有 70% 节省,对于运动序列,相同视频质量下码率有 58% 的节省,因为视频内容越静止不动,帧间编码的比例越高,则 RCC 的优势越明显,所以视频会议序列 RCC 的增益比运动序列要高,是合理的。
下图展示了一个测试序列使用 RCC 后码率波动的变化,蓝线表示的是未加 RCC 的码率波动,红线表示的是加了 RCC 之后的码率波动,可以看到使用 RCC 后分辨率切换处的编码 I 帧码率尖峰明显没有了,码率更加平稳,而且视频质量 PSNR 也有所提升。

蓝线中分辨率切换处的 I 帧平均码率为 840kbps, PSNR=33.5db, 39.7db, 40.6db for Y, U, V 三个分量;而红线中分辨率切换帧的平均码率为 360kbps, PSNR=36.3db, 40.9db, 42.0db for Y, U, V 三个分量。
即开了 RCC 之后,分辨率切换时的 I 帧码率降低了近 60%,同时亮度的 PSNR 提升了近 3 个 db 。
除了前述的单纯 codec level 变分辨率不编 I 帧带来的一帧的压缩性能提升之外,RCC 在和 LTR (Long Term Reference) 结合后会进一步降低弱网下频繁请求 I 帧的可能性。
LTR 抗弱网的原理在上一篇分享《阿里云 RTC QoS 屏幕共享弱网优化之若干编码器相关优化》中已有所介绍,在此结合 RCC 会进一步提升其抗弱网效果,原理如下:
本文在 RTC level 模拟弱网场景,使其一秒钟切换一次分辨率,下面两图分别是未加 RCC 和 加了 RCC 之后的效果,可以看到未加 RCC 的画面在分辨率切换时会有明显的卡顿以及编 I 帧造成的 flicker 效应,而加了 RCC 的则会很流畅,画面也没有 flicker 效应。

上图是未加 RCC,一秒钟切换一次分辨率的效果,有多次明显的小卡顿,且画面有频繁 I 帧造成的 flicker 效应。

上图是加了 RCC,一秒钟切换一次分辨率的效果,整体比较流畅,感觉不到卡顿,视频质量也比较平稳,没有 flicker 效应。
]]>作者| 良逸
审校| 泰一
随便搜索一下,我们就能在网上找到很多关于 WebRTC 中音频 NetEQ 的文章,比如下面的几篇文章都是非常不错的学习资料和参考。特别是西安电子科技大学 2013 年吴江锐的硕士论文《 WebRTC 语音引擎中 NetEQ 技术的研究》,非常详尽地介绍了 NetEQ 实现细节,也被引用到了很多很多的文章中。
这些文章大部分从比较 “学术” 的或 “算法” 的角度,对 NetEQ 的细节做了非常透彻的分析,所以这里我想从更宏观一些的角度,说一下我个人的理解。白话更容易被大家接受,争取一个数学公式都不用,一行代码都不上就把思路说清楚,有理解不对的地方,还请大家不吝赐教。
在音视频实时通信领域,特别是移动办公( 4G ),疫情下的居家办公和在线课堂 ( WIFI ),网络环境成了影响音视频质量最关键的因素,在差的网络质量面前,再好的音视频算法都显得有些杯水车薪。网络质量差的表现主要有延时、乱序、丢包、抖动,谁能处理和平衡好这几类问题,谁就能获得更好的音视频体验。由于网络的基础延时是链路的选择决定的,需优化链路调度层来解决;而乱序在大部分网络条件下并不是很多,而且乱序的程度也不是很严重,所以接下来我们主要会讨论丢包和抖动。
抖动是数据在网络上的传输忽快忽慢,丢包是数据包经过网络传输,因为各种原因被丢掉了,经过几次重传后被成功收到是恢复包,重传也失败的或者恢复包过时的,都会形成真正的丢包,需要丢包恢复 PLC 算法来无中生有的产生一些假数据来补偿。丢包和抖动从时间维度上又是统一的,等一会来了的是抖动,迟到很久才来的是重传包,等一辈子也不来的就是 “真丢包”,我们的目标就是要尽量降低数据包变成 “真丢包” 的概率。
优化,直观来讲就是某个数据指标,经过一顿猛如虎的操作之后,从 xxx 提升到了 xxx 。但我觉得,评判优化好坏不能仅仅停留在这个维度,优化是要 “知己知彼”,己是自己的产品需求,彼是现有算法的能力,己彼合一才是最好的优化,不管算法是简单还是复杂,只要能完美的匹配自己的产品需求,就是最好的算法,“能捉到老鼠的就是好猫”。
《 GIPS NetEQ 原始文档》,这是由 GIPS 公司提供的最原始的 NetEQ 的说明文档(中文翻译),里面介绍了什么是 NetEQ 以及对其性能的简单说明。NetEQ 本质上就是一个音频的 JitterBuffer (抖动缓冲器),名字起的非常贴切,Network Equalizer (网络均衡器)。大家都知道 Audio Equalizer 是用来均衡声音的效果器,而这里的 NetEQ 是用来均衡网络抖动的效果器。而且 GIPS 还给这个名字注册了商标,所以很多地方看到的是 NetEQ (TM) 。 上面的官方文档中,有一条很重要信息,“最小化抖动缓冲带来的延时影响”,这说明 NetEQ 的设计目标之一就是:“追求极低延时”。这个信息很关键,为我们后续的优化提供了重要线索。

音视频通讯对于普通用户来说,只要网络是通的,WIFI 和 4G 都可以,一个呼叫过去,看到人且听到声音,就 OK 了,很简单的事情,但对于底层的实现却没有看起来那么简单。单 WebRTC 开源引擎的相关代码文件数量就有 20 万个左右,代码行数不知道有没有人具体算过,应该也是千万数量级的了。不知道多少码农为此掉光了头发 :)。
下面这张图,是对实际上更复杂的音视频通讯流程的抽象和简化。左边是发送 (推流) 侧:经过采集、编码、封装、发送;中间经过网络传输;右边是接收 (拉流) 侧:接收、解包、解码、播放;这里重点体现了 QoS ( Quality of Service,服务质量)的几个大的功能,以及跟推拉流数据主要流程的关系。可以看到 QoS 功能分散在音视频通讯流程中的各个位置,导致要了解整个流程之后才能对 QoS 有比较全面的理解。图上看起来左边发送侧的 QoS 功能要多一些,这是因为 QoS 的目的就是要解决通讯过程中的用户体验问题,要解决问题,最好就是找到问题的源头,能从源头解决的,都是比较好的解决方式。但总有一部分问题是不能从源头来解决的,比如在多人会议的场景,一个人的收流侧网络坏了,不能影响其它人的开会体验,不能出现 “一颗老鼠屎坏掉一锅粥” 的情况,不能污染源头。所以收流也要做 QoS 的功能,目前收流侧的必备功能就是 JitterBuffer,包括视频的和音频的,本文重点分析音频的 JitterBuffer -- NetEQ 。 
上面这张图是对 NetEQ 及其相关模块工作流程的抽象,主要包含 4 个部分,NetEQ 的输入、NetEQ 的输出、音频重传 Nack 请求模块、音视频同步模块。为什么要把 Nack 请求模块和音视频同步模块也放进 NetEQ 的分析中?因为这两个模块都直接跟 NetEQ 有依赖,相互影响。图里面的虚线,标识每个模块依赖的其它模块的信息,以及这些信息的来源。接下来介绍一下整个流程。
底层 Socket 收到一个 UDP 包后,触发从 UDP 包到 RTP 包的解析,经过对 SSRC 和 PayloadType 的匹配,找到对应的音频流接收的 Channel,然后从 InsertPacketInternal 输入到 NetEQ 的接收模块中。
收到的音频 RTP 包很可能会带有 RED 冗余包( redundance ),按照 RFC2198 的标准或者一些私有的封装格式,对其进行解包,还原出原始包,重复的原始包将会被忽略掉。解出来的原始 RTP 数据包会被按一定的算法插入到 packet buffer 缓存里面去。之后会将收到的每一个原始包的序列号,通过 UpdateLastReceivedPacket 函数更新到 Nack 重传请求模块,Nack 模块会通过 RTP 收包或定时器触发两种模式,调用 GetNackList 函数来生成重传请求,以 NACK RTCP 包的格式发送给推流侧。
同时,解完的每一个原始包,得到了时间轴上唯一的一个接收时刻,包和包之间的接收时间差也能算出来了,这个接收时间差除以每个包的打包时长就是 NetEQ 内部用来做抖动估计的 IAT ( interarrival time ),比如,两个包时间差是 120ms,而打包时长是 20ms,则当前包的 IAT 值就是 120/20=6 。之后每个包的 IAT 值经过核心的网络抖动估计模块( DelayManager )处理之后,得到最终的目标水位( TargetLevel ),到此 NetEQ 的输入处理部分就结束了。
输出是由音频硬件播放设备的播放线程定时触发的,播放设备会每 10ms 通过 GetAudioInternal 接口从 NetEQ 里面取 10ms 长度的数据来播放。
进入 GetAudioInternal 的函数之后,第一步要决策如何应对当前数据请求,这个任务交给操作决策模块来完成,决策模块根据之前的和当前的数据和操作的状态,给出最终的操作类型判断。NetEQ 里面定义了几种操作类型:正常、加速、减速、融合、拉伸(丢包补偿)、静音,这几种操作的意义,后面再详细的说。有了决策的操作类型,再从输入部分的包缓存( packet buffer )里面取出一个 RTP 包,送给抽象的解码器,抽象的解码器通过 DecodeLoop 函数层层调用到真正的解码器进行解码,并把解码后的 PCM 音频数据放到 DecodedBuffer 里面去。然后就是开始执行不同的操作了,NetEQ 里面为每一种操作都实现了不同的音频数字信号处理算法( DSP ),除了 “正常” 操作会直接使用 DecodedBuffer 里的解码数据,其它操作都会结合解码的数据进行二次 DSP 处理,处理结果会先被放到算法缓存( Algorithm Buffer )里面去,然后再插入到 Sync Buffer 里面。Sync Buffer 是一个循环 buffer,设计的比较巧妙,存放了已经播放过的数据、解码后未播放的数据,刚刚从算法缓存里插入的数据放在 Sync Buffer 的末尾,如上图所示。最后就是从 Sync Buffer 取出最早解码后的数据,送出去给外部的混音模块,混音之后再送到音频硬件来播放。
另外,从图上可以看出决策模块( BufferLevelFilter )会结合当前包缓存 packet buffer 里缓存的时长,和 Sync Buffer 里缓存的数据时长,经过算法过滤后得到音频当前的缓存水位。音视频同步模块会使用当前音频缓存水位,和视频当前缓存水位,结合最新 RTP 包的时间戳和音视频的 SR 包获得的时间戳,计算出音视频的不同步程度,再通过 SetMinimumPlayoutDelay 最终设置到 NetEQ 里面的最小目标水位,来控制 TargetLevel,实现音视频同步。
将每个包的 IAT 值,按照一定的比例(取多少比例是由下面的遗忘因子部分的计算决定的),累加到下面的 IAT 统计的直方图里面,最后计算从左往右累加值的 0.95 位置,此位置的 IAT 值作为最后的抖动 IAT 估计值。例如下图,假定目标水位 TargetLevel 是 9,意味着目标缓存数据时长将会是 180ms (假定打包时长 20ms )。

遗忘因子是用来控制当前包的 IAT 值取多少比例累加到上面的直方图里面去的系数,计算过程用了一个看起来比较复杂的公式,经过分析,其本质就是下面的黄色曲线,意思是开始的时候遗忘因子小,会取更多的当前包的 IAT 值来累加,随着时间推移,遗忘因子逐渐变大,会取更少的当前包 IAT 值来累加。这个过程搞的有点复杂,从工程角度看完全可以简化成直线之类的,因为测试下来 5s 左右的时间,基本就收敛到目标值 0.9993 了,其实这个 0.9993 才是影响抖动估计的最主要的因素,很多优化也是直接修改这个系数来调节估计的灵敏度。 
DelayManager 中有一个峰值检测器 PeakDetector 用来识别峰值,如果频繁检测到峰值,会进入峰值抖动的估计状态,取最大的峰值作为最终估计结果,而且一旦进入这个状态会一直维持 20s 时间,不管当前抖动是否已经恢复正常了。下面是一个示意图。 
决策模块的简化后的基本判定逻辑,如下图所示,比较简洁不用解释。这里解释一下下面这几个操作类型的意义:


下面是 ARQ 延时预留功能开启后的效果对比,平均拉伸率降低 50%,延时也会相应增加: 



NetEQ 作为音频接收侧的核心功能,基本上包含了各个方面,所以很多很多音视频通讯的技术实现里都会有它的踪迹,乘着 WebRTC 开源快 10 年的东风,NetEQ 也变的非常普及,希望这篇白话文章能帮大家更好的理解 NetEQ 。
作者最后的话:需求不停歇,优化无止境!
]]>所有的基于网络传输的音视频采集播放系统都会存在音视频同步的问题,作为现代互联网实时音视频通信系统的代表,WebRTC 也不例外。本文将对音视频同步的原理以及 WebRTC 的实现做深入分析。
同步问题就是快慢的问题,就会牵扯到时间跟音视频流媒体的对应关系,就有了时间戳的概念。
时间戳用来定义媒体负载数据的采样时刻,从单调线性递增的时钟中获取,时钟的精度由 RTP 负载数据的采样频率决定。音频和视频的采样频率是不一样的,一般音频的采样频率有 16KHz 、44.1KHz 、48KHz 等,而视频反映在采样帧率上,一般帧率有 25fps 、29.97fps 、30fps 等。
习惯上音频的时间戳的增速就是其采样率,比如 16KHz 采样,每 10ms 采集一帧,则下一帧的时间戳,比上一帧的时间戳,从数值上多 16 x10=160,即音频时间戳增速为 16/ms 。而视频的采样频率习惯上是按照 90KHz 来计算的,就是每秒 90K 个时钟 tick,之所以用 90K 是因为它正好是上面所说的视频帧率的倍数,所以就采用了 90K 。所以视频帧的时间戳的增长速率就是 90/ms 。
WebRTC 的音频帧的时间戳,从第一个包为 0,开始累加,每一帧增加 = 编码帧长 (ms) x 采样率 / 1000,如果采样率 16KHz,编码帧长 20ms,则每个音频帧的时间戳递增 20 x 16000/1000 = 320 。这里只是说的未打包之前的音频帧的时间戳,而封装到 RTP 包里面的时候,会将这个音频帧的时间戳再累加上一个随机偏移量(构造函数里生成),然后作为此 RTP 包的时间戳,发送出去,如下面代码所示,注意,这个逻辑同样适用于视频包。 
WebRTC 的视频帧,生成机制跟音频帧完全不同。视频帧的时间戳来源于系统时钟,采集完成后至编码之前的某个时刻(这个传递链路非常长,不同配置的视频帧,走不同的逻辑,会有不同的获取位置),获取当前系统的时间 timestamp_us_ ,然后算出此系统时间对应的 ntp_time_ms_ ,再根据此 ntp 时间算出原始视频帧的时间戳 timestamp_rtp_ ,参看下面的代码,计算逻辑也在 OnFrame 这个函数中。
为什么视频帧采用了跟音频帧不同的时间戳计算机制呢?我的理解,一般情况音频的采集设备的采样间隔和时钟精度更加准确,10ms 一帧,每秒是 100 帧,一般不会出现大的抖动,而视频帧的帧间隔时间较大采集精度,每秒 25 帧的话,就是 40ms 一帧。如果还采用音频的按照采样率来递增的话,可能会出现跟实际时钟对不齐的情况,所以就直接每取一帧,按照取出时刻的系统时钟算出一个时间戳,这样可以再现真实视频帧跟实际时间的对应关系。
跟上面音频一样,在封装到 RTP 包的时候,会将原始视频帧的时间戳累加上一个随机偏移量(此偏移量跟音频的并不是同一个值),作为此 RTP 包的时间戳发送出去。值得注意的是,这里计算的 NTP 时间戳根本就不会随着 RTP 数据包一起发送出去,因为 RTP 包的包头里面没有 NTP 字段,即使是扩展字段里,我们也没有放这个值,如下面视频的时间相关的扩展字段。 
从上面可以看出,RTP 包里面只包含每个流的独立的、单调递增的时间戳信息,也就是说音频和视频两个时间戳完全是独立的,没有关系的,无法只根据这个信息来进行同步,因为无法对两个流的时间进行关联,我们需要一种映射关系,将两个独立的时间戳关联起来。
这个时候 RTCP 包里面的一种发送端报告分组 SR (SenderReport) 包就上场了,详情请参考 RFC3550。
SR 包的其中一个作用就是来告诉我们每个流的 RTP 包的时间戳和 NTP 时间的对应关系的。靠的就是上边图片中标出的 NTP 时间戳和 RTP 时间戳,通过 RFC3550 的描述,我们知道这两个时间戳对应的是同一个时刻,这个时刻表示此 SR 包生成的时刻。这就是我们对音视频进行同步的最核心的依据,所有的其它计算都是围绕这个核心依据来展开的。
由上面论述可知,NTP 时间和 RTP 时间戳是同一时刻的不同表示,只是精度和单位不一样。NTP 时间是绝对时间,以毫秒为单位,而 RTP 时间戳则和媒体的采样频率有关,是一个单调递增数值。生成 SR 包的过程在 RTCPSender::BuildSR(const RtcpContext& ctx) 函数里面,老版本里面有 bug,写死了采样率为 8K,新版本已经修复,下面截图是老版本的代码: 
首先,我们要获取当前时刻(即 SR 包生成时刻)的 NTP 时间。这个直接从传过来的参数 ctx 中就可以获得:
其次,我们要计算当前时刻,应该对应的 RTP 的时间戳是多少。根据最后一个发送的 RTP 包的时间戳 last_rtp_timestamp_ 和它的采集时刻的系统时间 last_frame_capture_time_ms_,和当前媒体流的时间戳的每 ms 增长速率 rtp_rate ,以及从 last_frame_capture_time_ms_ 到当前时刻的时间流逝,就可以算出来。注意,last_rtp_timestamp_ 是媒体流的原始时间戳,不是经过随机偏移的 RTP 包时间戳,所以最后又累加了偏移量 timestamp_offset_ 。其中最后一个发送的 RTP 包的时间信息是通过下面的函数进行更新的: 
因为同一台机器上音频流和视频流的本地系统时间是一样的,也就是系统时间对应的 NTP 格式的时间也是一样的,是在同一个坐标系上的,所以可以把 NTP 时间作为横轴 X,单位是 ms,而把 RTP 时间戳的值作为纵轴 Y,画在一起。下图展示了计算音视频同步的原理和方法,其实很简单,就是使用最近的两个 SR 点,两点确定一条直线,之后给任意一个 RTP 时间戳,都可以求出对应的 NTP 时间,又因为视频和音频的 NTP 时间是在同一基准上的,所以就可以算出两者的差值。
上图以音频的两个 SR 包为例,确定出了 RTP 和 NTP 对应关系的直线,然后给任意一个 rtp_a,就算出了其对应的 NTP_a,同理也可以求任意视频包 rtp_v 对应的 NTP_v 的时间点,两个的差值就是时间差。
下面是 WebRTC 里面计算直线对应的系数 rate 和偏移 offset 的代码:
在 WebRTC 中计算的是最新收到的音频 RTP 包和最新收到的视频 RTP 包的对应的 NTP 时间,作为网络传输引入的不同步时长,然后又根据当前音频和视频的 JitterBuffer 和播放缓冲区的大小,得到了播放引入的不同步时长,根据两个不同步时长,得到了最终的音视频不同步时长,计算过程在 StreamSynchronization::ComputeRelativeDelay() 函数中,之后又经过了 StreamSynchronization::ComputeDelays() 函数对其进行了指数平滑等一系列的处理和判断,得出最终控制音频和视频的最小延时时间,分别通过 syncable_audio_->SetMinimumPlayoutDelay(target_audio_delay_ms) 和 syncable_video_->SetMinimumPlayoutDelay(target_video_delay_ms) 应用到了音视频的播放缓冲区。
这一系列操作都是由定时器调用 RtpStreamsSynchronizer::Process() 函数来处理的。
另外需要注意一下,在知道采样率的情况下,是可以通过一个 SR 包来计算的,如果没有 SR 包,是无法进行准确的音视频同步的。
WebRTC 中实现音视频同步的手段就是 SR 包,核心的依据就是 SR 包中的 NTP 时间和 RTP 时间戳。最后的两张 NTP 时间-RTP 时间戳 坐标图如果你能看明白(其实很简单,就是求解出直线方程来计算 NTP ),那么也就真正的理解了 WebRTC 中音视频同步的原理。如果有什么遗漏或者错误,欢迎大家一起交流!
简言之,把 WebRTC 作为 Framework 使用,而不是 Library,即:WebRTC 仓库轻量化,核心模块插件化。
详细的,WebRTC 作为 Framework 串联核心模块;核心模块既可以以插件形式使用我们的实现,也可以 Fallback 到 WebRTC 的默认实现。目的是减少 WebRTC 冲突的可能性,提高升级 WebRTC 的敏捷性。
目标:一年升级一次 WebRTC,一次花费一个人月。
WebRTC 的核心模块,包括:
WebRTC 在长期的演进中,API 已经具备了作为 Framework 的大部分能力。红色的核心模块,已经基本可以插件化,如下面的 API:
light-rtc 作为 WebRTC 仓库,我们需要保留两个 Remote,一个是 Alibaba,一个是 Google 。升级 WebRTC 时,我们从 Google 上 Pull 最新代码, 解决冲突,然后 Push 到 Alibaba 。
对插件化的模块,我们需要放到单独的仓库 lrtc-plugin 里,这样有两个好处:
对 lrtc-plugin 依赖的第三方库,也应该以单独的仓库存在,并保留两个 Remote,比如 Opus,这样,即使修改了 Opus 源码,仍然可以像升级 light-rtc 一样,方便的单独升级 Opus 版本。
音频编解码器、视频编解码器,是我们最常优化的部分之一:
这部分插件化是相对简单的,只需要实现自己的 [Video|Audio][Encoder|Decoder]Factory 即可。以 Simulcast 为例,在自己实现的 VideoEncoderFactory 里,先用 WebRTC 原始的 VideoEncoderFactory,创建多个 Encoder 对象,然后封装到一个 Simulcast Encoder 里。
很可惜,ADM(Audio Device Module)没有提供检测设备插拔的功能,需要增加 Callback 接口。
另外,虽然 WebRTC 支持样本数量的监控,但是当前只用于打印日志,如果想在此基础上做更多事情(如:发现采集样本为 0 时,重启采集),则单独做一个 AudioSampleMoniter 的类,比较有利于扩展。 ADM 是一个适配难点,相信是困扰 RTC 同行的共同难题。不同操作系统、不同机型,都可能有不一样的问题。例如:
这些修改大部分属于 Bugfix,参考“Bugfix”章节。
APM(Audio Processing Module)可能是 light-rtc 相对难处理的部分。
APM 与 NetEQ 一起,可能是 WebRTC 核心模块中,开源价值最大的部分。在我对 APM 有限的认知里,对 APM 常见的优化可能有:
下图是 WebRTC APM 内部模块的数据流程图: 从图中可以看出,APM 其实也为插件化做了准备,但是只在近端信号的尾部、远端信号的头部。从 APM 构造函数上也可以看出来:
滤波 /均衡,可以方便的实现一个 CustomProcessing 的 render_pre_processor 。
其他的优化,遵循轻量化 /插件化的理念,没有现成的插件接口,我们可以创造新的插件接口,如啸叫抑制,以及 AECM 优化的部分算法。
但 APM 仍然会有很多没办法插件化的,只能修改 light-rtc 仓库,如 AECM Double Talk 优化等。
AM(Audio Mixer)的插件化,可以在不修改 light-rtc 的基础上,玩出很多花样:
FEC(Forward Error Correction),常见的修改:
CC(Congestion Control),包含两个方面,一个是 CC 算法本身,一个是 CC 关联模块。
算法本身,可以用不同的算法实现,如 WebRTC 默认的 goog_cc,也可以是 BBR,甚至是满足 WebRTC::NetworkControllerFactoryInterface 接口的外部插件。
关联模块:
Android 、iOS 、Mac,WebRTC 都提供了默认的实现,虽然有少量 Bug,但是基本满足需求。
Windows 平台,早期 WebRTC 提供了 D3D 的实现,最新版已经剔除,我们可以在 lrtc-plugin 仓库实现自己的 D3D,或者其他的渲染,如 QT OpenGL 。
WebRTC 并没有提供视频前处理(如:美颜)、后处理(如:超分辨率)的接口,但是我们完全可以像 rtc::BitrateAllocationStrategy 一样,创造 VideoProcessInterface 接口, 并在 lrtc-plugin 仓库里实现。 让 VideoProcessInterface 同时继承 Sink 和 Source 接口,可以方便的把多个对象串联起来。
其他核心模块,如 JitterBuffer 、ICE 等,目前接触的主要是 Bugfix,还没有发现自己定制重写的必要。
Bugfix,往往只能修改 light-rtc 仓库。一方面,是尽量把 Bugfix 内聚成函数,减少对已有代码的修改;另一方面,尽量把 Bugfix 贡献到开源社区(Issue Tracker),既为开源社区做了贡献,也彻底避免了升级的冲突。
贡献到开源社区,往往比想象的要复杂,但也更能锻炼人。在特定场景,往往只用了 WebRTC 一部分能力,如视频 JitterBuffer,一个 Bugfix 可能只考虑到了 H264,贡献到开源社区时,则需要同时兼顾 VP8/VP9,甚至是将来的 AV1 。在这个过程中,Google 工程师会在 Code Review 中与你亲密切磋,其实是非常好的锻炼机会,进一步提高对 WebRTC 的认识。
WebRTC m74 源码
RSFEC:
CC
]]>golang + websocket + WebRTC 的,原本是为了屁 2 屁传文件, 但是现在WebRTC只在局域网内建立成功, 这样的话,就只能使用 websocket 通过服务端进行转发,
但是这样跑服务器带宽,有点贵啊,能给点啥子建议或者帮忙看看是因为什么只能在局域网建立成功?
项目在 https://github.com/kGoChat
用的是 golang 和 vue
但是要做东西实在太多,搞了一个半月感觉快不行了。感兴趣的兄弟可以帮帮我😭
想要做一个基于 WebRTC 的易用的音视频架构,包括 流媒体服务(media server),信令服务器(signal server),Web,iOS 和 Android 端 SDK 。使用 SDK,可以轻松容易构建 videoChat app 。 基本架子: 
Docker,镜像已上线 dockerhub 。目前信令 Docker 镜像 14M;流媒体 Docker 镜像 1.6G ,流媒体服务器会逐渐使用 Go 重写,减小镜像,预计能减到 80M。Android ,kotlin的生态怎么样,SDK 用 kotlin 做,有没有什么大坑?Typescript写的,SDK的API文档是直接用Typescript代码生成,比如用 typedoc,还是生成 Javascript之后,再用jsdoc生成?总之,欢迎 pull request
]]>客户提问:webrtc 不是才有的技术,应用领悟主要是网页端,现在安卓版本更新很快,为什么内置浏览器不支持这> 种几年前就有的新技术,这个不合理
各位有什么看法🙃
]]>每月组织 Javascript/Node 开发者聚会,关注热门的前端、后端框架,开发工具和方法。
* 学习新东西 * 认识新朋友 * 聚餐
我们会为你准备: 咖啡,甜点,爆米花和动听的音乐,一起度过一个愉快的下午。
2017 年 1 月 8 日 星期日 下午 13:00 ~ 17:00
微软大厦 2 号楼 2 楼颐和园会议室
地址: 北京海淀区海淀区丹棱街 5 号微软大厦 Tower 2, 2F 颐和园会议室 百度地图
为了提升活动质量,鼓励分享,本期 node-party 进行售票,早鸟票和标准票只是价格上不同,分别是 29 元和 49 元。
http://www.bagevent.com/event/331797
13:00 ~ 13:30 入场
13:20 - 13:30 微软新视界开发者沙龙简介 - 王添 @微软
王添,微软新视界项目负责人
13:30 - 14:30 WebRTC 快速入门 - 刘连响 @dotEngine
刘连响, Founder@dotEngine, https://dot.cc/. 音视频通话云, github/notedit
WebRTC 的前世今生 WebRTC 的基础架构 基础架构详解
14:50 - 15:50 WebRTC 开发实践
James Pan, 野狗科技音视频项目负责人。
WebRTC 简介 四大核心:连接,通信,音视频处理和安全 使用 WebRTC 开发视频通话 - 3.1 WebRTC 通信流程 - 3.2 使用步骤,核心方法和注意点
16:10 - 17:00 直播服务实践
李智维, Founder@趣直播, http://m.quzhiboapp.com/ 前 LeanCloud 工程师,微博 @lzwjava
直播的基础 自建直播服务器集群 星域、阿里云等直播 CDN 手机 H5 、桌面网页优化 费用成本、直播行业感悟
照片, PPT , Slides https://github.com/rockq-org/node-party
]]>欢迎 Star 跟 Fork. 会不断更新.
]]>