3. 浏览器音频能力全景

前面两章我们搞定了麦克风输入和音频上下文。现在问题来了——声音进来了,但它是「无形」的。你听到的是连续的波形,可浏览器里怎么把它变成跳动的柱状图?怎么画出那条上下抖动的波形线?

答案就在 AnalyserNode 身上。它不改变音频,它只「偷看」音频数据,然后把数据交给你去画图。说白了,它就是个音频示波器。

3.1 FFT 快速傅里叶变换:一句话说清楚

在讲 AnalyserNode 之前,得先聊聊 FFT。别怕,我不推公式。

你想想看,一段声音在时域里就是一条上下抖动的曲线。但这条曲线里其实藏着很多不同频率的正弦波——比如 100Hz 的人声 + 1000Hz 的吉他泛音 + 5000Hz 的镲片声。FFT 干的事就是:把这条混合曲线拆开,告诉你每个频率上有多大的能量。

我刚开始做音频可视化时,总觉得 FFT 是个黑魔法。后来发现,你不需要理解它的数学细节,只要记住三个关键点:

  • 输入:一段时域信号(比如 1024 个采样点)
  • 输出:每个频率区间的能量值(频域数据)
  • 代价:频率分辨率越高,时间分辨率就越低(鱼和熊掌)
我的经验: 做实时可视化时,别用太大的 FFT 窗口。我曾经设了 8192,结果频谱图更新慢得像幻灯片。后来改成 1024,流畅多了。

3.2 AnalyserNode 的创建与配置

创建 AnalyserNode 很简单,跟其他音频节点一样:

const analyser = audioCtx.createAnalyser();

但真正关键的是它的两个配置属性:fftSize 和 frequencyBinCount。

fftSize:窗口大小

fftSize 决定了每次 FFT 分析用多少个采样点。它必须是 2 的幂,范围从 32 到 32768。默认值是 2048。

analyser.fftSize = 1024;  // 我常用的值

fftSize 越大,频率分辨率越高,但响应越慢。我一般这样选:

fftSize 频率分辨率 适用场景
256 低 快速波形显示、节拍检测
1024 中等 通用频谱图(我推荐这个)
2048 较高 精细频谱分析
8192 高 离线分析,不推荐实时

frequencyBinCount:你真正能拿到的数据量

这个属性是只读的。它等于 fftSize 的一半。比如 fftSize 是 1024,那 frequencyBinCount 就是 512。

为什么会这样?因为 FFT 的输出是对称的,我们只需要前半段(0Hz 到 Nyquist 频率)。

console.log(analyser.frequencyBinCount);  // 输出 512(当 fftSize = 1024 时)
注意: 你创建的数组长度必须等于 frequencyBinCount,不能多也不能少。我曾经用错了长度,结果画出来的频谱图全是乱码,排查了半天才发现是数组越界。

3.3 获取数据:时域 vs 频域

AnalyserNode 提供了两组方法,分别获取两种数据:

频域数据:getByteFrequencyData

这个最常用。它返回每个频率区间的能量值,范围 0-255。0 表示没声音,255 表示满格。

const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);

function updateSpectrum() {
  analyser.getByteFrequencyData(dataArray);
  // 现在 dataArray 里装满了 0-255 的数据
  // 用这些数据去画柱状图
  requestAnimationFrame(updateSpectrum);
}

注意:数据是 0-255 的整数,不是浮点数。如果你需要更精细的值,可以用 getFloatFrequencyData,但那个返回的是 dB 单位,范围 -100 到 0,处理起来稍微麻烦点。

时域数据:getByteTimeDomainData

这个返回原始的波形数据,也是 0-255。128 表示零线(静音),大于 128 是正半周,小于 128 是负半周。

const timeData = new Uint8Array(bufferLength);

function updateWaveform() {
  analyser.getByteTimeDomainData(timeData);
  // timeData 里是波形采样值
  // 128 是中心线,画波形时记得偏移
  requestAnimationFrame(updateWaveform);
}
核心区别:
  • 频域数据 → 画频谱图(柱状图),横轴是频率,纵轴是能量
  • 时域数据 → 画波形图(曲线),横轴是时间,纵轴是振幅

3.4 绘制实时频谱图与波形图

好了,数据拿到了,接下来就是画。我用 Canvas 2D 来画,因为简单直接。

