深入理解 AudioUnit (一) ~ IO Unit 结构和运行机制
Apple 平台上如果涉及到音频采集,很难避开 AudioUnit 这个工具库,AudioUnit 是 Audio Toolbox 下的一套有年头的 C API, 功能相对也比较强大,虽然苹果最近几年推出并逐渐在其基础之后完善了一套 AVAudioUnit 的 OC/Swift 的 API, 但 AudioUnit 依然有很广泛的使用,而且了解这套 C API 也对理解 AVAudioUnit 内部的实现和使用有很大的帮助.
其实里面的概念并不是特别复杂,但是因为文档比较老旧,概念也比较绕,上手并不易。我此前做唱歌和直播 app 相关的工作,对 AudioUnit 使用的也比较多,积累了一些经验,希望能够最大程度地把一些通用的概念和使用方法分享出来。接下来将带大家剖析 AudioUnit 的内部原理和丰富多样的使用方式,如果你在做涉及到声音采集和处理的工作,希望能带大家深入浅出地摸透 AudioUnit.
关于 AudioUnit 的文章是一个系列,我希望能够把之前的经验结合一些实际的场景来介绍,大概分为以下四个部分:
- 熟悉 IO Unit 结构和运行机制,使用它来进行录制和播放
- 熟悉其他类型的 AudioUnit, 比如 Mixer, Effect, Converter 等
- 使用 AUGraph 串联起来 AudioUnit, 以及常用的使用模式
- 熟悉使用 AVAudioUnit 进行音频采集和播放
本文中我们先来看第一部分.
1. AudioUnit 介绍
如下图,可见 iOS 上所有的音频基础都是基于 AudioUnit 的,比如 AudioToolbox、Media Player, AV Foundation 等都是在 AudioUnit 上做的封装. AudioUnit 本身处理效率非常高,实时性也很强,支持 VoIP 常见下进行回声消除、降噪等处理.
2. IO Unit 的结构
其实 AudioUnit 分为一下几类:
- IO Unit: 音频采集和播放,回声消除、降噪等
- Effect Unit: 效果器,比如 EQ 均衡器
- Mixing Unit: 字面意思,就是 “混音”, 可以 mix 多路输入,产生一路输出
- Format Converter: 格式转换器,比如采样率 48000 下采样为 44100, 或者双声道转为单声道等等.
我们首先直接来看 IO Unit, 这是最核心的一个 AudioUnit, 其他的种类将会在后面的篇幅里介绍。我喜欢先说原理,再上代码是示例,这样会比较好理解.
首先,IO Unit 的职责就是负责 音频的采集和播放
. 他是通过系统硬件打交道,可以说是封装了硬件的实现,降低我们和硬件打交道的成本。涉及到哪些硬件呢?我们简单地思考一下,采集一定要和麦克风打交道,播放呢,就是听筒或者扬声器.
在介绍 IO Unit 的结构设计之前,我们先想象一下,如果我们来设计实现这个模型,大概是什么样子?可能是这样的:
输入硬件 (麦克风) -> 采集到的原始音频数据 -> 自定义处理音频数据 -> 处理后的音频数据 -> 输出设备 (扬声器 / 听筒)
我们可以将此分为两部分:
- 输入硬件 (麦克风) -> 采集到的原始音频数据
- 待播放的音频数据 -> 输出设备 (扬声器 / 听筒)
当然我们拿到了 “采集到的原始音频数据” 之后,就可以自行处理,然后做为 “待播放的音频数据” 塞给输出设备。这个设计基本上不能再精简了。事实上 IO Unit 的设计也是很类似的:
这个图非常重要,初看会有点困惑,我们来看一下每个部分,首先有两个概念需要了解下:
- Element, 很多 API 里也用 bus 来表示,这两个词在这里完全等价。我们可以理解为
一节水管
. IO Unit 固定有两个 Element. - Scope, 如果 Element 理解为水管的话,这个 Scope 就是
水管的两头
, 每个 Element 固定有两个 Scope, 左侧 Input Scope 是流入口,右侧 Output Scope 是流出口.
这里的 Element 1 是输入水管,因为 1 和 I (Input) 很像,Element 0 表示输出水管,0 和 O (Output) 很像。这样就比较好记了,但是注意,这个约定只在 IO Unit 里起作用。我们分开来看.
Element 1 作为输入水管,左侧 (Input Scope) 固定连接着硬件麦克风,不可改动,右侧 (Output Scope) 是水管的出口,从这里,我们就可以拿到采集到的音频数据.
Element 0 是输出水管,左侧 Input Scope 可以传入要播放的数据,右侧 Output Scope 固定连着扬声器 / 听筒,如果我们想播放什么音频,从 Element 0 的 Input Scope 传入就可以了.
这么看是不是上面我们自己设计的很类似?只是苹果用新增了 Element 和 Scope 的概念。虽然看着两个 Bus 是一体的,其实 Element 0 和 1 是可以独立使用的.
参考下图,从以上我们可以知道,我们可以从 Element 1 的 Output Scope 拿到采集到的音频数据,拿到之后,Application 层就可以对其做任何想做的处理。然后呢,我们可以把要处理后要播放的音频数据塞给 Element 0 的 Input scope, 这样扬声器里就播放这段音频,这样的话,我们耳朵里就听到了录制到的声音,也就实现了耳返监听的功能 (可见耳返在 iOS 上实现非常简单,而且是系统内置支持,延迟很低,Android 上会比较麻烦:软件耳返延迟高,硬件耳返需要单独对接各家手机厂商).
除此之外,Scope 上可以设置很多属性,比如说,设置音频的格式,如果我想采集 48000 的 16 bit float 的数据,那在 Element 1 的 Output Scope 上设置就可以了。同理,我们也需要在 Element 0 的 Input Scope 处设置我们塞过去的数据的格式,这样 Element 0 就知道如何播放了.
前面提到 Element 0 和 Element 1 是相互独立的,也就是说可以只使用其中的一个,或者两个都使用。这也是有实际意义的,比如纯录制场景,只需把采集到的文件保存到文件里,不涉及到播放,或者纯播放场景,想用 AudioUnit 播放一段 mp3 数据.
到此,IO Unit 的结构基本介绍完了。如果有困惑或者疑问的话,欢迎留言讨论.
3. Remote IO (媒体音量) vs VPIO (通话音量)
IO Unit 实际分为两种模式: Remote IO
和 VPIO
, Remote IO 就是封装了和硬件的交互,从而实现采集和播放的功能. VPIO 全称是 Voice Processing IO, 它主要用于 VoIP (Voice over IP) 场景,比如音视频通话,它的结构和 Remote IO 结构完全相同,只是多增加了回声消除和降噪的特点.
这里注意一下 VPIO 和 VoIP 的区别,前者是 apple 平台 AudioUnit 里特有的概念,VoIP 是通用概念.
另外圈内会把 Remote IO 接地气地称为 媒体音量
, 把 VPIO 称为 通话音量
. 他们的区别有以下几点:
- Remote IO (媒体音量) 下因为没有做回声消除和降噪,所以音质非常好,适合播放音乐等高音质的场景。音量条可以向下调整到 0.
- VPIO (通话音量) 下有回声消除和降噪,很适合不带耳机通话的场景,避免中间产生回声和啸叫,但代价是对音质有损伤,适合通话的场景。音量调最小只能设置到 1 格,无法调整到 0 格,也可以根据这个特点判断当前属于哪种模式.
Ps: 上面说的调节音量条,都是调节的 播放音量
, 采集音量无法通过硬件调节,可以通过 AudioUnit 的 volume 属性调节.
这里主要介绍 Remote IO, VPIO 很类似,这里不多做介绍,感兴趣的可以查看对应的 API 即可.
接下来我们来实战一下了.
4. 如何从 IO Unit 获取采集到的数据?InputCallback!
通过上面的介绍我们知道,要拿到 IO Unit 的数据,需要从 Element 1 入手. AudioUnit 提供了一个通用的方法,我们问一个 AudioUnit 要数据,可以通过 AudioUnitRender 函数来实现.
1 | OSStatus AudioUnitRender( |
这是一个 C 函数,所以 in 开头的表示传入的参数,io 表示既可以是传入的参数,也可能会被内部修改,作为传出的数据。第一个参数是我们向哪个 AudioUnit 要数据,第二个是一个 flags 配置,第三个是时间戳,第四个是 bus number, 即 element number, 对于 IO Unit 采集来说,那肯定是 Element 1 了。第五个参数 NumberFrames 就是音频帧数量,最后一个就是返回的数据,使用 AudioBufferList 来承接。这里我们先有个概念.
我们知道这么获取了,那我们可以设置一个定时器,然后定时去通过 AudioUnitRender 去获取。虽然这是一种方法,但不推荐,AudioUnit 支持设置一个 Input Callback, 告诉我们何时有可用的数据。我们通过设置 Input Callback, 在 Input Callback 里调用 AudioUnitRender 函数获取采集到的数据.
我们来看一个例子,这个例子通过上面说的 InputCallback 和 AudioUnitRender 函数获取音视频数据,然后保存到文件中。代码示例如下,第一次涉及到具体的代码,这里会从从头开始介绍,这段代码是基于 WebRTC 里的实际场景略作修改的.
1 | // 创建 IO Unit, 创建之前, 需要先创建 description, 这是创建 AudioUnit 的标准做法, 还有其他的办法来创建, 后面的部分会介绍 |
回调函数的实现:
1 | OSStatus OnRecordedDataIsAvailable(void * inRefCon, |
至此,我们就拿到了采集到的数据。完整版本参考 AudioUnitRecorder
5. 如何塞给 IO Unit 待播放的音频数据?RenderCallback!
根据我们前面介绍的可知,如果要播放音频数据的话,我们需要往 Element 0 的 Input Scope 传递数据,AudioUnit 也给我们提供了另外一个 callback 叫做 RenderCallback, 方法的签名和 InputCallback 一致,不同的是,callback 的最后一个参数是初始化好的,我们可以直接往里写数据即可。代码示例:
1 | ... |
OnAskingForMoreDataForPlayingRenderCallback
函数的实现:
1 | OSStatus OnAskingForMoreDataForPlayingRenderCallback( |
完整版本参考 AudioUnitPlayer
到这里可以思考一下小问题,如果我们有个需求:录制人声,播送到耳返里,同时保存到本地一份,这个应该这么做呢?
- 通过 InputCallback 和 AudioUnitRender 拿到采集到的 Buffer
- 把这段 buffer 缓存起来,当 AudioUnit 的 RenderCallback 回调的时候,把缓存起来的 buffer copy 到 ioData 里
- 在第二步缓存的同时,写入到本地文件一份
6. 总结
至此,我们的第一部分结束了。我们回顾一下主要内容:
- 认识到 AudioUnit 在 iOS/macOS 整体音频体系中的位置
- 熟悉 AudioUnit 中最重要的一个类型 IO unit 的实现结构。它有两个 Element, 0 表示输出 (播放), 1 表示输入 (采集), 相当于两节水管,每个 Element 有两个 Scope, 相当于水管的两头. Element 1 这段水管的源头 (Input Scope) 固定连着麦克风,Element 0 这段水管的尽头 (Output Scope) 固定连接着输出设备 (e.g. 扬声器).
- 然后我们通过 InputCallback 通知我们,并使用 AudioUnitRender 驱动 Element 1 拿到采集到的音频数据。同时可以通过 AudioUnitRenderCallback 往 Element 0 的 Input Scope 填充待播放的数据.
- 了解了 RemoteIO 和 VPIO 各自的特点
Ref: