Android 音频可视化
人民网>>社会·法治

Android 音频可视化

2025-06-24 12:30:55 | 来源:人民网
小字号

计算公式看起来很复杂,但不懂也不会影响我们实现音频可视化,FFT 的计算可以使用已有的库,不需要自己来实现。但为了从 FFT 的计算结果得到最终用来绘制的数据,有必要了解以下DFT特性:

  • 输入全部为实数时,输出结果满足共轭对称性:XN−k=Xk∗X_{ N-k}=X_k^*XN−k​=Xk∗​,因此一般实现只返回一半结果
  • 如原始信号采样率为 fsf_sfs​,序列长度为 N,输出频率分辨率为 fs/Nf_s/Nfs​/N,第 k 个点的频率为 kfs/Nkf_s/Nkfs​/N,可用于查找指定频率范围在结果中对应的位置
  • 如一个频率对应输出的实部和虚部为 re 和 im,其模为 M=re2+im2M=\sqrt{ re2+im2}M=re2+im2​,原始信号振幅为 A={ M/NDC2M/NotherA=\begin{ cases} M/N & DC \\ 2M/N & other \end{ cases}A={ M/N2M/N​DCother​,可用于计算分贝和数据缩放

数据源

提供播放 pcm 数据的 FFT 计算结果的数据源有两种,一种是 Android 系统提供的 Visualizer类,这种存在兼容性问题,因此我们引入了另一种自己实现的数据源。同时,我们实现了在不修改上层各动效的数据处理和绘制逻辑的基础上切换数据源,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Android Visualizer

系统 Visualizer提供了方便的 api 来获取播放音频的波形或 FFT 数据,一般使用方式是:

  1. 用 audio session ID 创建 Visualizer对象,传 0 可获取混音后的可视化数据,传特定播放器或 AudioTrack所使用的 audio session 的 ID,可获取它们所播放音频的可视化数据
  2. setCaptureSize方法设置每次获取的数据大小,调 setDataCaptureListener方法设置数据回调并指定获取数据频率(即回调频率)和数据类型(波形或 FFT)
  3. setEnabled方法开始获取数据,不再需要时调 release方法释放资源

更详细的 api 信息可查看官方文档。

系统 Visualizer输出的数据大小正比于音量,当音量为 0 时,输出也为 0,可视化效果会随音量变化。

使用系统 Visualizer存在兼容性问题,在有些机型上会导致系统音效失效,如要在所有机型上都能无副作用地展示动效,需要实现自定义 Visualizer

自定义 Visualizer

作为跟系统 Visualizer功能一致的数据源,自定义 Visualizer 需具备两个功能:

  • 获取 pcm 数据,计算 FFT
  • 以指定频率和大小发送 FFT 数据

实现第一个功能首先要获取播放音频的 pcm 数据,这要求使用的播放器能够提供 pcm 数据,我们的播放器是自己实现的,能够满足这个要求。我们对播放器进行了扩展,增加了收集解码后的 pcm 数据计算 FFT 的功能。

由于不同音频采样率不同,而计算 FFT 时采用固定的窗口大小,导致 FFT 计算结果回调频率随播放音频改变,同时指定的数据大小可能跟计算结果的大小不同,因此要实现第二个功能,需要对计算结果做固定频率和采样等处理。

另外,我们的播放器在播放进程中运行,而实际使用 FFT 数据的动效页面运行于主进程中,所以还需要跨进程传输数据。

综上,自定义 Visualizer 的整体流程是:在播放进程 native 层中计算 FFT,通过 JNI 调用,把计算结果回调给Java 层,然后通过 AIDL 把 FFT 数据传递给主进程进行后续的数据处理和发送操作。如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

固定频率需要将可变的 FFT 计算结果回调频率转换为外部设置的 Visualizer 回调频率,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

根据所需数据发送时间间隔和 FFT 回调时间间隔差值的不同,我们采用两种不同的方式。

当时间间隔差值小于等于回调时间间隔时,每 t/Δtt/ \Delta tt/Δt 次回调丢弃一次数据,其中 t 为 FFT 回调时间间隔,Δt\Delta tΔt 为时间间隔差值,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

当时间间隔差值大于回调时间间隔时,每 t1/tt1/tt1/t 次回调发送一次数据,其中 t1 为所需数据发送时间间隔,t 为 FFT 回调时间间隔,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

采样就是当外部设置的数据大小小于 FFT 计算结果的数据大小时,对原始 FFT 数据以合适的间隔抽取数据,以满足设置的要求。

为了让自定义 Visualizer 返回数据的取值范围跟系统 Visualizer一致,从而实现数据源无缝切换,我们需要对 FFT 数据进行缩放。这里就需要用到前面提到的模与振幅的计算了,解码所得 pcm 数据的取值范围为 [-1, 1],所以原始信号振幅取值范围为 [0, 1],即 2M/N2M/N2M/N 的取值范围为 [0, 1](绘制时不会用到直流分量,这里不考虑);而系统 Visualizer返回的 FFT 数据是一个 byte数组,实部和虚部的取值范围为 [-128, 128],模的取值范围为 [0,128×2][0, 128 \times \sqrt2][0,128×2​],那么 2M/N×128×22M/N \times 128 \times \sqrt22M/N×128×2​ 的取值范围跟系统 Visualizer输出 FFT 的模的取值范围一致。由于绘制不会用到相位信息,我们可以将用上述方式缩放后的值作为输出 FFT 数据的实部,并把虚部设为 0。

由于数据发送的频率较高,为了避免频繁创建对象导致内存抖动,我们采用对象池来保存数据数组对象,每次从对象池中获取所需大小的数组对象,填充采样数据后加入到队列中等待发送,数据消费完后将数组对象返回到对象池中。

数据处理

不同动效的具体数据处理方式不同,忽略细节上的差异,云音乐现有的动效中,除了宇宙尘埃和孤独星球,其他的处理流程基本一致,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

首先根据动效选择的频率范围计算所需的频率数据在 FFT 数组中的索引位置:

fr=fs/N,start=⌈MIN/fr⌉,end=⌊MAX/fr⌋f_r=f_s/N, start=\lceil MIN/f_r \rceil, end=\lfloor MAX/f_r \rfloorfr​=fs​/N,start=⌈MIN/fr​⌉,end=⌊MAX/fr​⌋

其中 fsf_sfs​ 为采样率,N 为 FFT 窗口大小,frf_rfr​ 为频率分辨率,MIN 为频率范围起始值,MAX 为频率范围结束值。

然后根据动效所需数据点数,对频率范围内的 FFT 数据进行采样或用一个 FFT 数据表示多个数据点。

然后计算分贝:

db=20log⁡10Mdb=20\log_{ 10}Mdb=20log10​M

其中 M 为 FFT 数据的模。

然后将分贝转化为高度:

h=db/MAX_DB⋅maxHeighth=db/MAX\_DB \cdot maxHeighth=db/MAX_DB⋅maxHeight

其中 MAX_DB 是预设的分贝最大值,maxHeight 是当前动效要求的最大高度。

最后对计算出的高度做数据上的平滑处理。

平滑

对最终用来绘制的数据做平滑处理,可以得到更柔和的曲线,达到更好的视觉效果,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

数据平滑算法有很多,我们综合考虑效果和计算复杂度选择了 Savitzky–Golay 滤波法,其计算方式如下,对应的窗口大小分别为5、7 和 9,可以按需选择不同的窗口大小。

Yi=135(−3yi−2+12yi−1+17yi+12yi+1−3yi+2)Y_i={ 1 \over 35}(-3y_{ i-2}+12y_{ i-1}+17y_i+12y_{ i+1}-3y_{ i+2})Yi​=351​(−3yi−2​+12yi−1​+17yi​+12yi+1​−3yi+2​)

Yi=121(−2yi−3+3yi−2+6yi−1+7yi+6yi+1+3yi+2−2yi+3)Y_i={ 1 \over 21}(-2y_{ i-3}+3y_{ i-2}+6y_{ i-1}+7y_i+6y_{ i+1}+3y_{ i+2}-2y_{ i+3})Yi​=211​(−2yi−3​+3yi−2​+6yi−1​+7yi​+6yi+1​+3yi+2​−2yi+3​)

Yi=1231(−21yi−4+14yi−3+39yi−2+54yi−1+59yi+54yi+1+39yi+2+14yi+3−21yi+4)Y_i={ 1 \over 231}(-21y_{ i-4}+14y_{ i-3}+39y_{ i-2}+54y_{ i-1}+59y_i+54y_{ i+1}+39y_{ i+2}+14y_{ i+3}-21y_{ i+4})Yi​=2311​(−21yi−4​+14yi−3​+39yi−2​+54yi−1​+59yi​+54yi+1​+39yi+2​+14yi+3​−21yi+4​)

经过平滑处理后数据的变化如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

BufferQueue

有些动效的数据处理计算比较复杂,为提升并行性,减少主线程耗时,我们借鉴系统图形框架中 BufferQueue 的思想,实现了一个简单的承载动效绘制数据,连接数据处理和绘制的 BufferQueue,其工作过程如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在使用 BufferQueue的动效绘制类初始化时,根据需要创建一个合适大小的 BufferQueue,并启动用于执行数据处理的 Looper线程。

数据处理部分对应 BufferQueueProducer,当 FFT 数据到来时,通过绑定 Looper线程的 Handler将数据发送到 Looper线程中执行数据处理。数据处理时,首先调用 Producerdequeue方法从 BufferQueue中获取空闲的 Buffer,然后对 FFT 数据进行处理,生成需要的数据向 Buffer中填充,最后调用 Producerqueue方法将 Buffer加入到 BufferQueue中的 queued 队列中。

绘制部分对应 BufferQueueConsumer,调用 Producerqueue方法时会触发 ConsumerListeneronBufferAvailable回调,在回调中通过绑定主线程的 Handler切换到主线程消费 Buffer。首先调用 Consumeracquire方法从 BufferQueuequeued队列中获取 Buffer,然后从 Buffer中取出所需数据来绘制,最后调用 Consumerrelease方法将上次的 Buffer返回给 BufferQueue

绘制

绘制部分的主要工作是调用系统 CanvasAPI 将处理后的数据绘制成所需的效果,具体如何使用 API 绘制,随动效的不同而不同,这里不展开介绍。本节将从对绘制来说比较重要的体验和性能方面介绍一些动效绘制的优化经验。

由于 FFT 数据回调的时间间隔大于 16ms,如果只在数据到来时绘制,会产生视觉上的卡顿,为了得到更好的视觉效果,需要在两次回调之间加入过渡帧,以达到渐变的动画效果。实现方式是在两次数据到达的时间间隔内,以上次数据为起点,本次数据为终点,根据当前时间相对于数据到达时间的消逝时间计算当前的高度,不断重复绘制,如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

性能优化有两大手段:batch 和 cache,在动效绘制时也可以使用这些手段。对于需要绘制多条线或多个点的动效,应该调用 drawLinesdrawPoints方法进行批处理,而不是循环调用 drawLinedrawPoint方法,以减少执行时间。
结语

本文介绍了 Android 音频可视化涉及的背景知识和实现过程,并提供了一些问题解决方案和优化思路。本文专注于通用方案,不涉及特定动效的具体实现,希望读者能从中受到些许启发,实现自己的酷炫动效。
参考资料

  • 傅里叶变换
  • 离散傅里叶变换
  • 快速傅里叶变换
  • 数据平滑
    供了一些问题解决方案和优化思路。本文专注于通用方案,不涉及特定动效的具体实现,希望读者能从中受到些许启发,实现自己的酷炫动效。
    参考资料

  • 傅里叶变换
  • 离散傅里叶变换
  • 快速傅里叶变换
  • 数据平滑

(责编:人民网)

分享让更多人看到