12、音频可视化:AnalyserNode节点、获取频域与时域数据、绘制音频波形图

音频可视化,说白了就是把「听不见的声音」变成「看得见的图形」。我在做第一个WebRTC项目时,客户非要看实时音频波形,我当时心想:声音还能画出来?后来一查,原来浏览器早就给我们准备好了——AnalyserNode

这一节,我们就来聊聊怎么用这个节点,把麦克风采集到的音频数据,变成漂亮的波形图和频谱图。

12.1 AnalyserNode 是什么?

AnalyserNode 是 Web Audio API 里的一个分析节点。它不改变音频流,只是「偷看」一下数据。你可以把它想象成一个示波器——接在电路里,但不影响电路本身。

我习惯把它放在音频处理链的末端,专门用来做可视化。比如这样:

麦克风 → MediaStreamSource → AnalyserNode → 画布绘制

它不会改变音量,不会加特效,纯粹就是给你数据用的。

核心能力:

  • 获取时域数据(波形)—— 声音的振幅随时间变化
  • 获取频域数据(频谱)—— 声音在不同频率上的能量分布

12.2 创建 AnalyserNode

创建它很简单。先拿到音频上下文,然后调用 createAnalyser() 方法。

const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();

这里有个参数我要提一下——fftSize。它决定了分析的精度。默认是 2048,数值越大,频率分辨率越高,但响应速度会变慢。

我在项目中一般这样设置:

analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
// bufferLength = fftSize / 2 = 1024

frequencyBinCount 是只读属性,等于 fftSize / 2。你想想看,FFT 之后,有效数据只有一半,另一半是对称的,没必要重复。

我的习惯:如果做实时波形,fftSize 用 1024 就够了,响应更快。如果做精细频谱分析,可以用 4096。

12.3 获取时域数据(波形)

时域数据,就是声音的原始波形。用 getByteTimeDomainData() 方法获取。

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

返回的数组里,每个值范围是 0~255。128 代表静音(中心线),大于 128 是正半周,小于 128 是负半周。

嗯,这里要注意:如果你想要浮点数精度,可以用 getFloatTimeDomainData(),范围是 -1 到 1。

12.4 获取频域数据(频谱)

频域数据,就是声音的频率分布。用 getByteFrequencyData() 方法。

const frequencyArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(frequencyArray);

每个值范围也是 0~255。0 表示该频率没有能量,255 表示能量最大。

我曾经踩过一个坑:忘记在每次绘制前清空数组。结果波形图拖尾严重,看起来像鬼影。后来我养成习惯,每次 requestAnimationFrame 里都重新获取一次数据。

避坑指南:AnalyserNode 的数据是「一次性读取」的。如果你在同一个帧里多次调用 getByteFrequencyData,只有第一次有效。所以,请确保每次绘制循环只读取一次。

12.5 绘制音频波形图

波形图,说白了就是把时域数据画在 canvas 上。横轴是时间,纵轴是振幅。

我一般用 Canvas 2D 来画,配合 requestAnimationFrame 做动画循环。

function drawWaveform() {
  requestAnimationFrame(drawWaveform);

  analyser.getByteTimeDomainData(dataArray);

  canvasCtx.fillStyle = 'rgb(200, 200, 200)';
  canvasCtx.fillRect(0, 0, canvas.width, canvas.height);

  canvasCtx.lineWidth = 2;
  canvasCtx.strokeStyle = 'rgb(0, 0, 0)';
  canvasCtx.beginPath();

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

  for (let i = 0; i < bufferLength; i++) {
    const v = dataArray[i] / 128.0;
    const y = v * canvas.height / 2;

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

    x += sliceWidth;
  }

  canvasCtx.lineTo(canvas.width, canvas.height / 2);
  canvasCtx.stroke();
}

这段代码的核心逻辑:

  • 每次动画帧,从 AnalyserNode 读取最新的时域数据
  • 把 0~255 映射到画布高度(除以 128,得到 0~2 的范围)
  • 逐点连线,形成波形

你想想看,如果数据是 128(静音),画出来的就是一条直线在中间。如果有人在说话,波形就会上下跳动。

12.6 绘制频谱图

频谱图稍微复杂一点。它展示的是不同频率的能量分布。横轴是频率(低频在左,高频在右),纵轴是能量。

function drawSpectrum() {
  requestAnimationFrame(drawSpectrum);

  analyser.getByteFrequencyData(frequencyArray);

  canvasCtx.fillStyle = 'rgb(0, 0, 0)';
  canvasCtx.fillRect(0, 0, canvas.width, canvas.height);

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

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

    canvasCtx.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
    canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);

    x += barWidth + 1;
  }
}

这里我用了渐变色——能量越高,颜色越红。你也可以用 HSL 或者渐变对象,效果会更炫酷。

我的经验:频谱图的柱子不要画太宽,留一点间隙看起来更清晰。另外,低频部分(左边)通常能量更大,所以柱子会更高。这是正常的,不是 bug。

12.7 完整示例:波形 + 频谱双视图

在实际项目中,我经常把波形图和频谱图放在一起。上面是波形,下面是频谱,一目了然。

// 初始化
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;

const bufferLength = analyser.frequencyBinCount;
const timeData = new Uint8Array(bufferLength);
const freqData = new Uint8Array(bufferLength);

// 连接麦克风
navigator.mediaDevices.getUserMedia({ audio: true })
  .then(stream => {
    const source = audioContext.createMediaStreamSource(stream);
    source.connect(analyser);
    draw();
  });

// 绘制循环
function draw() {
  requestAnimationFrame(draw);

  analyser.getByteTimeDomainData(timeData);
  analyser.getByteFrequencyData(freqData);

  // 清空画布
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 上半部分:波形
  drawWaveform(ctx, timeData, 0, canvas.height / 2);

  // 下半部分:频谱
  drawSpectrum(ctx, freqData, 0, canvas.height / 2, canvas.height / 2);
}

你看,代码其实不多。核心就是三步:创建 AnalyserNode → 连接音频源 → 循环读取并绘制

12.8 知识体系图

下面这张 SVG 图,帮你理清整个音频可视化的流程:

音频可视化核心流程 麦克风采集 MediaStreamSource AnalyserNode 扬声器 getByteTimeDomainData() getByteFrequencyData() Canvas 绘制波形/频谱 数据流方向:麦克风 → 分析 → 绘制

12.9 性能优化小贴士

做可视化时,性能是个大问题。我遇到过 canvas 绘制卡顿的情况,后来做了几件事:

  • 降低 fftSize:不需要太高精度时,用 1024 就够了
  • 减少绘制频率:不一定每帧都画,可以用计时器控制,比如每秒 30 帧
  • 使用离屏 canvas:如果图形复杂,先画到离屏 canvas,再一次性复制到主 canvas
  • 避免频繁创建对象:Uint8Array 和 Canvas 路径对象,尽量复用

注意:不要在音频处理线程里做 UI 操作。AnalyserNode 的数据读取和 canvas 绘制,都应该在主线程(requestAnimationFrame)里完成。

12.10 总结

音频可视化,说白了就是三步走:接数据、读数据、画数据。AnalyserNode 是浏览器给我们的礼物,用好了,你的 WebRTC 应用就能「看得见声音」。

我个人觉得,波形图适合展示实时语音的节奏感,频谱图适合展示音乐或环境音的频率分布。两者结合,效果最好。

嗯,这一节的内容就到这里。代码不多,但都是实战经验。你拿去跑一跑,对着麦克风说几句话,看看波形怎么跳,很有意思的。


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