本站没有任何音频文件。每一次点击、弹跳、爆炸和背景音符,都是浏览器在几百行 JavaScript 里现场合成的。这个决定最初是为了体积——一个上百款游戏的站点很容易背上十兆音效——后来它教会我的游戏音频知识,比任何采样库都多。

为什么要合成

三个理由,按重要性排序:

  1. 体积。 一个合成音效大约二十行代码;录制版本要 30–200 KB,而且为避免重复疲劳你得做好几个变体。
  2. 延迟。 生成的音调在你排程的那一刻准确开始;解码文件要先取、再解、再播——延迟很小,但那正是「跟手」和「黏糊」的区别。
  3. 音高。 合成可以为每次事件改变音高。三次弹跳用略有差别的音高听起来是活的;同一个采样播三遍听起来像 bug。

代价是个性。合成爆炸永远不像真实爆炸,它像一个游戏爆炸——而对街机站点来说,这本来就是正确的美学。

浏览器一开始跟你作对

网页音频最大的坑不是合成,而是自动播放策略。没有用户手势,浏览器拒绝启动音频上下文,而且它们对「什么算手势」越来越严格。

实际含义是:页面加载时创建的音频上下文处于 suspended 状态,除非用户做了某个动作让它恢复,否则它会永远挂起。只在加载时或首次点击时尝试一次的代码,会让整个会话静默无声。

有效的做法:

  • 在 document 层、捕获阶段监听 pointerdown、mousedown、touchstart、keydown,这样没有东西能在你看到事件之前把它拦掉。
  • 每次这类事件发生时,如果上下文存在且未运行,就尝试恢复它。
  • 上下文挂起期间保持一个轻量的重试,因为某些浏览器只在第二次尝试时才认可手势。
  • 当上下文真的开始运行时,不要等 statechange 事件——直接查状态。在手势内部创建的上下文可能一出生就是 running,那样永远不会有事件触发。
最常被发布出去的音频 bug 不是弹错了音,而是一个正确的音弹进了一个静默的上下文。

音量是一个物理问题

最天真的做法是给每个音效传一个「音量」数字:这里 0.05,那里 0.08。听起来合理,做出来的游戏在笔记本上听不见。

两件事能修好它:让所有声音经过一条主音量总线;把每个音效的旧数值做归一化,好让游戏代码继续能用。主总线还顺带送你一个带淡出的静音,而不是硬切。

包络就是音色

一个恒定振幅的原始振荡器听起来像测试信号。让它读起来像「撞击」或「弹出」的是包络:起音多快、衰减多快、音高是否随衰减下滑。

三种形状能覆盖多数街机需求:

  • 点击 / 滴答。 极短起音、近乎瞬时衰减、高音高。十毫秒。
  • 撞击 / 弹跳。 快起音、音高在 80 毫秒内下滑五度、快速衰减。
  • 胜利 / 失败。 两三个音的顺序——上行是赢,下行是输。音程本身携带意义。

在打击类音效上叠一层滤波白噪声,它们会得到纯振荡器缺少的物理边缘感。

背景音乐是调度器,不是文件

只要你接受一个前提,程序化音乐比听起来容易:它不需要好听,它需要不打扰且无尽头。

可行的实现是「前瞻调度」:一个大约 150 毫秒醒一次的定时器,把未来几百毫秒内该发的音符排进音频时钟,计时用音频时钟而不是定时器。这样即使切标签页、掉帧也不会跑偏,因为音频时钟不会卡顿。

然后每种氛围选一个音阶、一个速度、一个低音,让它自己走。三种氛围——平静、推进、紧张——足够覆盖一个门户站,按游戏分类切换几乎零成本。

可以带走的东西

游戏音频大体上不是音色设计,而是「声音在什么时候发生」以及「玩家到底能不能听见」。把手势解锁做对,把所有声音放在一条总线上,用包络塑造性格,让音乐简单到可以永远循环。

音色本身是最后那百分之五——而在一个上百款游戏的站点上,这百分之五是你可以生成的。