Soloud 踩坑日志

由于新作对引擎内核的音频模块提出了更高的要求,所以一直陪伴在身边的 SDL_mixer 便差不多该结束他的职业生涯了,是时候进行新的音频内核探索了,本文主要围绕着在探索过程中遇到的让人又爱又恨的 Soloud 音频库展开记录——

为什么是 Soloud ?

因为在研的下一代引擎 StarDustEngine 暂定目标仍为开源项目,截止到目前为止所依赖的所有三方库均为开源,在协议方面对于再分发和商业使用都没有太大限制,所以,对于常见音频库的选择思路:

  • OpenAL:其实玩 OpenGL 已经耗费了几乎所有精力,所有 OpenXX 的这种相对底层的库,现在的我大概保持一种敬而远之的态度;
  • FMODE:我承认他很强大,但是他太过于强大了,这种次时代的商业音频库对于我们现在能力范围内所实现的轻量级小体量引擎来说,就像是小正太在被大车开;更何况,FMODE 不仅不开源,在商业授权上也是一套又一套,一眼望过去便繁琐至极;
  • SDL_mixer:诚然,作为 SDL 全家桶中的重要一环,使用起来算是极其方便的,但是,简明的 API 所带来的反面效果便是开发者很难从相对底层的层面上去控制音频播控,从而无法实现效果器和响度调控等功能,这正是下一代引擎所必须的功能;
  • IrrKlang:算作是相对小众的老牌音频库,虽然自己没有深度使用过,但是在学习过程中看到过很多资料作者都在用其进行项目教学;仔细了解下后才发现,IrrKlang 同样没有开源,且对商业授权付费,所以依然 Pass;
  • Soloud:开源,免费,并且专为游戏打造,虽然比较小众,但是当我看到下文时,我就决定是他了:

    If you’re planning to make a multi-million budgeted console game, this library is (probably) not for you. Feel free to try it though :-)
    如果您计划制作一款预算数百万美元的主机游戏,那么这个库(可能)不适合您。不过请随意尝试一下:-)

Soloud Logo

噩梦的开始……

首先,从官网的 Download 页面下载了包含 Demo 二进制文件的源码压缩包,随手打开一个 Demo 便被来自耳机的声波物理震撼到了,不知是不是作者有意展示他文档中所提到的动态压缩器的效果,几乎所有测试所使用的音频素材都被拉满了响度,爆音发生在每一秒;

关掉几乎在起反向宣传作用的 Demo,打开了源码目录准备编译,却发现大意了,我下意识以为这种体量的开源项目,必然提供一个 cmake 或者是 nmake 这样的构建配置,查阅官网后才发现自己孤陋寡闻了,整个项目使用一个名为 Genie 的构建工具进行配置;在简单配好构建环境后发现,文档中分明在要求开发者将前置依赖库手动编译然后修改配置文件到你存放的路径,这……

好在我们有强大的 vcpkg,将依赖库都编译完毕后发现自己第一次对一个用 lua 写的配置文件的修改无从下手——到处都是语法糖,先学习配置规则去寻找路径进行修改多少有点太费时间了,无奈之下只好先依据默认配置生成对应的 VS 工程,然后在项目配置中一点点将编译好的依赖库放置到对应的目录下——我承认这有点太不专业了,但是将 Genie 这一未知领域的问题,转变为 VS 工程依赖项配置错误这一熟悉领域的问题,确实是在技术验证阶段最高效的选择了。

Soloud 的设计还算先进,尤其是在很清楚自己定位的情况下,专业而小巧,“fire and forget”大概是其最核心的设计理念——大多数情况下我们不需要即时修改声音参数(如我们可以在初始化时提供默认值),在运行时只需要根据事件触发声音效果即可,剩下的事情 Soloud 都会帮你善后。

那么,接下来最重要的便是对新晋音频库最核心的功能需求的测试了——如何获得或计算总线上的响度,从而实现引擎编辑器内的响度计设计。

响度?音量?

响度计的实现自然需要实时获取指定总线在运行时的左右声道响度大小,首先要做的是自然是查阅官网文档和看 Demo 源码,找了一圈没有发现直接获取响度的接口,倒是注意到 Demo 和文档中提到了获取 Wave 和 FFT 的接口,那么能否通过这些数据计算得到实时响度呢?
Bus.getWave() / Bus.calcFFT() 数据可视化

此处需要特别注意 FFT 的数据采样方式,Bus.calcFFT() 方法返回的为 256 个数据采样点的数据,而在可视化绘图时有多少个采样点有效与当前声道数量有关,如当前总线使用双声道播放,那么有效的采样点数量只有 256 / 2 = 128 个,绘图时只需要表示数组中前 128 个数据即可。

那么在查阅资料后,找到了以 DB 为单位的响度计算方法:
响度计算方法

既然需要振幅,那么首选的就应该是时域的 Wave 波形,对信号处理一塌糊涂的我在请教大佬后决定使用均方根对振幅进行计算:
通过 Wave 计算振幅

