sasa:针对性能调优的音频库实践

sasa:针对性能调优的音频库实践

众所周知,音频延迟一直是音乐游戏中比较头疼的一个问题。同样的事情也在我自己编写的一款音乐游戏模拟器 prpr 中发生。

关于延迟,你需要知道的东西

延迟是什么?

我们这里的延迟,主要限制在音乐游戏方面(下文简称音游),且分为了两个方面:

  1. 音乐与画面同步上的延迟;
  2. 用户输入(触摸等)到打击音效的延迟。

第一种延迟具体表现为,谱面的实际内容和音乐出现了不同步;这种不同步可能来自于谱师的错误配置,但更有可能来自于游戏自身的实现失误。

第二种延迟,则是游戏走过 用户触摸$\rightarrow$判定处理$\rightarrow$播放音效$\rightarrow$用户听到 这条路径所需要耗费的时间。

两种延迟虽然看似并不相同,在下层却共享着一些处理机制。例如,优化 播放音效$\rightarrow$用户听到 这条路径的耗时是解决两种延迟都必须要做的工作。但从上层而言,两种延迟仍然需要我们分别处理,没有银弹可言。

为什么会有延迟?

让我们想象一个简单的例子。你想要实现一个简单的音乐播放器,并根据 BPM(Beats Per Minute,每分钟节拍数)播放节拍音效(kick?snare?)。首先出场的自然是我们的 naive 实现:

1
2
3
4
5
6
7
8
9
// 一段伪代码
music.play();

thread {
loop_forever {
sound.play();
sleep(60s / bpm);
}
}

看上去不错!……但只是看上去。

随着时间的推移,我们发现音效和音乐逐渐不再对齐。这背后的原因至少有二:

  1. sleep 并不总是精确的。事实上,它在大部分情况下都不会是精确的。操作系统可不会提供这方面的保证。
  2. 音乐播放也并不总是会与现实中的时间契合。你无法确定音乐播放不会在某一时刻突然出现波动,或者是被来自宇宙的一束高能粒子打中而扰动了播放进度——谁知道呢?

总结:不能过度相信操作系统!真实世界的延迟本就无法避免,来自操作系统的各种不稳定因素(资源调度之类)更是雪上加霜。我们需要一些更稳定的方法……

让我们干掉延迟!

如果只是解决第一个问题,我们可以采用如下的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...

music.play();
var start_time = now_time();

var should_play_at = 0;

thread {
loop_forever {
var time = now_time() - start_time;
while (should_play_at <= time) {
sound.play();
should_play_at += 60s / bpm;
}
}
}

不错!我们的播放器不再受制于 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
2
3
4
5
6
7
8
music.play();
var start_time = now_time();

loop_forever {
var time = now_time() - start_time;
draw_frame(start_time);
wait_for_next_frame();
}

不过,就像上面所提到的那样,音乐播放并不总是贴合实际时间。尽管看似如此,这样的方案反而是目前最流行的解决方案。在不发生严重音频问题的情况下,系统时间和音乐时间不会出现大的误差…… 至少是大部分情况下。关于那个小部分,或许为了开发的精力和可维护性,可以被“选择性”地忽略。但今天我们既然来到这里,不妨把这个小部分也弄清楚些。

你说:好吧,那用上面获取音乐播放时间的函数来替换时间值不就好了?

使用音乐时间

1
2
3
4
5
6
7
music.play();

loop_forever {
var time = get_music_position();
draw_frame(start_time);
wait_for_next_frame();
}

要是真有这么简单就好了。

倘若你兴致勃勃地将这段代码投入使用,不一会儿你就会发现…… 怎么这么卡??

get_music_position 的精度可没有那么高

一方面,当播放音频时,音频数据会被首先发送到缓冲区,再根据系统音频的调度定期清空缓冲区。这导致播放位置的精度会受制于缓冲区大小。

你可能会想,“把缓冲区调小不就好了?” 那不妨让我们回到缓冲区的设计目的上:优化性能。过小的缓冲区会导致频繁的缓冲区清空和数据写入,会造成严重的性能问题甚至于音频不连续。

