引言
Web Audio API 是浏览器里唯一被广泛支持的低延迟音频处理接口。它把音频处理抽象成一张有向图:节点(AudioNode)是处理单元,连线是音频数据流,由浏览器内部的音频线程按固定块大小(通常 128 帧)驱动整张图。
它的设计目标不是"播放一个 mp3"(那是 <audio> 标签的事),而是实时合成、处理与空间化。这也是它比 <audio> 复杂得多的原因:你要自己管理时间、自己调度、自己处理生命周期。好消息是这套模型与桌面音频框架(CoreAudio、VST)高度同构,理解一套即可迁移。
工程上的难点集中在四处:时间模型(currentTime 与调度语义)、图的生命周期(什么时候可以断开、什么时候会被 GC)、参数自动化(AudioParam 的调度曲线如何与事件交互)、以及浏览器策略(autoplay、音频焦点、后台节流)。本文按这四个难点组织,并在最后给出与 AudioWorklet、WASM 协作的实践路径。
目录
- AudioContext 与生命周期
- 音频图:节点、连接与声道规则
- 时间模型与精确调度
- AudioParam 参数自动化
- 常用内置节点速查
- AudioBuffer 与音频数据
- OfflineAudioContext 离线渲染
- 浏览器策略:autoplay 与音频焦点
- 与 AudioWorklet、WASM 的协作
1. AudioContext 与生命周期
AudioContext 是所有节点的容器,同时持有音频时钟与渲染线程。
const ctx = new AudioContext({
latencyHint: 'interactive', // 'balanced' | 'interactive' | 'playback'
sampleRate: 48000, // 可选,浏览器可能忽略
});
console.log(ctx.sampleRate); // 实际采样率,常见 44100 或 48000
console.log(ctx.state); // 'suspended' | 'running' | 'closed'
console.log(ctx.baseLatency); // 图内部引入的最小延迟(秒)
console.log(ctx.outputLatency); // 到扬声器的额外延迟(秒,可能为 0)
关键点:
sampleRate是只读的,构造时指定的值可能被忽略。音频线程只能有一个采样率,若页面已有其他AudioContext,新建的会继承同一个采样率。latencyHint影响缓冲区大小。'interactive'通常对应约 5~10 ms,'playback'可到 50 ms 以上。移动端浏览器往往自行决定,该参数只是建议。baseLatency是图渲染延迟,outputLatency是设备输出延迟,两者之和才是"按下播放到听到声音"的下限。
1.1 状态机
suspended ──resume()──▶ running ──suspend()──▶ suspended
│ │
└──────── close() ────┴──▶ closed(不可恢复)
close() 之后 AudioContext 不可复用,必须新建。单页应用里频繁创建/销毁 AudioContext 是常见的资源泄漏来源——浏览器的同时活跃上下文数量有上限(Chrome 历史上是 6 个),超出会抛错。
1.2 推荐的单例模式
let ctx = null;
export function getAudioContext() {
if (!ctx || ctx.state === 'closed') {
ctx = new AudioContext({ latencyHint: 'interactive' });
}
return ctx;
}
export async function unlockAudio() {
const c = getAudioContext();
if (c.state === 'suspended') await c.resume(); // 必须在用户手势里调用
return c;
}
2. 音频图:节点、连接与声道规则
节点之间用 connect() 连接,形成有向无环图。
const src = ctx.createBufferSource();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
const analyser = ctx.createAnalyser();
src.connect(gain);
gain.connect(filter);
filter.connect(analyser);
analyser.connect(ctx.destination);
src.start();
2.1 声道数上混与下混
连接时,若源节点与目标节点的声道数不同,浏览器会按规范化的上混/下混规则自动转换:
| 源 → 目标 | 行为 |
|---|---|
| mono → stereo | 复制到左右两声道(-3 dB 或 0 dB,取决于是否 channelInterpretation: 'speakers') |
| stereo → mono | 左右相加后乘 0.5 |
| stereo → 5.1 | 左右进 L/R,中置与环绕留空 |
| 5.1 → stereo | L/R 保留,C 混入 L/R(-3 dB),环绕混入(-3 dB),LFE 丢弃 |
这套规则由 channelCountMode('max' / 'clamped-max' / 'explicit')与 channelCount 控制。混音节点(GainNode)默认 'max',会把声道数提升到输入的最大值——这就是"接了单声道节点后整个链路变成单声道"的常见原因。
2.2 图的动态修改
connect() / disconnect() 是可以在任意时刻调用的,浏览器会在块边界生效。但要注意:
- 节点被断开且没有引用时会被 GC,若源节点还在播放(
AudioBufferSourceNode已start()),GC 掉会静默停止。稳妥做法是持有引用直到onended。 AudioBufferSourceNode是一次性的,start()之后不能再次start(),需要重新createBufferSource()。- 反馈环(A → B → A)在 Web Audio 中是允许的,浏览器会自动插入至少 128 帧延迟避免死锁。这与"音频图必须是 DAG"的一般认知不同。
// 允许的反馈:延迟 + 反馈增益 < 1 才稳定
const delay = ctx.createDelay(1.0);
delay.delayTime.value = 0.25;
const fb = ctx.createGain();
fb.gain.value = 0.6; // 必须 < 1,否则自激
src.connect(delay);
delay.connect(fb);
fb.connect(delay); // 反馈环
delay.connect(ctx.destination);
3. 时间模型与精确调度
AudioContext.currentTime 是音频线程的时钟,以秒为单位、单调递增、精度远高于 Date.now()(后者受限于系统时钟分辨率,且会被 NTP 校正)。
// 在"当前时间 + 0.5 秒"处精确启动
const t0 = ctx.currentTime;
src.start(t0 + 0.5);
// 调度一个 4 拍循环,提前调度下一拍避免抖动
function scheduleClick(when) {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.frequency.value = 1000;
env.gain.setValueAtTime(0.8, when);
env.gain.exponentialRampToValueAtTime(0.001, when + 0.05);
osc.connect(env).connect(ctx.destination);
osc.start(when);
osc.stop(when + 0.06);
}
3.1 为什么必须提前调度
setTimeout 的抖动在毫秒级,而音乐的时间精度要求在亚毫秒。标准做法是前瞻调度(look-ahead scheduling):
const LOOKAHEAD = 0.1; // 提前 100 ms 调度
const INTERVAL = 25; // 每 25 ms 检查一次
let nextNoteTime = ctx.currentTime;
let step = 0;
setInterval(() => {
while (nextNoteTime < ctx.currentTime + LOOKAHEAD) {
scheduleClick(nextNoteTime);
nextNoteTime += 0.125; // 120 BPM 的十六分音符
step++;
}
}, INTERVAL);
这样即使 setInterval 被延迟几十毫秒,音频事件仍然精确落点。这正是所有 Web 音序器的核心模式。
3.2 currentTime 与墙上时钟的差异
currentTime 由音频硬件时钟驱动,与 performance.now() 存在缓慢漂移。若要把音频事件与动画(requestAnimationFrame)对齐,需要建立映射:
const audioStart = ctx.currentTime;
const perfStart = performance.now();
const toAudioTime = (perfMs) => audioStart + (perfMs - perfStart) / 1000;
长时间运行时这个线性映射会累积误差,需要周期性重校准。
4. AudioParam 参数自动化
AudioParam 是 Web Audio 最强大的设计之一:所有参数都是时间函数,可以按曲线调度。
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(1.0, t + 0.01); // 10 ms 起音
g.gain.setValueAtTime(1.0, t + 0.5);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.8); // 释音
四种调度方法:
| 方法 | 语义 |
|---|---|
setValueAtTime(v, t) | 在 t 时刻跳到 v |
linearRampToValueAtTime(v, t) | 从上一个事件线性插值到 v |
exponentialRampToValueAtTime(v, t) | 指数插值(不能跨 0,两端必须同号且非零) |
setTargetAtTime(v, t, tau) | 一阶低通逼近(指数趋近),tau 为时间常数 |
4.1 指数斜坡的坑
exponentialRampToValueAtTime 要求起始值与目标值同号且非零。淡出到 0 是常见错误:
// 错误:会抛 InvalidStateError
g.gain.exponentialRampToValueAtTime(0, t + 0.5);
// 正确:逼近到一个极小正数
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
g.gain.setValueAtTime(0, t + 0.5);
4.2 AudioParam 也能被连接
AudioParam 可以作为 connect() 的目标,实现"用信号调制参数"(FM、AM、LFO 调制滤波器截止频率等):
const lfo = ctx.createOscillator();
lfo.frequency.value = 5; // 5 Hz 颤音
const lfoGain = ctx.createGain();
lfoGain.gain.value = 30; // ±30 Hz 深度
lfo.connect(lfoGain);
lfoGain.connect(filter.frequency); // 调制截止频率
lfo.start();
连接后,参数值 = 调度值 + 所有输入信号之和。这个加法语义意味着调制是双向叠加的,不能"覆盖"。
4.3 k-rate 与 a-rate
AudioParam 有 automationRate('a-rate' 逐样本 / 'k-rate' 逐块)。BiquadFilterNode 的 frequency 默认 a-rate(逐样本更新,算力高),DynamicsCompressorNode 的参数是 k-rate(每 128 帧更新一次)。把不必要 a-rate 的参数设为 k-rate 可以显著降低算力。
5. 常用内置节点速查
| 节点 | 关键参数 | 典型用途 |
|---|---|---|
GainNode | gain | 音量、淡入淡出、混音 |
BiquadFilterNode | type, frequency, Q, gain | 均衡、低通、高通 |
DelayNode | delayTime, maxDelayTime | 延迟、回声、反馈环 |
ConvolverNode | buffer, normalize | 混响(卷积脉冲响应) |
DynamicsCompressorNode | threshold, ratio, attack, release, knee | 压缩、限幅 |
WaveShaperNode | curve, oversample | 失真、波形整形 |
OscillatorNode | type, frequency, detune | 合成、LFO、测试音 |
AudioBufferSourceNode | buffer, playbackRate, loop | 采样播放 |
StereoPannerNode | pan | 立体声定位 |
PannerNode | panningModel, positionX/Y/Z | 3D 空间音频 |
AnalyserNode | fftSize, getByteFrequencyData() | 频谱可视化 |
ChannelSplitter/Merger | — | 多声道拆分与合并 |
5.1 WaveShaper 的过采样
WaveShaperNode 的 oversample 属性可设为 'none' / '2x' / '4x'。失真必然产生超出奈奎斯特频率的谐波,不设过采样会产生混叠,听感刺耳。这是 audio-sampling-quantization
中"非线性处理必过采样"原则在 Web Audio 里的直接体现。
const shaper = ctx.createWaveShaper();
shaper.curve = makeDistortionCurve(50); // 曲线在 [-1, 1] 上定义
shaper.oversample = '4x'; // 关键:抑制混叠
5.2 ConvolverNode 的算力
ConvolverNode 用 FFT 分块卷积实现,算力与脉冲响应长度成正比。一个 3 秒、48 kHz 的立体声混响 IR 有 144000 个样本,卷积开销相当可观。移动端上同时开多个卷积混响会明显掉帧。工程做法是复用同一个 ConvolverNode 实例,或改用更便宜的算法混响(反馈延迟网络)。
6. AudioBuffer 与音频数据
AudioBuffer 是内存中的 PCM 数据块,支持多声道、32 bit float。
// 从文件解码
const res = await fetch('/samples/kick.wav');
const arr = await res.arrayBuffer();
const buf = await ctx.decodeAudioData(arr);
console.log(buf.sampleRate, buf.numberOfChannels, buf.length, buf.duration);
// 手工构造
const ab = ctx.createBuffer(2, ctx.sampleRate * 2, ctx.sampleRate);
const left = ab.getChannelData(0); // Float32Array,长度 = length
for (let i = 0; i < left.length; i++) {
left[i] = Math.sin(2 * Math.PI * 440 * i / ctx.sampleRate) * 0.5;
}
6.1 解码是异步且昂贵的
decodeAudioData 在主线程之外解码,但结果通过 Promise 返回主线程,大文件(如 10 分钟 WAV)会带来明显的内存峰值。注意:
- 解码后的数据采样率会自动匹配
AudioContext.sampleRate,若源文件是 44.1 kHz 而上下文是 48 kHz,浏览器会做一次重采样。 decodeAudioData会消耗掉传入的ArrayBuffer(detach),若需复用必须先slice()一份。- 解码后的内存占用 =
length × channels × 4字节,10 分钟立体声 48 kHz 约 230 MB。移动端要谨慎。
6.2 大素材的替代方案
长音频(背景音乐、播客)不适合全量解码进 AudioBuffer。可选方案:
- 用
MediaElementAudioSourceNode包装<audio>标签,流式播放,但不能用AudioBufferSourceNode的精确调度。 - 用
MediaStreamAudioSourceNode接收网络流(WebRTC、MediaRecorder)。 - 自行分块解码(
AudioDecoder,WebCodecs)再逐块送入处理链。
7. OfflineAudioContext 离线渲染
OfflineAudioContext 用同一套图 API,但以"尽可能快"的速度渲染到内存,不接触硬件。
async function renderImpulse(durationSec = 2) {
const sr = 48000;
const offline = new OfflineAudioContext(2, sr * durationSec, sr);
const osc = offline.createOscillator();
const gain = offline.createGain();
osc.connect(gain).connect(offline.destination);
gain.gain.setValueAtTime(1, 0);
gain.gain.exponentialRampToValueAtTime(0.0001, durationSec);
osc.start(0);
osc.stop(durationSec);
const rendered = await offline.startRendering();
return rendered; // AudioBuffer
}
用途:
- 导出:把用户编辑好的工程渲染成 WAV(配合
WAV编码写文件)。 - 离线处理:批量做归一化、响度分析、卷积。
- 测试:在 CI 里用固定输入渲染并比对输出,做音频回归(思路与 audio-quality-testing 一致)。
注意 OfflineAudioContext 的 sampleRate 可以任意指定(不被硬件限制),但某些浏览器对极端值(如 8000 或 192000)支持有限。
8. 浏览器策略:autoplay 与音频焦点
8.1 Autoplay 策略
所有主流浏览器都要求 AudioContext 在用户手势中 resume(),否则一直停留在 suspended。判定标准是 navigator.userActivation.hasBeenActive。
document.addEventListener('click', async () => {
const ctx = getAudioContext();
if (ctx.state === 'suspended') await ctx.resume();
}, { once: true });
注意:创建 AudioContext 本身不会报错,报错或静默失效发生在 resume() 或第一次 start() 时。因此把 resume() 绑定到第一个用户手势是必备的初始化步骤。
8.2 后台节流
页面切到后台时,setTimeout / setInterval 会被节流到 1 秒一次,但音频线程不受影响——已调度的音频事件会照常播放。这带来一个陷阱:若用 setInterval 做前瞻调度,切到后台后前瞻窗口来不及补充,音乐会断续。
解法是在 visibilitychange 时扩大前瞻窗口,或改用 AudioWorklet 内部的样本计数调度(见 audio-worklet-realtime
)。
8.3 音频焦点
移动端(尤其 iOS 与 Android)有音频焦点概念:来电、其他 App 播放都会抢占。Web 端能感知的信号有限,通常靠 AudioContext.state 变化与 visibilitychange 组合推断,并在恢复时重建被中断的调度。
9. 与 AudioWorklet、WASM 的协作
内置节点覆盖了 80% 的场景,剩下 20%(自定义合成算法、物理建模、复杂效果链)需要 AudioWorklet。
分工建议:
- 内置节点:混音、增益、均衡、延迟、压缩、卷积混响、空间化。
- AudioWorklet:自定义 DSP、逐样本逻辑、需要状态的算法(音高检测、颗粒合成、专用合成器)。
- WASM:把已有的 C/C++ DSP 库搬进浏览器,或需要 SIMD 性能的密集运算。
WASM 与 AudioWorklet 结合时的关键约束是不能跨线程调用:WASM 模块必须加载在 AudioWorklet 的 AudioWorkletGlobalScope 中(用 addModule 加载包含 WASM 的 worklet 脚本),而不是主线程。这与 wasm-media-processing-codecs
中讨论的编解码 WASM 化路径一致。
TypeScript 项目里建议对节点参数做类型收窄,避免 any 泛滥,可参考 typescript-advanced-types 中的映射类型技巧。
权衡取舍
| 场景 | 方案 A | 方案 B | 建议 |
|---|---|---|---|
| 短音效播放 | AudioBufferSourceNode | <audio> 标签 | 需精确调度/变调用前者,长音频用后者 |
| 长音频流 | MediaElementAudioSourceNode | WebCodecs 分块解码 | 只需播放用前者,需处理用后者 |
| 自定义 DSP | ScriptProcessorNode | AudioWorklet | 一律用 AudioWorklet,前者已废弃 |
| 混响 | ConvolverNode(真实) | 反馈延迟网络(算法) | 质量优先用卷积,算力优先用算法 |
| 导出 | OfflineAudioContext | 服务端渲染 | 前端已有效果链时用离线渲染 |
| 调度 | setTimeout 前瞻 | AudioWorklet 内部计数 | 简单音序器用前者,高精度用后者 |
常见坑清单
- 忘记在用户手势里 resume:
AudioContext停在suspended,没有任何声音也不报错。 - 反复创建 AudioContext:超出浏览器上限(约 6 个)后抛错,应复用单例。
- exponentialRamp 到 0:抛
InvalidStateError,必须逼近到极小正数再setValueAtTime(0)。 - 声道数被悄悄改变:
channelCountMode默认'max',接了单声道节点后整条链变单声道。 - AudioBufferSourceNode 复用:
start()一次后不可再启动,必须重建节点。 - 解码后不释放引用:大
AudioBuffer常驻内存,移动端容易 OOM。 - 后台标签页音乐断续:
setInterval被节流到 1 s,前瞻窗口不足。 - WaveShaper 不过采样:失真产生的高次谐波折回,听感刺耳。
- 以为反馈环不允许:Web Audio 允许反馈并自动插延迟,但增益必须 < 1 才稳定。
- 用
Date.now()做音频时间:分辨率不足且会被系统时钟校正,必须用ctx.currentTime。
小结
Web Audio API 的核心是"图 + 时间":用 AudioNode 搭出信号流,用 currentTime 与 AudioParam 精确控制每个参数在时间轴上的取值。理解声道上混下混规则、前瞻调度模式与 autoplay 策略,就能覆盖绝大多数前端音频需求。
选型上,先用内置节点把链路搭出来,只有在内置节点无法表达时才引入 AudioWorklet,只有需要复用已有 C/C++ 库或需要 SIMD 性能时才引入 WASM。这个顺序能避免过早引入复杂度。
继续深入建议读 audio-worklet-realtime 掌握实时线程的编程约束,读 audio-dsp-filters 补上滤波与效果的实现细节,读 audio-spatial-3d 理解 PannerNode 背后的 HRTF 与 Ambisonics 原理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。