其实一开始我以为通过上述公式计算出的结果是前述示例中的 14731 值,还需要除以声音样本的最高振幅才可以求得标准化后的振幅结果从而带入响度公式计算,但是看到求得的结果极小后才才意识到这已经被标准化过了——想来这也合理,Soloud 作为音频库屏蔽声音样本位数这样的信息自然是合理的,既然对这些信息进行了封装,那么我们便无法得到当前位数的声音样本的最大振幅,那么 Soloud 在给我们返回 Wave 之前将采样值标准化自然也是合理的。

想到这里我突然意识到——既然 Soloud 都考虑到将这些数值进行标准化,那么为什么还需要我们通过波形的均方根计算振幅?这显然是不符合常理的设计,只能说自己走了弯路,再次回去查阅文档,终于发现了端倪:Soloud 的总线设置和获取音量的接口是不对等的!

我们首先对总线的音量设置进行探究:

  • 如果我们需要设置总线的默认音量:
1
2
3
SoLoud::Bus gBus;

gBus.setVolume(11);

注意这里设置的是总线的默认音量,也就是说必须要在总线的 play 方法调用之前设置音量,如果一个 AudioSource 已经在该总线上播放,设置总线的默认值将不会改变已经播放的音源音量。

  • 如果我们需要动态设置总线上播放的音源的音量:
1
2
3
4
5
6
SoLoud::Bus gBus;
SoLoud::Wav gSample;
SoLoud::Soloud gSoloud;

int handle = gBus.play(gSample);
gSoloud.setVolume(handle, 0.5f);

这也体现了 Soloud 的设计思想,你不需要始终持有采样器等大体积的结构体,播控的管理均通过轻量化的句柄控制,音源等资源生命周期受游戏引擎或更上层的结构控制;而使用无效的句柄大多数情况只会“do nothing”而不会导致崩溃或其他未知的异常。

那么回到总线音量的获取上:通过句柄进行获取的接口显然是对等的,此处不再赘述;而总线的默认音量作为不常变化的静态值,一般只需要设置接口而舍弃获取接口也是可以理解的;但是,为什么 Soloud 会在总线上提供一个名为 getApproximateVolume 的方法:
Bus.getApproximateVolume()

我们将这个值输出出来后惊奇地发现——这不就是前文中通过均方差计算得到的标准化振幅吗?!

现在问题倒是解决了,Bus.getApproximateVolume() 函数返回的即为各声道的响度计算公式中的 amplitude 参数值,可是 Soloud 为什么要将这个值与 Volume 的概念混为一谈——在大多数声音引擎,包括 Soloud 在内的动态设置音量的接口中,Volume 的概念更像是一个缩放比例,如我们通过 setVolume 函数设置 Volume 为 0.5,那么音源将会使用一半的响度进行播放,而设置为 2 则使用两倍的响度进行播放;而 getApproximateVolume 返回的值则是一个永远小于 1 的标准化振幅——这或许是作者命名上的 Trick,又或者是中英文语言环境的差异,到现在为止也实在无法理解这样设计的巧妙之处……

那么,获取各声道响度的代码就显而易见了:

1
2
3
4
5
6
float ch1 = gBus.getApproximateVolume(0);
float ch2 = gBus.getApproximateVolume(1);
float ld1 = 20 * log(ch1);
float ld2 = 20 * log(ch2);
printf("Volume is %3.3f %3.3f", ch1, ch2);
printf("Loudness is %3.3fdb %3.3fdb", ld1, ch2);

响度计!启动!

