9、音频可视化:FFT频谱分析、波形绘制、实时频谱渲染、粒子特效联动
音频可视化,说白了就是把声音“画”出来。你听到的音乐,在手机里其实是一串数字。我们要做的,就是把这串数字变成柱状图、波浪线、甚至漫天飞舞的粒子。嗯,这章内容有点多,但每块都挺有意思的。
我个人习惯把音频可视化分成三个层次:数据层(拿到原始PCM)、分析层(FFT变换)、渲染层(波形/频谱/粒子)。咱们一层层来拆。
9.1 从PCM到波形绘制
先说说最基础的——波形绘制。你拿到PCM数据后,直接按采样点画线就行。但这里有个坑:音频采样率通常是44100Hz,一秒就有44100个点。你全画上去,屏幕根本显示不下。
我的做法是: 先降采样。比如屏幕宽度只有1080像素,那就把44100个点压缩到1080个。具体来说,每N个点取一个最大值或平均值。
// 波形绘制 - 降采样逻辑
fun drawWaveform(canvas: Canvas, pcmData: ShortArray, width: Int, height: Int) {
val step = pcmData.size / width
val path = Path()
val midY = height / 2f
for (i in 0 until width) {
var maxSample = 0f
for (j in 0 until step) {
val index = i * step + j
if (index < pcmData.size) {
val sample = abs(pcmData[index].toFloat())
if (sample > maxSample) maxSample = sample
}
}
val y = midY - (maxSample / Short.MAX_VALUE) * midY
if (i == 0) path.moveTo(i.toFloat(), y)
else path.lineTo(i.toFloat(), y)
}
canvas.drawPath(path, paint)
}
9.2 FFT频谱分析——把声音拆开看
波形只能看到音量变化,看不到频率成分。比如一段音乐里有鼓声(低频)和镲声(高频),波形上混在一起,根本分不清。这时候就需要FFT了。
FFT(快速傅里叶变换)能把时域信号变成频域信号。说白了,就是告诉你每个频率上有多大的能量。
Android里怎么做? 我一般用官方的AudioRecord拿到PCM数据,然后自己写FFT或者用第三方库。不过说实话,自己写FFT容易出bug,我建议直接用libfftw3或者Kotlin版的kissfft。
// FFT频谱计算(简化版)
fun computeFFT(pcmData: ShortArray): FloatArray {
val n = pcmData.size
val real = FloatArray(n)
val imag = FloatArray(n)
// 复制数据并应用汉宁窗
for (i in 0 until n) {
val window = 0.5f * (1f - cos(2f * PI * i / (n - 1)).toFloat())
real[i] = pcmData[i].toFloat() * window
imag[i] = 0f
}
// 执行FFT(这里用现成库,不展开)
FFT.transform(real, imag)
// 计算幅值
val magnitude = FloatArray(n / 2)
for (i in 0 until n / 2) {
magnitude[i] = sqrt(real[i] * real[i] + imag[i] * imag[i])
}
return magnitude
}
9.3 实时频谱渲染
频谱渲染就是把FFT算出来的幅值画成柱状图。但实时渲染有个关键问题:性能。你每秒钟要处理30-60帧,每帧都要做FFT、更新UI。
我的经验是:
- FFT大小选1024或2048。太小频率分辨率不够,太大计算量上去了。
- 只画前一半频点。因为FFT结果是对称的,后一半是镜像。
- 用对数坐标。人耳对频率的感知是对数的,低频区域多分配一些柱子。
// 频谱渲染 - 对数分频
fun drawSpectrum(canvas: Canvas, magnitudes: FloatArray, barCount: Int) {
val barWidth = canvas.width.toFloat() / barCount
val maxFreq = 22050f // 采样率44100的一半
for (i in 0 until barCount) {
// 对数映射:低频多分,高频少分
val freqIndex = (Math.pow(10.0, i.toDouble() / barCount * 3.0) - 1) / 999.0 * magnitudes.size
val mag = magnitudes[freqIndex.toInt().coerceIn(0, magnitudes.size - 1)]
val barHeight = (mag / maxMag) * canvas.height * 0.8f
canvas.drawRect(
i * barWidth, canvas.height - barHeight,
(i + 1) * barWidth - 2f, canvas.height,
paint
)
}
}
9.4 粒子特效联动
粒子特效是可视化的“颜值担当”。原理很简单:每个粒子就是一个点,有位置、速度、颜色、生命周期。然后根据频谱数据驱动粒子的行为。
我常用的联动方式:
- 低频驱动粒子大小:鼓点一来,粒子变大。
- 高频驱动粒子颜色:镲片声让粒子变亮变白。
- 整体音量驱动粒子数量:高潮部分粒子喷发。
// 粒子系统与频谱联动
class Particle {
var x: Float = 0f
var y: Float = 0f
var vx: Float = 0f
var vy: Float = 0f
var size: Float = 4f
var alpha: Float = 1f
var color: Int = 0xFFFFFFFF.toInt()
}
fun updateParticles(particles: List<Particle>, magnitudes: FloatArray) {
val bassEnergy = magnitudes.take(10).average() // 低频能量
val trebleEnergy = magnitudes.takeLast(20).average() // 高频能量
for (p in particles) {
// 低频影响大小
p.size = 4f + bassEnergy.toFloat() * 0.1f
// 高频影响颜色(偏白)
val brightness = 0.5f + trebleEnergy.toFloat() * 0.5f
p.color = Color.rgb(brightness, brightness, 1f)
// 更新位置
p.x += p.vx
p.y += p.vy
p.alpha -= 0.02f
}
}
9.5 整体架构与数据流
最后,我把整个流程串起来。你写代码的时候,可以按这个结构来组织:
| 模块 | 职责 | 关键类/方法 |
|---|---|---|
| 音频采集 | 从麦克风或文件读取PCM | AudioRecord / MediaExtractor |
| 数据处理 | FFT变换、加窗、幅值计算 | FFT.transform() |
| 波形渲染 | 时域波形绘制 | Canvas + Path |
| 频谱渲染 | 频域柱状图绘制 | SurfaceView + Canvas |
| 粒子系统 | 粒子生成、更新、绘制 | Particle类 + 对象池 |
| 同步控制 | 保证音画同步 | HandlerThread / Choreographer |
嗯,到这里,音频可视化的核心内容就差不多了。你想想看,从最原始的PCM数据,到漂亮的频谱柱状图,再到漫天飞舞的粒子,其实每一步都不复杂。关键是把数据流理清楚,然后注意性能优化。
我个人觉得,可视化最迷人的地方在于——你终于能“看见”声音了。那种把抽象变成具象的感觉,真的很棒。