AudioWorklet 与实时音频处理

AudioWorklet 让 JavaScript 进入音频渲染线程。本文讲解 AudioWorkletProcessor 的结构与 128 帧量子约束、process 的输入输出语义、实时安全规则、parameterDescriptors 参数自动化、主线程与 worklet 的通信模式、WASM 与 SIMD 加速,以及从 ScriptProcessorNode 迁移的实践。

引言

AudioWorklet 是 Web Audio API 里唯一能跑自定义 DSP 的地方。它把用户代码放进音频渲染线程(audio rendering thread),与主线程完全隔离,按固定节奏被调用。这既是它强大的原因,也是它难用的原因:主线程的很多习惯(分配对象、打日志、发网络请求、更新 DOM)在这个线程里全部违规。

它的前身 ScriptProcessorNode 因为同样跑在主线程上而广受诟病——主线程一次垃圾回收就能造成爆音。AudioWorklet 把代码搬到专用线程,从根本上解决了这个问题,但也把"实时安全"的责任交给了开发者:你必须自己保证每次 process() 调用都不会阻塞。

本文从运行环境讲起,覆盖 process() 的调用契约、实时安全的具体规则、参数自动化、跨线程通信、状态管理与性能优化,最后给出与 ScriptProcessorNode 的对比和迁移路径。读者读完应当能写出一个在 128 帧量子下稳定运行、不产生爆音的自定义处理器。

目录

  1. AudioWorklet 的定位与运行环境
  2. AudioWorkletProcessor 的结构
  3. process() 的调用契约与返回值
  4. 实时安全:分配、锁与 GC
  5. 参数:parameterDescriptors 与自动化
  6. 主线程与 worklet 线程的通信
  7. 状态管理与资源生命周期
  8. 性能优化:SIMD、WASM 与批处理
  9. 与 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(wasm_simd128)可达 510 倍。关键是数据零拷贝:WASM 的线性内存与 Float32Array 之间需要显式复制,若每块都复制 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 的对比与迁移

维度ScriptProcessorNodeAudioWorklet
运行线程主线程音频线程
块大小256~16384(可配)固定 128
主线程阻塞影响直接爆音无影响
参数自动化无(需自己实现)原生 AudioParam
状态已废弃推荐
多输入有限支持多输入多输出
调试方便(主线程)困难(需 postMessage)

9.1 迁移要点

  1. 块大小从可变变成固定 128:原来依赖大块做 FFT 的代码需要引入内部缓冲,攒够 N 个量子再处理。
  2. 状态从闭包变量变成实例字段:process() 是方法调用,this 可用。
  3. 参数从主线程变量变成 AudioParam:原来直接读的全局变量要改成 parameterDescriptors 或 postMessage。
  4. 日志与错误处理要重构:不能直接 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内置节点够用就别自己写
自定义 DSPAudioWorklet (JS)AudioWorklet + WASM算法简单用 JS,热点用 WASM
参数传递postMessageSharedArrayBuffer低频用前者,逐块更新用后者
节点回收返回 false恒返回 true除明确需要回收外恒 true
大块处理攒量子改用离线渲染实时链路攒量子,离线用 OfflineAudioContext
调试console.logpostMessage 上报一律后者,前者会爆音

常见坑清单

  1. 在 process 里 new 对象:触发 GC 停顿,表现为随机爆音。所有分配移到构造函数。
  2. 忘记清零输出缓冲:输出数组是复用的,残留上一块数据,听到周期性噪声。
  3. a-rate 参数按长度 1 处理:无自动化时数组长度为 1,写 g[i] 会读到 undefined 产生 NaN。
  4. 输入为空时抛异常:inputs[0] 可能是空数组,异常会导致节点被静默移除。
  5. 在音频线程 console.log:I/O 耗时可达毫秒级,直接造成欠载。
  6. 返回 false 后输入恢复:节点不会自动唤醒,链路静默,需恒返回 true。
  7. postMessage 回调里做重活:回调在音频线程执行,解析大对象会超时,应只赋值 + 标志位。
  8. Atomics.wait 阻塞音频线程:音频线程不能用阻塞原语,只能用原子读写。
  9. 假设块大小可变:render quantum 恒为 128,依赖更大块的算法需内部攒缓冲。
  10. 切换 AudioContext 后复用旧 worklet:sampleRate 变化后预计算系数失效,必须重新加载模块并重算。

小结

AudioWorklet 把自定义 DSP 带进了浏览器,代价是把实时安全的全部责任交给了开发者。三条核心规则:所有分配在构造期完成、process() 只做算术与数组索引、跨线程通信只传必要数据。守住这三条,就能在 2.67 ms 的预算内稳定运行。

实践路径建议:先用内置节点搭出链路,只有在内置节点无法表达时才写 AudioWorklet;先用纯 JS 实现验证算法正确性,只有确认算力不足时才引入 WASM;先在 OfflineAudioContext 里验证输出正确,再接入实时链路。这个顺序能大幅减少调试成本。

继续深入建议读 audio-dsp-filters 获取可用的滤波与效果器实现,读 audio-spectral-analysis-fft 掌握频谱分析在实时链路中的用法,读 audio-streaming-latency 理解端到端延迟中音频线程这一段的占比。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「音频工程」更多文章

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