又到了一年一度的造轮子时间,既然 ImGUI 并没有扩展库提供响度计类似的组件,那么就只好通过 ImDrawList 这种相对底层的接口手搓一个响度计了,用来实现的思路依然是 EtherTK + Lua,响度计组件相关的代码有点太长,此处只展示部分用以示意,完整的源码链接会放到本文最后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
local metatable = 
{
__index =
{
render = function(self)
_CheckAndLoadFontTexture()
-- [[ 更新 Tick 时间间隔 ]]
if not self.last_tick then self.last_tick = Time.GetInitTime() end
local current_time = Time.GetInitTime()
local delta_time = (current_time - self.last_tick) / 1000
self.last_tick = current_time
-- [[ 计算组件位置和槽位位置 ]]
ImGUI.BeginGroup()
ImGUI.Text(self.id)
-- ... ...
ImGUI.Dummy(size)
-- [[ 开始响度计组件内容绘制 ]]
local draw_list = ImGUI.GetWindowDrawList()
-- 绘制响度计背景色
draw_list:add_rect_filled({x = pos_widget_x, y = pos_widget_y}, {x = pos_widget_x + size.x, y = pos_widget_y + size.y}, color_background)
-- 绘制左右声道槽位背景色
draw_list:add_rect_filled({x = pos_l_chan_x, y = pos_l_chan_y}, {x = pos_l_chan_x + size_slot.x, y = pos_l_chan_y + size_slot.y}, color_background_slot)
draw_list:add_rect_filled({x = pos_r_chan_x, y = pos_r_chan_y}, {x = pos_r_chan_x + size_slot.x, y = pos_r_chan_y + size_slot.y}, color_background_slot)
-- 绘制左右声道响度条
local ldn_l, ldn_r
if self.bus == Soloud then
ldn_l, ldn_r = 20 * math.log(self.bus.GetApproximateVolume(0), 10), 20 * math.log(self.bus.GetApproximateVolume(1), 10)
else
ldn_l, ldn_r = 20 * math.log(self.bus:get_approximate_volume(0), 10), 20 * math.log(self.bus:get_approximate_volume(1), 10)
end
local delta_ldn_l, delta_ldn_r = -speed_slow_fall_valid * delta_time, -speed_slow_fall_valid * delta_time
if self.dummy_loudness.l < -6 then delta_ldn_l = -speed_slow_fall_invalid * delta_time end
if self.dummy_loudness.r < -6 then delta_ldn_r = -speed_slow_fall_invalid * delta_time end
if ldn_l >= self.dummy_loudness.l then self.dummy_loudness.l = ldn_l else self.dummy_loudness.l = _Lerp(self.dummy_loudness.l, ldn_l, delta_ldn_l) end
if ldn_r >= self.dummy_loudness.r then self.dummy_loudness.r = ldn_r else self.dummy_loudness.r = _Lerp(self.dummy_loudness.r, ldn_r, delta_ldn_r) end
_RenderLoudnessSlotContent(draw_list, self.dummy_loudness.l, pos_l_chan_x, pos_l_chan_y)
_RenderLoudnessSlotContent(draw_list, self.dummy_loudness.r, pos_r_chan_x, pos_r_chan_y)
-- 绘制响度标尺刻度
local p1, p2 = {x = pos_r_chan_x + size_slot.x + scale_margin, y = pos_r_chan_y}, {x = 0, y = 0}
p2.x, p2.y = p1.x + scale_width, p1.y
draw_list:add_line(p1, p2)
draw_list:add_image(scale_text_texture_max, {x = p2.x + scale_margin, y = p2.y - scale_text_texture_max_size.y / 2},
{x = p2.x + scale_margin + scale_text_texture_max_size.x, y = p2.y + scale_text_texture_max_size.y / 2})
-- ... ...
ImGUI.EndGroup()
end
}
}

渲染效果看起来还算不错:
响度计

但是,我们很快注意到一个问题,即便我们将各总线的音量设置为 1,经过总线播放的音源振幅在实际播放时都会变为原来的 0.75 左右(如上图中的 BGM 总线经过 Master 总线播放后,响度的值有了明显的衰减),起初我以为是 Soloud 自以为是的动态压缩器和“后处理缩放器”(PostClipScaler 暂且这么翻译吧)这些因素的影响,但是在关停这些功能后振幅的衰减依然没有得到改善,截止到现在该问题依然没能从非源码层面解决,如果想要恢复原始音频的大小进行播放,则需要将设置 Volume 为 1.4 左右的数值,思考一下这可能是 Soloud 为了避免音频叠加播放时的音爆而执行的默认行为,虽然 Volume 的设置并不影响响度计功能,但是作为一个相对专业的音频引擎,处处在“为开发者考虑”,认为自己始终在执行最优方案,提供难以禁用的隐式默认行为,这也可以算作是一种傲慢之罪了。

好的,Get 相关的功能已经基本完成,接下来便是对应的 Set——没想到在这里又暴露出了新的问题:

我们通过句柄的方式重设了 BGM 总线的音量,虽然从耳机中实际播放出的音频音量确实有所改变,但是在通过 bgmBus.getApproximateVolume() 获取到的标准化振幅并没有任何变化,此时变化的反而是下一阶段的总线通道(即 Master 总线)的标准化振幅值,结合之前 Volume 的“系数本质论”,那么这种现象便有了合理的解释,如下图所示:
总线音量示意图

也就是说,我们为总线设置的 Volume 只是该总线向下一阶段输出时振幅的缩放系数,而通过 getApproximateVolume 方法获取到的本总线振幅并不会有任何变化,所以如果我们需要在响度计中实时地显示当前总线的实际响度的话,就需要在使用 getApproximateVolume 获取到标准化振幅后再乘以该总线当前的 Volume,所以在前述的代码中就有所体现——我们重写了总线的 getApproximateVolume 逻辑:

1
2
3
4
5
6
7
8
9
10
local metatable = 
{
__index =
{
-- ... ...
get_approximate_volume = function(self, channel)
return self.bus:get_approximate_volume(channel) * self.volume
end
}
}

到此为止,一套基于 Soloud 音频后端的响度计实现便已基本能完工!

源码链接

感兴趣的朋友可以在这里查看本项目源码:https://github.com/VoidmatrixHeathcliff/DeconstructKatanaZERO/tree/main/LoudnessMeterCustomBus

作者

Voidmatrix

发布于

2023-06-22

更新于

2023-06-23

许可协议

评论