音频合成器(Synthesizer)

Rust 编写的 8 复音减法合成器,WebAssembly 在 AudioWorklet 音频线程里实时生成声音

点击「启动音频」后弹奏琴键

👆 按琴键 发声(可多指、可滑动)  |  电脑键盘 A~; 对应白键,W E T Y U O P 对应黑键

📖 原理说明

减法合成是模拟合成器最经典的做法:先用振荡器产生谐波丰富的波形,再用滤波器「减掉」一部分高频,最后用包络控制音量随时间的变化。这个合成器完全用 Rust 编写,编译成不依赖任何胶水代码的 .wasm,直接运行在浏览器的 AudioWorklet 音频线程里:每 128 个采样(48kHz 下约 2.7ms)调用一次 Rust 生成下一段声音。

🧮算法原理

f = 440 · 2(n − 69) / 12MIDI 音符号 n 换算成频率:69 是 A4 = 440Hz,每升一个半音乘以 ¹²√2
振荡器 → 滤波器 → 包络 → 混音每个声部独立完成这条链,8 个声部相加后软削波输出
振荡器与 PolyBLEP
锯齿波和方波在跳变处有无限高的谐波,直接按公式采样会产生刺耳的混叠(高于奈奎斯特频率的谐波「折返」成错误的音高)。PolyBLEP 在每个跳变附近叠加一小段多项式修正,把跳变「磨圆」,大部分混叠就消失了。正弦波没有谐波,三角波的谐波衰减得很快,都不需要修正。
状态变量滤波器
低通滤波器保留截止频率以下的成分。这里用的是 Zavalishin 的 TPT 状态变量滤波器:截止频率和共振任意变化都保持稳定。共振提高截止频率附近的增益,调高后能听到典型的「哇」声。截止频率滑块按对数映射,每一段滑动距离对应相同的音程。
ADSR 包络
起音(Attack)从 0 升到最大,衰减(Decay)降到延音电平,按住琴键时保持延音(Sustain),松开后在释音(Release)时间内线性降到 0,声部随之空闲。起音很短、释音很长像钢琴,起音很长像弦乐渐入。
复音与抢占
最多 8 个声部同时发声。按下新音时优先使用空闲声部;都在忙就抢占最早的那个,并且优先抢已经在释音的声部。重复按同一个音会复用原来的声部,包络从当前电平继续,不会发出咔哒声。
软削波
8 个声部叠加可能超过 ±1,直接截断会产生失真的方角。输出前经过 tanh,小信号几乎不变,大信号平滑地逼近 ±1。

🔄Rust 与 JavaScript 的分工

  1. JS点击「启动音频」后,主线程创建 AudioContext,加载 processor.js,并把 pkg/synth/index.wasm 的字节通过 processorOptions 传给音频线程。
  2. JS音频线程里的 AudioWorkletProcessor 用 WebAssembly.instantiate 实例化模块。这个 crate 不用 wasm-bindgen:音频线程不能加载 pkg 里的 ES 模块胶水,而裸的 extern "C" 导出只需要数字参数,imports 为空。
  3. Rust每个渲染量子调用 synth_render(128),Rust 把 128 个采样写进自己内存里的缓冲区并返回指针;JS 用 Float32Array 直接读这块内存复制到输出,不经过任何序列化。
  4. JS琴键和滑块的变化通过 port.postMessage 发给音频线程,再调用 synth_note_on / synth_note_off / synth_set_param。示波器从 AnalyserNode 读取波形,按上升过零点对齐,周期波形就能稳定显示。

⚡性能要点

  • 实测(Node 中运行同一个 .wasm):8 个声部同时发声,生成 1 秒音频约 2.6ms,只占实时预算的 0.3% 左右。
  • 音频线程独立于页面主线程,主线程被耗时的 JS 卡住时声音也不会中断;反过来,音频代码里不能分配大块内存或做耗时操作,这里所有缓冲区都在初始化时分配好。
  • AudioWorklet 只能在安全上下文中使用:localhost 可以,手机通过局域网 IP 访问需要 HTTPS。iPhone 的静音开关打开时网页没有声音。

源码crates/media/synth/src/lib.rswww/synth/processor.jswww/synth/index.js