Web Audio API 与音频图建模

Web Audio API 用有向图建模音频信号流。本文讲解 AudioContext 的生命周期与采样率行为、节点连接与声道数上混下混规则、AudioParam 的调度时间模型、常用内置节点的参数与适用场景、AudioBuffer 与解码、OfflineAudioContext 离线渲染,以及 autoplay 策略、音频焦点与 WASM 协作的工程实践。

引言

Web Audio API 是浏览器里唯一被广泛支持的低延迟音频处理接口。它把音频处理抽象成一张有向图:节点(AudioNode)是处理单元,连线是音频数据流,由浏览器内部的音频线程按固定块大小(通常 128 帧)驱动整张图。

它的设计目标不是"播放一个 mp3"(那是 <audio> 标签的事),而是实时合成、处理与空间化。这也是它比 <audio> 复杂得多的原因:你要自己管理时间、自己调度、自己处理生命周期。好消息是这套模型与桌面音频框架(CoreAudio、VST)高度同构,理解一套即可迁移。

工程上的难点集中在四处:时间模型(currentTime 与调度语义)、图的生命周期(什么时候可以断开、什么时候会被 GC)、参数自动化(AudioParam 的调度曲线如何与事件交互)、以及浏览器策略(autoplay、音频焦点、后台节流)。本文按这四个难点组织,并在最后给出与 AudioWorklet、WASM 协作的实践路径。

目录

  1. AudioContext 与生命周期
  2. 音频图:节点、连接与声道规则
  3. 时间模型与精确调度
  4. AudioParam 参数自动化
  5. 常用内置节点速查
  6. AudioBuffer 与音频数据
  7. OfflineAudioContext 离线渲染
  8. 浏览器策略:autoplay 与音频焦点
  9. 与 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 → stereoL/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. 常用内置节点速查

节点关键参数典型用途
GainNodegain音量、淡入淡出、混音
BiquadFilterNodetype, frequency, Q, gain均衡、低通、高通
DelayNodedelayTime, maxDelayTime延迟、回声、反馈环
ConvolverNodebuffer, normalize混响(卷积脉冲响应)
DynamicsCompressorNodethreshold, ratio, attack, release, knee压缩、限幅
WaveShaperNodecurve, oversample失真、波形整形
OscillatorNodetype, frequency, detune合成、LFO、测试音
AudioBufferSourceNodebuffer, playbackRate, loop采样播放
StereoPannerNodepan立体声定位
PannerNodepanningModel, positionX/Y/Z3D 空间音频
AnalyserNodefftSize, 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> 标签需精确调度/变调用前者,长音频用后者
长音频流MediaElementAudioSourceNodeWebCodecs 分块解码只需播放用前者,需处理用后者
自定义 DSPScriptProcessorNodeAudioWorklet一律用 AudioWorklet,前者已废弃
混响ConvolverNode(真实)反馈延迟网络(算法)质量优先用卷积,算力优先用算法
导出OfflineAudioContext服务端渲染前端已有效果链时用离线渲染
调度setTimeout 前瞻AudioWorklet 内部计数简单音序器用前者,高精度用后者

常见坑清单

  1. 忘记在用户手势里 resume:AudioContext 停在 suspended,没有任何声音也不报错。
  2. 反复创建 AudioContext:超出浏览器上限(约 6 个)后抛错,应复用单例。
  3. exponentialRamp 到 0:抛 InvalidStateError,必须逼近到极小正数再 setValueAtTime(0)。
  4. 声道数被悄悄改变:channelCountMode 默认 'max',接了单声道节点后整条链变单声道。
  5. AudioBufferSourceNode 复用:start() 一次后不可再启动,必须重建节点。
  6. 解码后不释放引用:大 AudioBuffer 常驻内存,移动端容易 OOM。
  7. 后台标签页音乐断续:setInterval 被节流到 1 s,前瞻窗口不足。
  8. WaveShaper 不过采样:失真产生的高次谐波折回,听感刺耳。
  9. 以为反馈环不允许:Web Audio 允许反馈并自动插延迟,但增益必须 < 1 才稳定。
  10. 用 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 原理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「音频工程」更多文章

  1. 音视频同步与时间码
  2. 音频硬件接口与驱动栈
  3. 响度标准化与交付规范