sasa:针对性能调优的音频库实践
众所周知,音频延迟一直是音乐游戏中比较头疼的一个问题。同样的事情也在我自己编写的一款音乐游戏模拟器 prpr 中发生。
关于延迟,你需要知道的东西
延迟是什么?
我们这里的延迟,主要限制在音乐游戏方面(下文简称音游),且分为了两个方面:
- 音乐与画面同步上的延迟;
- 用户输入(触摸等)到打击音效的延迟。
第一种延迟具体表现为,谱面的实际内容和音乐出现了不同步;这种不同步可能来自于谱师的错误配置,但更有可能来自于游戏自身的实现失误。
第二种延迟,则是游戏走过 用户触摸
$\rightarrow$判定处理
$\rightarrow$播放音效
$\rightarrow$用户听到
这条路径所需要耗费的时间。
两种延迟虽然看似并不相同,在下层却共享着一些处理机制。例如,优化 播放音效
$\rightarrow$用户听到
这条路径的耗时是解决两种延迟都必须要做的工作。但从上层而言,两种延迟仍然需要我们分别处理,没有银弹可言。
为什么会有延迟?
让我们想象一个简单的例子。你想要实现一个简单的音乐播放器,并根据 BPM(Beats Per Minute,每分钟节拍数)播放节拍音效(kick?snare?)。首先出场的自然是我们的 naive 实现:
1 | // 一段伪代码 |
看上去不错!……但只是看上去。
随着时间的推移,我们发现音效和音乐逐渐不再对齐。这背后的原因至少有二:
sleep
并不总是精确的。事实上,它在大部分情况下都不会是精确的。操作系统可不会提供这方面的保证。- 音乐播放也并不总是会与现实中的时间契合。你无法确定音乐播放不会在某一时刻突然出现波动,或者是被来自宇宙的一束高能粒子打中而扰动了播放进度——谁知道呢?
总结:不能过度相信操作系统!真实世界的延迟本就无法避免,来自操作系统的各种不稳定因素(资源调度之类)更是雪上加霜。我们需要一些更稳定的方法……
让我们干掉延迟!
如果只是解决第一个问题,我们可以采用如下的方式:
1 | ... |
不错!我们的播放器不再受制于 sleep
的不稳定性了。在每一次更新中,我们获取到当前的系统时间,并和 should_play_at
做比较,如果需要播放,就播放并且计算下一次需要播放的时间…… 等等,有谁在敲门?
浮点误差:你好
众所周知,0.1 + 0.2 !== 0.3
。如果一直加上 60s / bpm
,这样的浮点误差将会累计,最后 should_play_at
将会和 60s / bpm * i
越来越远。这样的问题似乎解决起来也并不困难,我们只需要把每次的 +=
换成记录 i
就好了。
不过,让我们回到上面的第二个问题:
音乐播放也并不总是会与现实中的时间契合。你无法确定音乐播放不会在某一时刻突然出现波动,或者是被来自宇宙的一束高能粒子打中而扰动了播放进度——谁知道呢?
为此,大部分音频库为我们提供了获取音频当前播放位置的函数(例如 get_playback_position
)。把上面的 now_time
换成对应的函数,我们似乎就已经到达本次旅途的终点了!
…… 听着,我并不想打搅你的好心情,不过这似乎只是个开头。
Get Your Hands Dirty!
经过上面的折腾,我们写的简单音乐播放器总算是能用了,尽管还没有考虑一些微妙的因素(从播放到实际输出的延迟,等等)。
不过在音游里,考虑的可远不止这些。不妨让我们从上面的第一个问题开始:
谱面和音乐同步
使用系统时间
大部分的游戏都是通过当前的时间来更新游戏画面的。相当于说,我们游戏会在每一帧刷新,刷新时根据当前帧对应的谱面时间来绘制相应的谱面内容。于是我们得到了初版代码:
1 | music.play(); |
不过,就像上面所提到的那样,音乐播放并不总是贴合实际时间。尽管看似如此,这样的方案反而是目前最流行的解决方案。在不发生严重音频问题的情况下,系统时间和音乐时间不会出现大的误差…… 至少是大部分情况下。关于那个小部分,或许为了开发的精力和可维护性,可以被“选择性”地忽略。但今天我们既然来到这里,不妨把这个小部分也弄清楚些。
你说:好吧,那用上面获取音乐播放时间的函数来替换时间值不就好了?
使用音乐时间
1 | music.play(); |
要是真有这么简单就好了。
倘若你兴致勃勃地将这段代码投入使用,不一会儿你就会发现…… 怎么这么卡??
get_music_position
的精度可没有那么高。
一方面,当播放音频时,音频数据会被首先发送到缓冲区,再根据系统音频的调度定期清空缓冲区。这导致播放位置的精度会受制于缓冲区大小。
你可能会想,“把缓冲区调小不就好了?” 那不妨让我们回到缓冲区的设计目的上:优化性能。过小的缓冲区会导致频繁的缓冲区清空和数据写入,会造成严重的性能问题甚至于音频不连续。
另一方面,在一些平台上(例如,据我所知,Web),获取高精度的播放时间是 被禁止 的。据 相关的文档 称,这样的目的是为了防止有可能的信息泄露和基于时间的攻击(侧信道攻击)。
“嗯……” 你想着,“既然这样,不妨让我们让他变得平滑一些?”
1 | music.play(); |
啊,不错!看上去平滑多了,除了…… 延迟。这可不好,我们绕到了出发点!
看清楚为什么会有延迟了吗?这种加权平均的方式意味着我们的 last_time
总是会比 get_music_position
慢上 那么一些…… 具体多少?谁知道呢!这东西会随着不稳定的 get_music_position
乱动…… 我是说,看在上帝的份上,这可不好玩。
那该怎么办?如果单纯遵循系统时间,可能会出现随时间越来越明显的不同步;如果单纯遵循音乐播放位置,精度又会很低,继而导致画面不连续,或者说,看上去 帧率很低……
把它们组合一下如何?
好主意!让基于系统时间的实现助力我们达成画面上的连续性,并用音乐播放位置辅助同步…… 可是该怎么实现呢?
目前 prpr 中的实现大致如下(这在游戏中被列为一个可开关的选项:自动对齐时间
):
1 | music.play(); |
通过对 start_time
做修改,我们实际上是在整体地调整系统时间的延迟。当系统时间和音乐时间差别过大时,这个误差会以更快的速度被弥补;当差别不明显时,便不会对我们最终使用的时间产生大的影响。
看上去不错。那让我们前往第二个话题……
输出延迟
这也就是说,从你调用 sound.play()
,到这段声音被真正地播放出来,还有一段距离。在剖析这段距离之前,我们需要先对音频输出的机制有一个大体上的了解。
我们知道,最后直接播放的音频,或者说传递到扬声器或者是耳机上的数据,是一堆音高的采样点。这意味着,当我们同步播放一些音频时,需要有一个混音器(mixer)将不同的音轨混合起来,形成一串数据。在大多数设备上,这样的混音器都是软件实现的。为了不干扰主逻辑,混音器会在一个独立的线程上工作,这意味着我们需要跨越线程向 mixer 发送指令,从而控制音频输出的具体行为。例如:暂停、播放、定位等等。
在 prpr 中,我最初使用了 kira。它自己实现了一个混音器,并使用 cpal 对接底层的音频输出。但在后来的用户反馈中,延迟一直是一个很严重的问题。于是我建立了文章标题提到的那个项目:sasa——让我们自己写个混音器吧!
我们首先注意到,无论是否在音游中,绝大多数的音频使用可以被分为两类:音乐(Music)和音效(Sound Effect,简称 SFX)。为什么要分成两类呢?因为我们对这两种类型的音频有着不同的功能要求:
- 音乐:一般同一时间内只会有一个;需要能获取播放时间、暂停、定位;一般较长;
- 音效:同一时间可能会有多个;不需要获取时间、定位等等;一般较短。
在 sasa 中,我将这两种音频分开实现。我定义了一种 Renderer
特性,表明它可以为 Mixer
混音器提供数据,并让 Music
和 Sfx
分别实现了该特性。特性定义如下:
1 | pub trait Renderer: Send + Sync { |
alive
表示该 Renderer
是否依旧存活(或者说,是否还会输出更多数据)。若返回 false
,Mixer
就会及时将该 Renderer
移出更新队列,避免占用资源。而后的 render_mono
和 render_stereo
分别是输出单通道或双通道的数据。
在两种 Renderer
的实现中,我都实现了一种 producer-consumer
的模式。producer
是用户端,根据一个 handle 向处于 Mixer
线程的 consumer
发送指令;而 consumer
则会根据命令去执行真正的数据输出。具体而言,我使用了一个环状缓冲区 ringbuf 来跨线程的发送和接受这些命令。在音乐中,同一时间需要执行的命令会相对更少一些,因此这个缓冲区会较小;音效则相反。
同时,为了实现 alive
,我为 producer
和 consumer
各自配备了一个引用计数(Arc
)的指针。当所有 producer
端都 drop 掉强引用指针后,consumer
端便可以根据自己存储的弱引用指针来决定是否 alive
。
音乐 Music
音乐的实现相对简单,所有的实现可以在 这里 看到。我定义了如下三种命令:
1 | enum MusicCommand { |
同时为音乐的创建提供了一些可调整的参数:
1 |
|
同时,我还用到了一些原子变量来共享数据,从而实现了获取播放时间的功能。整个实现都是 straight-forward 的,不再赘述。
音效 Sfx
音效的设计,正如上面所提,我们需要支持多个音频的同时播放。一般的做法是创建多个 Renderer
,但这些 Renderer
之间并没有实现资源的复用,同时在 Renderer
不再输出音频后我们需要移除这些 Renderer
,频繁地在线性存储结构中加入删除也会对性能造成一定损耗。
由于一个 Sfx
的播放时间是恒定的,先播放的音效必然会先结束,于是我在 Sfx
的 Renderer
内部维护了一个环形缓冲区,每一个元素记录了自己开始播放的时间戳。当音效播放完成后,Renderer
就会将其从环形缓冲区中移除。如果同时播放的音效数量超出了预先设定的缓冲区大小,根据环形缓冲区的性质,我们会舍弃最早播放的音效;这也是符合逻辑的。具体的实现可以在 这里 查看。
输出框架
cpal 是一个不错的库。它对接了多种底层音频输出,并提供了统一的 API 以供音频输入输出软件使用。不过在安卓平台上,尽管其选用了安卓官方推荐的低延迟输出框架 oboe,但并没有开启一些针对低延迟优化的选项。于是对于安卓平台的构建,我直接调用了 oboe
,并在相关配置中指定了高性能的输出模式。
oboe 的 rust 库有一点比较坑的是,其对双声道提供的输出格式是 &mut [(f32, f32)]
,但 tuple 并不是一种 POD(Plain Old Data),导致我们并没有办法安全地假定其空间的连续分布。怎么办呢?没办法,只有 unsafe
地相信了。
错误恢复
通过输出框架建立起的输出流可不会那么稳定。在输出环境发生变化时(例如,当用户切换默认输出设备,插入耳机之类时),输出流可能会发生错误而不再有效。在 oboe
和 cpal
中,由于错误回调都是需要去往底层的,会强制要求 Send + Sync + 'static
,这样一来我们就没法用很漂亮的方式对在错误回调时在当前 Mixer
上重新建立新的输出流。怎么办呢?
事实上,我们的解决方案相对简单。由于游戏是每帧刷新的,我们只需要在发生错误时设置一个 Atomic Flag,然后由游戏线程不断地去 consume 这个 flag,有错误时重启即可。
结语
似乎就是这样了。由于本人也是第一次接触相对底层的音频处理相关,可能写的文章会有疏漏,还请多包涵。也希望这篇文章会对有类似需求的开发者有一定帮助。
sasa:针对性能调优的音频库实践