引言
AudioWorklet 是 Web Audio API 里唯一能跑自定义 DSP 的地方。它把用户代码放进音频渲染线程(audio rendering thread),与主线程完全隔离,按固定节奏被调用。这既是它强大的原因,也是它难用的原因:主线程的很多习惯(分配对象、打日志、发网络请求、更新 DOM)在这个线程里全部违规。
它的前身 ScriptProcessorNode 因为同样跑在主线程上而广受诟病——主线程一次垃圾回收就能造成爆音。AudioWorklet 把代码搬到专用线程,从根本上解决了这个问题,但也把"实时安全"的责任交给了开发者:你必须自己保证每次 process() 调用都不会阻塞。
本文从运行环境讲起,覆盖 process() 的调用契约、实时安全的具体规则、参数自动化、跨线程通信、状态管理与性能优化,最后给出与 ScriptProcessorNode 的对比和迁移路径。读者读完应当能写出一个在 128 帧量子下稳定运行、不产生爆音的自定义处理器。
目录
- AudioWorklet 的定位与运行环境
- AudioWorkletProcessor 的结构
- process() 的调用契约与返回值
- 实时安全:分配、锁与 GC
- 参数:parameterDescriptors 与自动化
- 主线程与 worklet 线程的通信
- 状态管理与资源生命周期
- 性能优化:SIMD、WASM 与批处理
- 与 ScriptProcessorNode 的对比与迁移
1. AudioWorklet 的定位与运行环境
AudioWorklet 是 BaseAudioContext 的一个属性,通过 audioWorklet.addModule(url) 加载模块。加载的脚本运行在 AudioWorkletGlobalScope,这是一个独立于主线程的全局作用域,其中:
- 没有
window、document、DOM API。 - 没有
fetch之外的网络能力受限(部分实现甚至不提供fetch)。 - 有
sampleRate、currentTime、currentFrame三个全局量。 - 有
registerProcessor()、AudioWorkletProcessor、AudioWorkletNode构造器。
// 主线程
const ctx = new AudioContext();
await ctx.audioWorklet.addModule('/worklets/gain-processor.js');
const node = new AudioWorkletNode(ctx, 'my-gain', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2],
});
source.connect(node).connect(ctx.destination);
// /worklets/gain-processor.js —— 运行在音频线程
class MyGainProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'gain', defaultValue: 1.0, minValue: 0, maxValue: 4 }];
}
process(inputs, outputs, parameters) {
const input = inputs[0]; // Float32Array[],按声道索引
const output = outputs[0];
const gain = parameters.gain; // Float32Array,长度 1 或 128
for (let ch = 0; ch < output.length; ch++) {
const inCh = input[ch];
const outCh = output[ch];
if (!inCh) { outCh.fill(0); continue; }
for (let i = 0; i < outCh.length; i++) outCh[i] = inCh[i] * gain[0];
}
return true; // 保持节点存活
}
}
registerProcessor('my-gain', MyGainProcessor);
1.1 模块加载时机
addModule() 是异步的,且每个 AudioContext 只能加载一次同名模块(重复加载同一 URL 会抛错或被忽略,取决于实现)。稳妥做法是用一个 Promise 缓存加载状态:
const loaded = new WeakMap();
function ensureWorklet(ctx, url) {
if (!loaded.has(ctx)) loaded.set(ctx, ctx.audioWorklet.addModule(url));
return loaded.get(ctx);
}
2. AudioWorkletProcessor 的结构
一个处理器类必须继承 AudioWorkletProcessor,并实现 process()。构造阶段可以接收 options:
class MyProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
this._phase = 0;
this._freq = options?.processorOptions?.frequency ?? 440;
this._buf = new Float32Array(128); // 构造期分配是允许的
}
process(inputs, outputs, params) { /* ... */ return true; }
}
2.1 128 帧量子(Render Quantum)
process() 每次被调用时,outputs 中每个 Float32Array 的长度恒为 128(这是规范规定的 render quantum)。这意味着:
- 你无法改变块大小,也无法要求更大的块。
- 128 帧 @ 48 kHz = 2.67 ms,这是两次调用之间的硬截止时间。
- 处理必须在 2.67 ms 内完成,否则欠载(underrun),听感是"啪"或"咔"。
2.2 输入可能为空
当上游节点没有输出、或输入被断开时,inputs[0] 可能是空数组 [],或其中的声道数组是 undefined。必须处理这种情况,否则抛异常会导致节点被静默移除。
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const hasInput = input && input.length > 0 && input[0];
if (!hasInput) {
for (const ch of output) ch.fill(0);
return true;
}
// ... 正常处理
return true;
}
2.3 输出声道数与输入不同
若 outputChannelCount 设为 [2] 而输入是单声道,outputs[0] 有两个声道但 inputs[0] 只有一个。写代码时不要假设声道数相等,要么显式复制到两个声道,要么用 channelCountMode 让浏览器帮你上混。
3. process() 的调用契约与返回值
process() 的返回值决定节点是否继续存活:
| 返回值 | 语义 |
|---|---|
true | 节点保持活跃,process() 会被继续调用 |
false | 节点可以停止(当无输入且无引用时被 GC) |
对持续产生声音的节点(振荡器、噪声源),必须返回 true。对纯处理节点(增益、滤波),当输入静默时可以返回 false 让浏览器回收,但要注意若之后输入恢复,节点不会被自动唤醒——这在实践中经常导致"处理链断了"的困惑。稳妥做法是永远返回 true,除非明确需要回收。
3.1 输出缓冲的初始化
outputs 中的数组是预分配并复用的,浏览器不保证每次调用前清零。若你的算法是"累加"语义(例如多输入相加),必须自己先 fill(0):
process(inputs, outputs) {
for (const ch of outputs[0]) ch.fill(0); // 关键:先清零
for (const input of inputs) {
for (let c = 0; c < input.length; c++) {
const src = input[c];
const dst = outputs[0][c] ?? outputs[0][0];
for (let i = 0; i < src.length; i++) dst[i] += src[i];
}
}
return true;
}
这是最高频的 bug 来源之一:忘记清零会听到上一块的残留,表现为周期性噪声。
4. 实时安全:分配、锁与 GC
音频线程的硬约束是每次 process() 必须在 2.67 ms 内返回。任何可能耗时不确定的操作都是禁止的。
4.1 禁止清单
| 操作 | 后果 |
|---|---|
new / 数组字面量 / 闭包 | 触发 GC,可能停顿数毫秒 |
console.log | 序列化 + I/O,实测可达毫秒级 |
Atomics.wait / 任何阻塞 | 直接错过截止时间 |
fetch / XMLHttpRequest | 网络不可预期 |
postMessage 高频调用 | 序列化开销 + 主线程压力 |
复杂的 try/catch 与异常构造 | 异常对象分配昂贵 |
| 正则表达式 | 可能回溯爆炸 |
Array.prototype.map/filter | 分配新数组 |
4.2 正确的写法:预分配 + 复用
class DelayProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
const maxSamples = sampleRate * 2; // 最多 2 秒
this._line = new Float32Array(maxSamples); // 构造期一次性分配
this._write = 0;
this._delaySamples = Math.floor(sampleRate * 0.25);
}
process(inputs, outputs, params) {
const input = inputs[0], output = outputs[0];
if (!input || !input[0]) return true;
const inCh = input[0], outCh = output[0];
const n = inCh.length; // 恒为 128
const size = this._line.length;
for (let i = 0; i < n; i++) {
const read = (this._write - this._delaySamples + size) % size;
outCh[i] = this._line[read];
this._line[this._write] = inCh[i];
this._write = (this._write + 1) % size;
}
return true;
}
}
注意这里所有内存都在构造函数里分配,process() 内只有算术与数组索引。这与 audio-dsp-filters
中延迟线效果器的结构完全一致。
4.3 打日志的正确姿势
需要调试时,把数据攒起来,周期性(例如每 100 次调用)通过 postMessage 发给主线程,由主线程打印:
process(inputs, outputs, params) {
// ... 处理 ...
this._counter = (this._counter || 0) + 1;
if (this._counter % 200 === 0) {
this.port.postMessage({ peak: this._lastPeak }); // 低频,可接受
}
return true;
}
5. 参数:parameterDescriptors 与自动化
parameterDescriptors 静态属性声明可在主线程通过 AudioParam 控制的参数:
static get parameterDescriptors() {
return [
{ name: 'gain', defaultValue: 1.0, minValue: 0, maxValue: 4,
automationRate: 'a-rate' },
{ name: 'bypass', defaultValue: 0, minValue: 0, maxValue: 1,
automationRate: 'k-rate' },
];
}
5.1 a-rate 与 k-rate 的数组长度差异
这是最容易写错的地方:
- k-rate:
parameters.gain长度恒为 1,整块用同一个值。 - a-rate:当该参数在当前块内没有自动化事件时,长度也为 1(优化);有自动化时长度为 128。
const g = parameters.gain;
if (g.length === 1) {
const v = g[0];
for (let i = 0; i < outCh.length; i++) outCh[i] = inCh[i] * v;
} else {
for (let i = 0; i < outCh.length; i++) outCh[i] = inCh[i] * g[i];
}
若代码里直接写 g[i] 而不判断长度,在无自动化时会读到 undefined,结果是 NaN 一路传播到输出,表现为静音或爆音。
5.2 平滑参数避免 zipper noise
直接改变增益会产生"拉链噪声"(zipper noise)。标准做法是在处理器内部做一阶平滑:
// 每个样本向目标值逼近,时间常数约 5 ms
const coeff = 1 - Math.exp(-1 / (0.005 * sampleRate));
this._smooth += (target - this._smooth) * coeff;
这与 audio-dsp-filters 中讨论的参数平滑策略是同一个问题。
6. 主线程与 worklet 线程的通信
AudioWorkletNode 有一个 port(MessagePort),用于双向通信。
// 主线程
node.port.postMessage({ type: 'setWave', data: [0, 1, 0, -1] });
node.port.onmessage = (e) => { /* 接收 worklet 发来的消息 */ };
// worklet
class P extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = (e) => {
if (e.data.type === 'setWave') this._wave = Float32Array.from(e.data.data);
};
}
}
6.1 postMessage 的时机与代价
port.onmessage 的回调在音频线程执行,但它的执行时机是不确定的(消息到达时)。若回调里做了重活(解析大 JSON、分配大数组),可能落在 process() 中间造成超时。
稳妥模式是双缓冲 + 标志位:消息处理只把数据写进"待用缓冲"并置一个标志,process() 在块边界检查标志并切换。
constructor() {
super();
this._pending = null;
this._active = null;
this.port.onmessage = (e) => { this._pending = e.data.ir; }; // 只赋值
}
process(inputs, outputs) {
if (this._pending) { this._active = this._pending; this._pending = null; }
// 使用 this._active
return true;
}
6.2 SharedArrayBuffer
需要零拷贝、低延迟的参数传递时可用 SharedArrayBuffer(需要 COOP/COEP 响应头)。配合 Atomics 可以做无锁的单生产者单消费者队列,模式与 cpp-lockfree-data-structures
中描述的 SPSC 环形队列一致。注意在音频线程里不能用 Atomics.wait(会阻塞),只能用 Atomics.load / Atomics.store 做原子读写。
7. 状态管理与资源生命周期
7.1 构造与销毁
AudioWorkletProcessor 没有显式的 dispose()。当节点被 GC 时,process() 不再被调用。若持有外部资源(如 WASM 堆内存),需要在 process() 中检测"是否已被断开"并主动清理,或通过 port.onmessage 接收主线程的显式销毁指令。
7.2 采样率与上下文变化
sampleRate 是 AudioWorkletGlobalScope 的全局量,在 worklet 内不可变。若主线程切换了 AudioContext(不同采样率),必须重新 addModule 到新的上下文,所有基于 sampleRate 预计算的系数都要重算。这类"上下文重建"是 SPA 中音频 bug 的常见来源。
7.3 多实例与共享状态
同一个处理器类可以有多个实例(每个 AudioWorkletNode 一个)。它们不共享内存,除非用 SharedArrayBuffer。若需要跨实例共享(例如一个全局的噪声源),必须在主线程层协调。
8. 性能优化:SIMD、WASM 与批处理
8.1 算力预算
128 帧 @ 48 kHz,2.67 ms 的预算。一个简单的逐样本乘法在 JS 中约 1~2 ns,128 个样本约 0.2 μs,余量充足。但复杂算法(物理建模、颗粒合成)可能需要每样本数百次运算:
预算:2.67 ms = 2,670,000 ns
每样本可用:2,670,000 / 128 ≈ 20,859 ns(看似很多)
但按 3 GHz 主频折算约 62,000 个时钟周期 / 128 样本 ≈ 480 周期/样本
实际上音频线程还要与主线程争抢 CPU,实际可用预算通常按 30~50% 估算。
8.2 WASM 加速
把热点算法编译成 WASM 可以拿到 25 倍加速,若用 SIMD(10 倍。关键是数据零拷贝:WASM 的线性内存与 wasm_simd128)可达 5Float32Array 之间需要显式复制,若每块都复制 128 帧 × 声道数,开销可能吃掉加速收益。做法是让 WASM 侧暴露一个指向内部缓冲的指针,JS 侧用 new Float32Array(wasmMemory.buffer, ptr, len) 建立视图(注意内存增长后视图会失效,需重建)。
浏览器端 WASM 音频处理的更多细节见 wasm-media-processing-codecs 。
8.3 批处理与向量化
即使不用 WASM,也可以利用 JS 引擎的自动向量化:
- 用
Float32Array而非普通数组(引擎可优化为连续内存)。 - 避免在循环中做类型转换(
Math.floor等)。 - 把分支提到循环外(例如把
g.length === 1的判断放在两层循环之外)。 - 尽量用
+=、*=等原地操作。
9. 与 ScriptProcessorNode 的对比与迁移
| 维度 | ScriptProcessorNode | AudioWorklet |
|---|---|---|
| 运行线程 | 主线程 | 音频线程 |
| 块大小 | 256~16384(可配) | 固定 128 |
| 主线程阻塞影响 | 直接爆音 | 无影响 |
| 参数自动化 | 无(需自己实现) | 原生 AudioParam |
| 状态 | 已废弃 | 推荐 |
| 多输入 | 有限 | 支持多输入多输出 |
| 调试 | 方便(主线程) | 困难(需 postMessage) |
9.1 迁移要点
- 块大小从可变变成固定 128:原来依赖大块做 FFT 的代码需要引入内部缓冲,攒够 N 个量子再处理。
- 状态从闭包变量变成实例字段:
process()是方法调用,this可用。 - 参数从主线程变量变成 AudioParam:原来直接读的全局变量要改成
parameterDescriptors或postMessage。 - 日志与错误处理要重构:不能直接
console.log,异常要捕获并通过port上报。
// 攒够 1024 帧再做一次 FFT 的模式
class FftProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._buf = new Float32Array(1024);
this._pos = 0;
}
process(inputs, outputs) {
const inCh = inputs[0]?.[0];
if (!inCh) return true;
const outCh = outputs[0][0];
for (let i = 0; i < 128; i++) {
outCh[i] = inCh[i];
this._buf[this._pos++] = inCh[i];
if (this._pos === 1024) {
this._analyze(this._buf); // 每 1024 帧分析一次
this._pos = 0;
}
}
return true;
}
}
权衡取舍
| 需求 | 方案 A | 方案 B | 建议 |
|---|---|---|---|
| 简单增益/滤波 | 内置节点 | AudioWorklet | 内置节点够用就别自己写 |
| 自定义 DSP | AudioWorklet (JS) | AudioWorklet + WASM | 算法简单用 JS,热点用 WASM |
| 参数传递 | postMessage | SharedArrayBuffer | 低频用前者,逐块更新用后者 |
| 节点回收 | 返回 false | 恒返回 true | 除明确需要回收外恒 true |
| 大块处理 | 攒量子 | 改用离线渲染 | 实时链路攒量子,离线用 OfflineAudioContext |
| 调试 | console.log | postMessage 上报 | 一律后者,前者会爆音 |
常见坑清单
- 在 process 里 new 对象:触发 GC 停顿,表现为随机爆音。所有分配移到构造函数。
- 忘记清零输出缓冲:输出数组是复用的,残留上一块数据,听到周期性噪声。
- a-rate 参数按长度 1 处理:无自动化时数组长度为 1,写
g[i]会读到undefined产生 NaN。 - 输入为空时抛异常:
inputs[0]可能是空数组,异常会导致节点被静默移除。 - 在音频线程 console.log:I/O 耗时可达毫秒级,直接造成欠载。
- 返回 false 后输入恢复:节点不会自动唤醒,链路静默,需恒返回 true。
- postMessage 回调里做重活:回调在音频线程执行,解析大对象会超时,应只赋值 + 标志位。
- Atomics.wait 阻塞音频线程:音频线程不能用阻塞原语,只能用原子读写。
- 假设块大小可变:render quantum 恒为 128,依赖更大块的算法需内部攒缓冲。
- 切换 AudioContext 后复用旧 worklet:
sampleRate变化后预计算系数失效,必须重新加载模块并重算。
小结
AudioWorklet 把自定义 DSP 带进了浏览器,代价是把实时安全的全部责任交给了开发者。三条核心规则:所有分配在构造期完成、process() 只做算术与数组索引、跨线程通信只传必要数据。守住这三条,就能在 2.67 ms 的预算内稳定运行。
实践路径建议:先用内置节点搭出链路,只有在内置节点无法表达时才写 AudioWorklet;先用纯 JS 实现验证算法正确性,只有确认算力不足时才引入 WASM;先在 OfflineAudioContext 里验证输出正确,再接入实时链路。这个顺序能大幅减少调试成本。
继续深入建议读 audio-dsp-filters 获取可用的滤波与效果器实现,读 audio-spectral-analysis-fft 掌握频谱分析在实时链路中的用法,读 audio-streaming-latency 理解端到端延迟中音频线程这一段的占比。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。