音频可视化,一言以蔽之,就是声音到图像的转换。
随着视觉工业时代的到来,用户逐渐重视产品的极致体验,在市场上诸多优秀的音乐类APP中,频谱动效 是一个经典的应用场景:
图片来源:咪咕音乐
本文以 Android
端为例,从音频信号 数据的获取、数据的处理、常见问题 几方面进行叙述,针对Android
端音频可视化的实现,提供一个通用可行的方案。
一、频谱数据的获取
1.时域与频域
绘制频谱动效,首先要获取歌曲对应的旋律,这里需要先对信号处理中 时域 和 频域 的概念有一个基本的认识:
时域(时间域) 是描述数学函数或物理信号对时间的关系,例如一个信号的时域波形可以表达信号随着时间的变化;
频域(频率域) 是描述信号在频率方面特性时用到的一种坐标系,纵轴是该频率信号的幅度,也就是通常说的频谱图。
对于我们而言,时域虽然是真实存在,但信号的表达 晦涩难懂;而频域相比前者则更加 简洁直观。
因此,开发者需进行时域转频域的操作,这里我们借助 傅里叶变换, 将讯号分解成振幅分量和频率分量,将函数的 时域(红色) 与 频域(蓝色) 相关联:
借此我们达到了从频域对模拟信号观察的目的,并将频谱展现给用户,从而提升用户听歌时沉浸式的体验感。
接下来,笔者针对时域转频域中使用到的 FFT算法(非常重要) 进行简单的介绍。
2.FFT-快速傅里叶变换
针对信号分析的最基本方法,称为 离散傅里叶变换(Discrete Fourier Transform,下称DFT) 傅里叶分析方法,它把信号从时域变换到频域,进而研究信号的频谱结构和变化规律。
在某些复杂场景下,比如上图对应的有限长序列,使用 DFT
计算量会很大,很难实时地处理问题,因此我们引出了 快速傅里叶变换 (fast Fourier transform, 下称FFT) 算法,其将 DFT
的运算量减少了几个数量级。
引入了这个概念,读者可以理解,为什么总是需要通过FFT
算法,拿到对应的数据输出才能完成绘制,为了便于理解,我们将对应输出的 byte[]
称为fft
,就和下面Android
官方的API
声明的一样:
public int getFft(byte[] fft) {
//...
}
3.原生API的优缺点
FFT
算法复杂且易出错,Android
官方也提供了简单的 API
—— Visualizer
类便于开发者调用,只需要传入 audioSession
的 ID
,系统就可以自动捕获当前设备在播音频的信号,完成 fft
的转换后,再回调给开发者,开发者直接进行频谱的绘制即可。
简单概括其优势有:
- 1.系统自动完成音频信号幅度的采样,无需适配,适用于任何原生和三方播放器;
- 2.系统自动完成
fft
转换,开发者通过回调拿到数据直接绘制,上手成本低。
因此,原生 Visualizer API
理所当然成为了实现方案的首选,但随着实现的深入,更多问题不断暴露出来:
- 1.音量为
0
时,回调函数不会返回fft
数据; - 2.类本身只是一个
API
的壳,真正实现都是在native
层,难以针对性进行修改和扩展; - 3.在某些特殊机型有兼容性问题;
- 4.由于
Visualizer API
内部是从当前系统获取音频信号,因此使用前 必须授予麦克风权限,否则没有任何返回。
在实际开发开发中,上述缺陷都是产品无法接受的,尤其是 3、4
两条,随着近两年国家推行一系列用户隐私保护的政策,「展示UI特效需要麦克风权限」 是无法说服任何人的,综上所述,原生Visualizer API
方案被果断抛弃。
4.重整思路
现在又回到了原点,我们需要自定义 Visualizer
。
具体的思路是,在数字化音频时,我们会对信号幅度进行频繁的采样,这称为 脉冲编码调制 (下称PCM)。正如上文对 时域 的描述,振幅随之被量化,但并不直观,因此,我们需要针对 PCM
信号进行结构,即 FFT
算法。
——之前这一切都是交由原生Visualizer API
自动完成的,现在由我们自己控制和实现,最终,我们得到了每个时刻声音的频率,并将这些幅度在页面上展示给用户。
5.底层实现
如何获取当前音频的 PCM
数据?这里可以让底层播放器提供,社区比较完善的三方播放器(ijkplayer
、ExoPlayer
)等都提供了对应支持,开发者可以根据自身业务调用API
或修改源码。
我们不断获得一帧帧的 PCM
数据,通常这是一个 ByteBuffer
,这些 byte
可能来自多个 channel
,简化处理可以取所有 channel
的平均值,这之后拿到这些数据进行傅里叶变换。
宏观上理解傅立叶变换,即 N
个时域点变换为 N
个频域点,瓜分采样频率 Fs
,频率间隔为△f=Fs / N
,根据奈奎斯特采样定理,采样频率是信号最高频率的2倍以上,故有效频点数为N / 2
,函数如下:
如上文所说,计算机直接进行傅立叶变换复杂度为 O(N^2)
,一般是采用快速FFT
算法,复杂度降低为 O(N*LogN)
,我们无需手动实现,社区中已有成熟稳定的 三方库 提供这样的支持。
此外,傅立叶变换后的频域点为复数域,不方便做可视化,处理方式是忽略相位信息,仅取(实数)幅度信息。
6.性能优化
即使 FFT
算法相比较 DFT
有一定的优化,但实际执行的性能消耗依然不能忽视,如何针对这一点进行优化?
网易云音乐在 这篇文章 也有提到这个思路:首先,将FFT
算法执行放在播放进程的 native
层中,通过 JNI
调用回调给 java
层后,再将结果数据跨进程传输给主进程,再进行后续的绘制操作:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aTcOih9w-1689157101651)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b367b8546ec142c7be6253ed65636c9b~tplv-k3u1fbpfcp-watermark.awebp)]
除此之外,由于 PCM
每一帧都包含大量的数据,最初单次的结果 fft
是一个长度为 4096
的 float[]
——我们可使用这些频率立即绘制 4096
个条形图,但实际中根本没有这样的场景。
因此,在 FFT
算法执行前,我们可以对不同频段进行初始数据的 二次采样。
根据声学,人耳能容易的分辨出100hz
和200hz
的音调不同,但是很难分辨出8100hz
和8200hz
的音调不同,尽管它们各自都是相差100hz
,可以说频率和音调之间的变化并不是呈线性关系,而是某种对数的关系。
因此,我们音频的采样以 低频段 为主, 高频段为辅,详细可参考 维基百科。
这样,在 FFT
算法执行时,单次输入的数据源的大小从初始的 4096
降低到了 64
或 128
,算法执行速度大幅提升。
二、数据的处理与绘制
拿到 fft
数据后,接下来针对数据各个阶段遇到的问题,分步进行处理。
1.平均算法
首先,因为数据是从不同频段的不同采样点进行获取,因此单个连续数据区间内会存在若干个数据突然很高或很低的情况,这里对数据进行简单的处理,进行 加权平均:
2.拟合曲线
对于某一帧的数据而言,即使经过简单的加权平均处理,对文章开头中间的柱状特效而言,展示效果依然不佳,如何将参差不齐的数据经过 平滑处理,形成视觉上的包络效果?
这里我们引入 曲线拟合,顾名思义,就是用连续曲线近似地刻画或比拟平面上离散点组所表示的坐标之间的函数关系的一种数据处理方法。
最小二乘法 是解决曲线拟合问题最常用的方法。其一般形式是:
引入之后,离散化的数据点形成一条光滑的曲线,能明显的提升用户的观感体验。
3.定义衰减系数
经过以上几步处理,单帧效果得到明显改善,但随着划入时间维度之后,相邻两帧的展示效果有明显的跳跃性,对于用户而言就好像掉帧一样。
这里我们引入 衰减系数,每一帧数据绘制前,与上一帧数据进行对比,当单个频率点数据发生较高的抖动,通过衰减系数对抖动进行 抑制。
三、其它问题
1.声音和特效不同步问题
这个问题通过gif
图是无法体现的,但用户在听音乐时会立即注意到:频谱特效跳跃的太早了,总是比我们实际听到的音乐节奏快了一步。
导致这个现象是因为自定义的播放器在数据传递给 Android
系统的 AudioTrack
之前,我们已经在拿处理好的 fft
进行绘制了,而播放器内部有自定义的缓冲区,这会导致视觉效果领先于音频效果,导致延迟输出。
解决这个问题也非常简单,只需针对PCM
数据,参考缓冲区相关源码,再定义一份对应的ByteBuffer
即可。
2.绘制方案
对于频谱绘制的技术方案,可以选择系统的 Canvas API
进行自定义View
,将数据的相关运算处理在CPU
中执行。
考虑到频谱动效本身偏 UI
的展示,且与用户没有直接、复杂的交互,以及性能方面的思量,最终使用了 OpenGL
,并将数据运算相关处理通过矩阵变换,直接交给GPU
计算并渲染。
小结
本文针对 Android
端频谱特效实现的整体流程进行概括性的描述,由于频谱动画属于定制化较高的效果,读者无需纠缠于细节,而是将目光聚焦在实际问题的解决思路上。实际开发中,可根据自身产品的需求,灵活运用开发。
参考资料
-
傅里叶变换-维基百科
-
快速傅里叶变换-百度百科
-
最小二乘法-百度百科
-
Android 音频可视化
-
译|Android Visualizer 可视化器的自定义实现