让烟花听起来像烟花
实验室的烟花上线以来,收到最扎心的反馈是:「这个音效像橡皮子弹」。
烟花页的音效是纯 Web Audio 实时合成的——零音频文件、零下载体积,我很为这个设计骄傲。但骄傲和听感是两回事。这篇文章记录怎么把"橡皮子弹"修成"烟花",以及为什么最终的方案是合成与采样并存。
先解剖:为什么听起来不对
原来的炸开声,本质上是两层:
tone(150, 0.3, 0.15 * v, "sine", 38); // 一个 sine 从 150Hz 坠到 38Hz
noise(0.36, 1150, 90, 0.11 * v); // 一段白噪声过单一 lowpass 扫频低频"咚"加一段闷掉的噪声。问题就出在这:
- 没有高频瞬态。真实爆炸起振那一刻有极短的高频"炸裂声",而单一低通滤波把高频一路闷掉了——只剩"砰"的body,没有"啪"的 attack;
- 没有空间感。所有声音 dry 直连输出,烟花在开阔夜空里的那点堂音完全没有;
- 起振太慢。包络 attack 有 12ms,对爆炸这种瞬态来说太肉了。
用频谱的语言说:真实烟花的能量随频率衰减得远没有这么快,而且时间结构上有一个接近脉冲的高频起始。
第一步:把合成做到底
升级后的合成引擎把一次炸开拆成四层:
| 层 | 作用 | 实现 |
|---|---|---|
| sub thump | 胸口一闷的低频 | sine 150→38Hz,attack 压到 2ms |
| body | "砰"的主体 | 白噪声 lowpass 1150→90Hz 扫频 |
| crack | 高频炸裂瞬态 | 白噪声 bandpass 3.6kHz 中心,40ms |
| crackle | 余烬噼啪 | 150~900ms 后散布几个窄带脉冲 |
再加两条总线:一个 DynamicsCompressor 把多发的峰值压住,一个程序生成混响——自己造一段 1.2 秒指数衰减的立体声噪声当脉冲响应,喂给 ConvolverNode。没有采样文件,混响也是"合成"的。
TIP
低端设备上卷积混响的 CPU 开销不值得,引擎会检测 navigator.hardwareConcurrency,2 核及以下自动跳过混响只保留压缩。
第二步:承认合成的上限
四层合成进步明显,但诚实地说:和真实录音放在一起 A/B,还是能听出来。合成信号太"干净"了——真实烟花里有碎裂的杂质、气流的抖动、远处的回声尾巴,这些细节一层层堆起来才是"真实"。
所以最终方案是双音色:
- 真实档(默认):Mixkit 上的免商用烟花录音——发射哨音 1 条、四种炸开各 1 条、噼啪余烬 1 条,共 6 个 mp3;
- 合成档:上面那套四层合成,作为回落和备选。
烟花页的音量条旁边多了一个「真实 / 合成」切换,选择记在 localStorage。
采样播放有个小讲究:同一条录音循环播放几次就成了"复读机",所以每次播放都随机变调——playbackRate 在 0.9 到 1.15 之间取随机值,每发听起来都不完全一样:
src.playbackRate.value = 0.9 + Math.random() * 0.25;NOTE
真实采样懒加载:进入烟花页且选择"真实"档时才 fetch + decode,总量约 500KB;任何一个文件加载失败,引擎自动回落合成档并在页面上提示一次。躺在弱网环境里也不会哑火。
体积与音质的账
我的服务器是 2 核 2G 的小水管,音频体积必须计较。这笔账大概是这样:6 条采样 × 12 秒 × 4864kbps ≈ 300~500KB,一次性加载后浏览器缓存。作为对比,随便一首背景音乐 mp3 就是它的两倍。
如果还想更省,合成档永远在那里——它是零体积的。
复盘
这次最大的观念修正是:"零依赖全合成"是一种美学,不是一种音质。合成适合结构性、可参数化的声音(瓶子的玻璃轻响、风铃),而爆炸类瞬态声音的信息密度太高,录音才是正解。
好在线路是双轨的:合成的骨架还在,随时可以调出完全不同性格的"电子烟花";真实的血肉盖在上面,默认给访客最像烟花的声音。代码在 src/lib/lab/fireworks-audio.ts,两套引擎共用一个 AudioContext,谁也不打扰谁。