4. 经典降噪算法概览

做音频处理,你得先学会“看”声音。

我刚开始接触 Web Audio 时,觉得能听到声音就行了。直到有一次,我写了一个降噪算法,耳朵听着好像还行,但一画波形图,发现语音的起音部分被削掉了一大截。嗯,从那以后,我再也不敢只靠耳朵调参数了。

这一章,我们就来聊聊时域和频域。说白了,就是两种看声音的角度。

4.1 时域波形 vs 频域频谱

先问个问题:声音是什么?

物理上,它是空气的振动。我们把它录下来,就得到了一条随时间变化的曲线——这就是时域波形。横轴是时间,纵轴是振幅(响度)。

但时域波形有个缺点:它看不出声音里有哪些频率成分。比如一段噪音和一段语音,波形可能看起来差不多,都是乱糟糟的。这时候就需要频域频谱了。

频域把声音拆解成不同频率的正弦波。横轴是频率(Hz),纵轴是能量(dB 或幅值)。

一句话总结:

  • 时域:告诉你“什么时候响”。
  • 频域:告诉你“什么频率在响”。

我在项目中遇到过一个问题:用麦克风采集的语音,总感觉闷闷的。看时域波形,振幅正常。但一画频谱图,发现 4kHz 以上的高频能量几乎为零。原来是麦克风的频响曲线有问题。你看,频谱图直接帮我定位了硬件缺陷。

维度 时域波形 频域频谱
横轴 时间 (s) 频率 (Hz)
纵轴 振幅 (幅值) 能量 (dB 或幅值)
主要用途 检测音量、削波、起音时间 分析音色、噪声分布、滤波效果
典型算法 RMS、峰值检测 FFT、STFT

4.2 使用 AnalyserNode 获取数据

在 Web Audio API 里,获取时域和频域数据靠的是 AnalyserNode。它就像一个“探针”,插在音频链路中间,不会改变音频流,只是“偷看”一下数据。

我个人习惯把它放在音源和输出之间。比如:

const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream); // 麦克风输入
const analyser = audioCtx.createAnalyser();

// 配置 AnalyserNode
analyser.fftSize = 2048; // FFT 窗口大小,必须是 2 的幂
analyser.smoothingTimeConstant = 0.8; // 平滑系数,0~1

source.connect(analyser);
analyser.connect(audioCtx.destination); // 继续传给扬声器

这里有两个关键参数:

  • fftSize:决定了频域的分辨率。值越大,频率分辨率越高,但时间分辨率越低。我一般用 2048 或 1024。
  • smoothingTimeConstant:让频谱图看起来更平滑,不会跳得太厉害。0 表示不平滑,1 表示极度平滑。我习惯设 0.8。

小提示:

fftSize 的一半就是频域数据的长度。比如 fftSize=2048,那么频域数据数组长度就是 1024。时域数据长度等于 fftSize。

获取数据的方法有两个:

  • getByteTimeDomainData():获取时域数据,返回 Uint8Array,范围 0~255,128 代表静音。
  • getByteFrequencyData():获取频域数据,返回 Uint8Array,范围 0~255,对应 0~-100dB 的能量。

举个例子:

const bufferLength = analyser.frequencyBinCount; // 频域数据长度
const timeData = new Uint8Array(analyser.fftSize); // 时域数据
const freqData = new Uint8Array(bufferLength);     // 频域数据

function updateData() {
  analyser.getByteTimeDomainData(timeData);
  analyser.getByteFrequencyData(freqData);
  // 接下来就可以用这些数据绘图了
  requestAnimationFrame(updateData);
}
updateData();

注意:

这两个方法都是同步的,而且不会阻塞音频线程。但如果你在动画循环里频繁调用,注意不要做太重的计算,否则会掉帧。我曾经在低端手机上遇到过这个问题,后来把 fftSize 从 4096 降到了 1024,流畅多了。

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

数据拿到了,怎么画?Canvas 是首选。我们一步步来。

4.3.1 绘制实时波形图

波形图就是把时域数据按时间顺序描点连线。横轴是采样点索引,纵轴是振幅。

function drawWaveform(canvas, data) {
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const height = canvas.height;
  const len = data.length;

  ctx.clearRect(0, 0, width, height);
  ctx.beginPath();
  ctx.strokeStyle = '#3498db';
  ctx.lineWidth = 2;

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

  for (let i = 0; i < len; i++) {
    // data[i] 范围 0~255,映射到 -1~1,再映射到 canvas 高度
    const v = (data[i] - 128) / 128; // 归一化到 -1~1
    const y = (v * height) / 2 + height / 2;

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

  ctx.stroke();
}

这里有个细节:data[i] - 128 是因为 Uint8Array 的 128 对应静音(0 振幅)。

4.3.2 绘制实时频谱图

频谱图通常用柱状图或热力图。柱状图更直观,每个柱子代表一个频率段的能量。

function drawSpectrum(canvas, data) {
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const height = canvas.height;
  const len = data.length;

  ctx.clearRect(0, 0, width, height);

  const barWidth = width / len;
  let x = 0;

  for (let i = 0; i < len; i++) {
    // data[i] 范围 0~255,映射到 0~height
    const barHeight = (data[i] / 255) * height;

    // 用颜色表示能量高低
    const hue = (1 - data[i] / 255) * 240; // 从蓝到红
    ctx.fillStyle = `hsl(${hue}, 80%, 50%)`;
    ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight);

    x += barWidth;
  }
}

我个人喜欢用 HSL 颜色,低频用蓝色,高频用红色,看起来一目了然。

核心逻辑:

波形图看“响不响”,频谱图看“有什么频率”。两者结合,你就能全面掌握音频信号的状态。

4.4 知识体系图

下面这张图帮你理清时域与频域分析的整体流程:

时域与频域分析知识体系 时域与频域分析知识体系 音频源 AnalyserNode getByteTimeDomainData() getByteFrequencyData() 绘制实时波形图 绘制实时频谱图

4.5 避坑指南

最后,分享几个我踩过的坑:

  • fftSize 不是越大越好:我曾经设成 8192,结果频谱图更新很慢,实时性很差。后来改成 2048,效果刚刚好。
  • 别忘了 connect:AnalyserNode 如果不 connect 到 destination,音频会断掉。我刚开始学的时候,画出了波形图但听不到声音,排查了半天才发现没连上。
  • Canvas 尺寸要适配:在高 DPI 屏幕上,Canvas 的 CSS 尺寸和实际像素尺寸要匹配,否则画出来的图会模糊。可以用 canvas.width = canvas.clientWidth * devicePixelRatio 来修正。
  • 动画循环用 requestAnimationFrame:不要用 setInterval,否则 CPU 占用会很高,而且动画不流畅。

我的习惯:

每次调试音频算法,我都会同时打开波形图和频谱图。波形图看有没有削波,频谱图看噪声分布。两者一对照,问题基本就清楚了。