3. 浏览器音频能力全景
前面两章我们搞定了麦克风输入和音频上下文。现在问题来了——声音进来了,但它是「无形」的。你听到的是连续的波形,可浏览器里怎么把它变成跳动的柱状图?怎么画出那条上下抖动的波形线?
答案就在 AnalyserNode 身上。它不改变音频,它只「偷看」音频数据,然后把数据交给你去画图。说白了,它就是个音频示波器。
3.1 FFT 快速傅里叶变换:一句话说清楚
在讲 AnalyserNode 之前,得先聊聊 FFT。别怕,我不推公式。
你想想看,一段声音在时域里就是一条上下抖动的曲线。但这条曲线里其实藏着很多不同频率的正弦波——比如 100Hz 的人声 + 1000Hz 的吉他泛音 + 5000Hz 的镲片声。FFT 干的事就是:把这条混合曲线拆开,告诉你每个频率上有多大的能量。
我刚开始做音频可视化时,总觉得 FFT 是个黑魔法。后来发现,你不需要理解它的数学细节,只要记住三个关键点:
- 输入:一段时域信号(比如 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 时)
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,画出来正好在画布中间。声音越大,曲线上下摆动幅度越大。
3.5 完整的数据流图
下面这张图展示了从音频输入到可视化的完整流程:
3.6 我的一些经验总结
做音频可视化这几年,有几个坑我反复踩过,分享给你:
- 数组长度必须匹配:创建 Uint8Array 时长度一定要用
analyser.frequencyBinCount,别自己写死。不同浏览器、不同 fftSize 下这个值会变。 - 别在数据获取和绘制之间做耗时操作:getByteFrequencyData 很快,但如果你在中间插了个复杂的计算,帧率会掉。我一般把数据处理放在绘制循环里一起做。
- 频谱图的横轴是线性的:人耳对频率的感知是对数的。如果你想要更自然的视觉效果,可以把横轴映射到对数坐标。这个后面章节会讲。
- 波形图的纵轴要留余量:声音峰值可能瞬间超过 255,虽然 Uint8Array 会截断,但画图时最好留 10% 的上下空间,避免波形「削顶」。