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 知识体系图
下面这张图帮你理清时域与频域分析的整体流程:
4.5 避坑指南
最后,分享几个我踩过的坑:
- fftSize 不是越大越好:我曾经设成 8192,结果频谱图更新很慢,实时性很差。后来改成 2048,效果刚刚好。
- 别忘了 connect:AnalyserNode 如果不 connect 到 destination,音频会断掉。我刚开始学的时候,画出了波形图但听不到声音,排查了半天才发现没连上。
- Canvas 尺寸要适配:在高 DPI 屏幕上,Canvas 的 CSS 尺寸和实际像素尺寸要匹配,否则画出来的图会模糊。可以用
canvas.width = canvas.clientWidth * devicePixelRatio来修正。 - 动画循环用 requestAnimationFrame:不要用 setInterval,否则 CPU 占用会很高,而且动画不流畅。
我的习惯:
每次调试音频算法,我都会同时打开波形图和频谱图。波形图看有没有削波,频谱图看噪声分布。两者一对照,问题基本就清楚了。