另一方面,在一些平台上(例如,据我所知,Web),获取高精度的播放时间是 被禁止 的。据 相关的文档 称,这样的目的是为了防止有可能的信息泄露和基于时间的攻击(侧信道攻击)。

“嗯……” 你想着,“既然这样,不妨让我们让他变得平滑一些?”

1
2
3
4
5
6
7
8
9
10
music.play();

var last_time = 0;

loop_forever {
var time = get_music_position();
last_time = (last_time * 2 + time) / 3;
draw_frame(last_time);
wait_for_next_frame();
}

啊,不错!看上去平滑多了,除了…… 延迟。这可不好,我们绕到了出发点!

看清楚为什么会有延迟了吗?这种加权平均的方式意味着我们的 last_time 总是会比 get_music_position 慢上 那么一些…… 具体多少?谁知道呢!这东西会随着不稳定的 get_music_position 乱动…… 我是说,看在上帝的份上,这可不好玩。

那该怎么办?如果单纯遵循系统时间,可能会出现随时间越来越明显的不同步;如果单纯遵循音乐播放位置,精度又会很低,继而导致画面不连续,或者说,看上去 帧率很低……

把它们组合一下如何?

好主意!让基于系统时间的实现助力我们达成画面上的连续性,并用音乐播放位置辅助同步…… 可是该怎么实现呢?

目前 prpr 中的实现大致如下(这在游戏中被列为一个可开关的选项:自动对齐时间):

1
2
3
4
5
6
7
8
9
10
11
music.play();

var start_time = now_time();

loop_forever {
var music_time = get_music_position();
var my_time = now_time() - start_time;
start_time -= (music_time - my_time) * 0.001;
draw_frame(now_time() - start_time);
wait_for_next_frame();
}

通过对 start_time 做修改,我们实际上是在整体地调整系统时间的延迟。当系统时间和音乐时间差别过大时,这个误差会以更快的速度被弥补;当差别不明显时,便不会对我们最终使用的时间产生大的影响。

看上去不错。那让我们前往第二个话题……

输出延迟

这也就是说,从你调用 sound.play(),到这段声音被真正地播放出来,还有一段距离。在剖析这段距离之前,我们需要先对音频输出的机制有一个大体上的了解。

我们知道,最后直接播放的音频,或者说传递到扬声器或者是耳机上的数据,是一堆音高的采样点。这意味着,当我们同步播放一些音频时,需要有一个混音器(mixer)将不同的音轨混合起来,形成一串数据。在大多数设备上,这样的混音器都是软件实现的。为了不干扰主逻辑,混音器会在一个独立的线程上工作,这意味着我们需要跨越线程向 mixer 发送指令,从而控制音频输出的具体行为。例如:暂停、播放、定位等等。

在 prpr 中,我最初使用了 kira。它自己实现了一个混音器,并使用 cpal 对接底层的音频输出。但在后来的用户反馈中,延迟一直是一个很严重的问题。于是我建立了文章标题提到的那个项目:sasa——让我们自己写个混音器吧!

我们首先注意到,无论是否在音游中,绝大多数的音频使用可以被分为两类:音乐(Music)和音效(Sound Effect,简称 SFX)。为什么要分成两类呢?因为我们对这两种类型的音频有着不同的功能要求:

  • 音乐:一般同一时间内只会有一个;需要能获取播放时间、暂停、定位;一般较长;
  • 音效:同一时间可能会有多个;不需要获取时间、定位等等;一般较短。

在 sasa 中,我将这两种音频分开实现。我定义了一种 Renderer 特性,表明它可以为 Mixer 混音器提供数据,并让 MusicSfx 分别实现了该特性。特性定义如下:

1
2
3
4
5
pub trait Renderer: Send + Sync {
fn alive(&self) -> bool;
fn render_mono(&mut self, sample_rate: u32, data: &mut [f32]);
fn render_stereo(&mut self, sample_rate: u32, data: &mut [f32]);
}

alive 表示该 Renderer 是否依旧存活(或者说,是否还会输出更多数据)。若返回 falseMixer 就会及时将该 Renderer 移出更新队列,避免占用资源。而后的 render_monorender_stereo 分别是输出单通道或双通道的数据。

