Web Audio · 前端 · KeyDo

一个音频文件都不用的打字音效

键道 KeyDo 复盘:命中、失误、消灭、升级、游戏结束——全套 8-bit 音效,零音频资源,全靠 Web Audio 的振荡器实时合成。

键道 KeyDo 是个复古街机风格的打字游戏,敲对一下「哒」、打错「噗」、消灭一个词「叮咚」、升级一段上行琶音、game over 一段下坠。全套音效——没有一个 .mp3.wav.ogg。整个 sound.js 只有 45 行,音全是当场用振荡器算出来的。

一个 blip 就是一次振荡

Web Audio 里,最基础的发声单元是 OscillatorNode——给它一个频率和波形,它就发出对应的纯音。加一个 GainNode 控制音量包络,串起来接到扬声器,就是一声「哔」:

function blip(freq, duration = 0.06, type = 'square', gain = 0.04) {
  if (muted) return
  const a = ac()
  const osc = a.createOscillator()
  const g = a.createGain()
  osc.type = type                    // square / sawtooth …
  osc.frequency.value = freq
  g.gain.setValueAtTime(gain, a.currentTime)
  g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + duration)
  osc.connect(g).connect(a.destination)
  osc.start()
  osc.stop(a.currentTime + duration)
}

关键是那两行 gain:起始设一个小音量,再用 exponentialRampToValueAtTimeduration 内指数衰减到接近 0。这个「起手即衰减」的包络,正是 8-bit 音效那种短促「哔」感的来源——没有包络的纯音会像蜂鸣器一样干涩刺耳,有了衰减才像一颗音符。 指数衰减比线性更接近真实乐音的自然消散。

波形也有讲究:square(方波)明亮、像红白机主音;sawtooth(锯齿波)更粗粝、带噪感,适合「失误」「扣命」这种负反馈。同一套振荡器,换个波形和频率,音色就换了。

用频率和时间差,拼出「一段」音效

单个 blip 是一个音。多个 blip 用 setTimeout 错开时间,就成了一小段旋律:

export const sfx = {
  hit:    () => blip(880, 0.04),                                    // 命中:高频短促
  miss:   () => blip(160, 0.12, 'sawtooth', 0.05),                 // 失误:低频锯齿
  kill:   () => { blip(660, 0.05); setTimeout(() => blip(990, 0.07), 50) },  // 消灭:两音上行
  levelUp:() => { blip(523, 0.08); setTimeout(() => blip(659, 0.08), 90);
                  setTimeout(() => blip(784, 0.12), 180) },         // 升级:do-mi-so 琶音
  gameOver:() => { blip(330, 0.15, 'sawtooth');
                   setTimeout(() => blip(220, 0.3, 'sawtooth'), 160) },     // 结束:两音下坠
}

规律很直白:高频 = 正面(命中、消灭),低频 = 负面(失误、扣命);上行音阶 = 进步(升级用 523/659/784 正好是 do-mi-so),下行 = 失败。 levelUp 那串就是一个大三和弦的琶音,听起来就「升了一级」。这些数字不是随便填的,是照着音高关系调出来的——但整段代码里没有一个音频素材,全是数字。

两个绕不过的浏览器坑

其一:AudioContext 必须懒加载。 浏览器的自动播放策略不允许页面一加载就出声,AudioContext 会以 suspended 状态创建。所以不在模块顶层 new,而是第一次要发声时才创建,并顺手 resume()

function ac() {
  if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)()
  if (ctx.state === 'suspended') ctx.resume()
  return ctx
}

第一次发声一定发生在用户按键之后(打字游戏嘛),这时 resume() 带着用户手势,浏览器就放行了。顺带用 window.AudioContext || window.webkitAudioContext 兼容老 Safari 的前缀。

其二:音频不可用要静默降级。 整个 blip 包在 try/catch 里,catch 什么都不做。某些环境下 Web Audio 可能抛错(受限的 iframe、被策略拦截),但音效是锦上添花,绝不能因为发不出声就让游戏崩了。静音开关 muted 存 localStorage,blip 开头一个 if (muted) return 直接短路——静音时连振荡器都不创建。

为什么值得不用音频文件

  • 零资源、零请求:没有音频文件要打包、要下载、要等加载。整个游戏的音效体积就是这 45 行代码,首屏没有任何音频网络请求;
  • 随手可调:想让命中音更高一点?改个数字。想加个新音效?拼几个 blip。不用开音频软件、导出、压缩、替换文件;
  • 和 CRT 街机气质天生一搭:8-bit 芯片音乐本来就是振荡器合成的,用 Web Audio 复刻这种音色,比找一堆「像素风音效包」更正宗。

小结

用 Web Audio 给一个打字游戏配全套音效、一个音频文件都不用:

  • 一个 blip = 振荡器 + 增益包络exponentialRampToValueAtTime 的衰减是「哔」感的关键,没包络就是刺耳蜂鸣;
  • 波形选音色:方波明亮做正反馈,锯齿粗粝做负反馈;
  • 频率和时间差拼旋律:高频/上行表示正面,低频/下行表示负面,setTimeout 串成琶音;
  • 懒加载 + 用户手势 resume:绕过自动播放策略,第一次发声搭在按键上;
  • 静默降级try/catch 兜底,音效发不出也不许拖垮游戏。

一句话:音效不一定是「资源」,也可以是「代码」——45 行数字,就是一整套街机音。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。