ChunLong Blog

ChunLong Blog

LOADING

让烟花听起来像烟花

2026-09-151028 字 · 约 3 分钟0 次阅读

实验室的烟花上线以来,收到最扎心的反馈是:「这个音效像橡皮子弹」。

烟花页的音效是纯 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,谁也不打扰谁。

相关阅读

评论