在两种 Renderer 的实现中,我都实现了一种 producer-consumer 的模式。producer 是用户端,根据一个 handle 向处于 Mixer 线程的 consumer 发送指令;而 consumer 则会根据命令去执行真正的数据输出。具体而言,我使用了一个环状缓冲区 ringbuf 来跨线程的发送和接受这些命令。在音乐中,同一时间需要执行的命令会相对更少一些,因此这个缓冲区会较小;音效则相反。

同时,为了实现 alive,我为 producerconsumer 各自配备了一个引用计数(Arc)的指针。当所有 producer 端都 drop 掉强引用指针后,consumer 端便可以根据自己存储的弱引用指针来决定是否 alive

音乐 Music

音乐的实现相对简单,所有的实现可以在 这里 看到。我定义了如下三种命令:

1
2
3
4
5
enum MusicCommand {
Pause, // 暂停
Resume, // 继续
SeekTo(f32), // 定位
}

同时为音乐的创建提供了一些可调整的参数:

1
2
3
4
5
6
#[derive(Debug, Clone)]
pub struct MusicParams {
pub loop_: bool, // 是否自动循环播放
pub amplifier: f32, // 放大倍数
pub playback_rate: f32, // 播放速度
}

同时,我还用到了一些原子变量来共享数据,从而实现了获取播放时间的功能。整个实现都是 straight-forward 的,不再赘述。

音效 Sfx

音效的设计,正如上面所提,我们需要支持多个音频的同时播放。一般的做法是创建多个 Renderer,但这些 Renderer 之间并没有实现资源的复用,同时在 Renderer 不再输出音频后我们需要移除这些 Renderer,频繁地在线性存储结构中加入删除也会对性能造成一定损耗。

由于一个 Sfx 的播放时间是恒定的,先播放的音效必然会先结束,于是我在 SfxRenderer 内部维护了一个环形缓冲区,每一个元素记录了自己开始播放的时间戳。当音效播放完成后,Renderer 就会将其从环形缓冲区中移除。如果同时播放的音效数量超出了预先设定的缓冲区大小,根据环形缓冲区的性质,我们会舍弃最早播放的音效;这也是符合逻辑的。具体的实现可以在 这里 查看。

输出框架

cpal 是一个不错的库。它对接了多种底层音频输出,并提供了统一的 API 以供音频输入输出软件使用。不过在安卓平台上,尽管其选用了安卓官方推荐的低延迟输出框架 oboe,但并没有开启一些针对低延迟优化的选项。于是对于安卓平台的构建,我直接调用了 oboe,并在相关配置中指定了高性能的输出模式。

oboe 的 rust 库有一点比较坑的是,其对双声道提供的输出格式是 &mut [(f32, f32)],但 tuple 并不是一种 POD(Plain Old Data),导致我们并没有办法安全地假定其空间的连续分布。怎么办呢?没办法,只有 unsafe 地相信了。

错误恢复

通过输出框架建立起的输出流可不会那么稳定。在输出环境发生变化时(例如,当用户切换默认输出设备,插入耳机之类时),输出流可能会发生错误而不再有效。在 oboecpal 中,由于错误回调都是需要去往底层的,会强制要求 Send + Sync + 'static,这样一来我们就没法用很漂亮的方式对在错误回调时在当前 Mixer 上重新建立新的输出流。怎么办呢?

事实上,我们的解决方案相对简单。由于游戏是每帧刷新的,我们只需要在发生错误时设置一个 Atomic Flag,然后由游戏线程不断地去 consume 这个 flag,有错误时重启即可。

结语

似乎就是这样了。由于本人也是第一次接触相对底层的音频处理相关,可能写的文章会有疏漏,还请多包涵。也希望这篇文章会对有类似需求的开发者有一定帮助。

sasa:针对性能调优的音频库实践

https://mivik.gitee.io/2023/research/sasa/

作者

Mivik

发布于

2023-02-22

更新于

2023-02-23

许可协议

评论