一个音频文件都不用的打字音效
键道 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:起始设一个小音量,再用 exponentialRampToValueAtTime 在 duration 内指数衰减到接近 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 行数字,就是一整套街机音。
留言