9、音频可视化:FFT频谱分析、波形绘制、实时频谱渲染、粒子特效联动

音频可视化,说白了就是把声音“画”出来。你听到的音乐,在手机里其实是一串数字。我们要做的,就是把这串数字变成柱状图、波浪线、甚至漫天飞舞的粒子。嗯,这章内容有点多,但每块都挺有意思的。

我个人习惯把音频可视化分成三个层次:数据层(拿到原始PCM)、分析层(FFT变换)、渲染层(波形/频谱/粒子)。咱们一层层来拆。

核心逻辑: PCM → 时域波形 → FFT → 频域频谱 → 粒子系统联动
音频可视化知识体系 数据层 PCM原始音频数据 分析层 FFT频谱分析 渲染层 波形/频谱/粒子 AudioRecord / MediaCodec 复数运算 · 窗函数 · 幅值 Canvas / OpenGL ES 实时渲染循环:采集 → 分析 → 绘制 → 粒子联动

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)
}
小技巧: 画波形时用Path比逐点画Line效率高很多。我在项目里实测过,Path绘制能快3-5倍。

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
}
注意: 直接对原始数据做FFT会有频谱泄露。一定要加窗函数!我习惯用汉宁窗,效果比较均衡。曾经有一次忘了加窗,频谱图全是毛刺,排查了半天才发现。

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
        )
    }
}
避坑指南: 我曾经在低端手机上做实时频谱,发现掉帧严重。后来把Canvas绘制改成了SurfaceView + 双缓冲,帧率从20fps直接飙到60fps。记住:普通View的onDraw不适合高频刷新。

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
    }
}
性能优化: 粒子数量控制在200-500个比较合适。超过1000个,低端机就开始卡了。另外,用对象池复用粒子对象,避免GC抖动。我项目里曾经因为频繁创建粒子对象,导致每几秒卡一次,改用对象池后问题解决。

9.5 整体架构与数据流

最后,我把整个流程串起来。你写代码的时候,可以按这个结构来组织:

模块 职责 关键类/方法
音频采集 从麦克风或文件读取PCM AudioRecord / MediaExtractor
数据处理 FFT变换、加窗、幅值计算 FFT.transform()
波形渲染 时域波形绘制 Canvas + Path
频谱渲染 频域柱状图绘制 SurfaceView + Canvas
粒子系统 粒子生成、更新、绘制 Particle类 + 对象池
同步控制 保证音画同步 HandlerThread / Choreographer

嗯,到这里,音频可视化的核心内容就差不多了。你想想看,从最原始的PCM数据,到漂亮的频谱柱状图,再到漫天飞舞的粒子,其实每一步都不复杂。关键是把数据流理清楚,然后注意性能优化。

我个人觉得,可视化最迷人的地方在于——你终于能“看见”声音了。那种把抽象变成具象的感觉,真的很棒。


公众号:蓝海资料掘金营,微信deep3321