频谱图:跳动的柱子

function drawSpectrum(canvas, analyser) {
  const ctx = canvas.getContext('2d');
  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);
  const width = canvas.width;
  const height = canvas.height;

  function draw() {
    analyser.getByteFrequencyData(dataArray);

    ctx.fillStyle = '#f0f8ff';
    ctx.fillRect(0, 0, width, height);

    const barWidth = (width / bufferLength) * 2.5;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      const barHeight = (dataArray[i] / 255) * height;

      // 用渐变色,低频暖色高频冷色
      const hue = (i / bufferLength) * 240 + 200;
      ctx.fillStyle = `hsl(${hue}, 80%, 60%)`;
      ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight);

      x += barWidth;
    }

    requestAnimationFrame(draw);
  }

  draw();
}

这段代码里有个小技巧:barWidth = (width / bufferLength) * 2.5。为什么要乘 2.5?因为高频部分的数据往往比较稀疏,乘个系数能让柱子看起来更饱满。嗯,这个值你可以自己调,看效果。

波形图:抖动的曲线

function drawWaveform(canvas, analyser) {
  const ctx = canvas.getContext('2d');
  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);
  const width = canvas.width;
  const height = canvas.height;

  function draw() {
    analyser.getByteTimeDomainData(dataArray);

    ctx.fillStyle = '#f0f8ff';
    ctx.fillRect(0, 0, width, height);

    ctx.lineWidth = 2;
    ctx.strokeStyle = '#3498db';
    ctx.beginPath();

    const sliceWidth = width / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128;  // 归一化到 0-2 之间
      const y = (v * height) / 2;

      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }

      x += sliceWidth;
    }

    ctx.stroke();
    requestAnimationFrame(draw);
  }

  draw();
}

注意这里 dataArray[i] / 128 的用意。因为时域数据是 0-255,128 是中心线。除以 128 后,静音时值是 1,画出来正好在画布中间。声音越大,曲线上下摆动幅度越大。

避坑指南: 我曾经在画波形图时忘了除以 128,结果画出来的曲线永远贴在画布顶部。排查了半小时才发现是归一化没做对。嗯,这种低级错误,犯过一次就不会再犯了。

3.5 完整的数据流图

下面这张图展示了从音频输入到可视化的完整流程:

AnalyserNode 数据流与可视化流程 音频源 麦克风/音频文件 AnalyserNode fftSize: 1024 frequencyBinCount: 512 getByteFrequencyData 频域数据 (0-255) getByteTimeDomainData 时域数据 (0-255) 频谱图 柱状图 波形图 曲线图 关键参数说明 • fftSize = 1024 → frequencyBinCount = 512 • 频域数据:每个元素代表一个频率区间的能量(0-255) • 时域数据:每个元素代表一个采样点的振幅(128 = 静音) • 可视化循环:requestAnimationFrame 驱动,每秒约 60 帧 • 注意:getByteFrequencyData 和 getByteTimeDomainData 不会创建新数组 它们直接修改你传入的 Uint8Array,所以记得每次调用前数据都会被覆盖

3.6 我的一些经验总结

做音频可视化这几年,有几个坑我反复踩过,分享给你:

  • 数组长度必须匹配:创建 Uint8Array 时长度一定要用 analyser.frequencyBinCount,别自己写死。不同浏览器、不同 fftSize 下这个值会变。
  • 别在数据获取和绘制之间做耗时操作:getByteFrequencyData 很快,但如果你在中间插了个复杂的计算,帧率会掉。我一般把数据处理放在绘制循环里一起做。
  • 频谱图的横轴是线性的:人耳对频率的感知是对数的。如果你想要更自然的视觉效果,可以把横轴映射到对数坐标。这个后面章节会讲。
  • 波形图的纵轴要留余量:声音峰值可能瞬间超过 255,虽然 Uint8Array 会截断,但画图时最好留 10% 的上下空间,避免波形「削顶」。
一句话总结: AnalyserNode 就是音频世界的「翻译官」。它把声音翻译成数字,你再用 Canvas 把这些数字画成图。fftSize 控制精度,frequencyBinCount 告诉你数据量,两个 getByte 方法分别给你频域和时域的「原材料」。剩下的,就看你怎么画了。