游戏的 Web Audio API
好的音频会让游戏脱胎换骨。没声音的游戏总是显得缺乏生气。Web Audio API 让你在浏览器里就能拿到低延迟音效、音乐播放,甚至 3D 空间音频。
试一试(点击按钮听合成音效):
自动播放问题
在做别的事之前,先理解自动播放限制。浏览器在用户与页面交互前不允许播放音频。这是好事(谁也不想网页一加载就放音乐),但它意味着你必须在点击、触摸或按键事件里初始化音频。
let audioCtx = null
function initAudio() {
if (!audioCtx) {
audioCtx = new AudioContext()
}
if (audioCtx.state === 'suspended') {
audioCtx.resume()
}
return audioCtx
}
// 首次交互时初始化
document.addEventListener('click', () => initAudio(), { once: true })
document.addEventListener('keydown', () => initAudio(), { once: true })如果在这之前播放音频,要么静默失败,要么 AudioContext 一直处于 suspended。很多开发者都被这件事坑过。
加载声音
音频文件在播放前必须解码。下载、解码、然后保留 buffer 反复使用:
async function loadSound(url) {
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
return audioBuffer
}解码需要时间,所以在加载画面里就把所有声音加载好,而不是要播时才去加载。
播放声音
播放声音要创建一个 buffer source,通过 gain 节点控制音量,然后启动:
function playSound(buffer, volume = 1) {
const source = audioCtx.createBufferSource()
source.buffer = buffer
const gainNode = audioCtx.createGain()
gainNode.gain.value = volume
source.connect(gainNode)
gainNode.connect(audioCtx.destination)
source.start(0)
return source
}buffer source 是一次性的。一旦播放结束就不能再启动了。每次播放都要新建一个。这看着浪费,但 API 就这么设计,浏览器也按这个用法做了优化。
一个 Sound Manager
实际游戏里,把所有东西封装到一个管理类里:
class SoundManager {
constructor() {
this.ctx = null
this.sounds = new Map()
this.masterGain = null
}
init() {
this.ctx = new AudioContext()
this.masterGain = this.ctx.createGain()
this.masterGain.connect(this.ctx.destination)
}
async load(name, url) {
const response = await fetch(url)
const buffer = await this.ctx.decodeAudioData(await response.arrayBuffer())
this.sounds.set(name, buffer)
}
play(name, volume = 1) {
const buffer = this.sounds.get(name)
if (!buffer) return
const source = this.ctx.createBufferSource()
source.buffer = buffer
const gain = this.ctx.createGain()
gain.gain.value = volume
source.connect(gain)
gain.connect(this.masterGain)
source.start(0)
return source
}
setMasterVolume(v) {
this.masterGain.gain.value = v
}
}master gain 节点让你能做全局音量滑块。所有声音都路由经过它。
背景音乐
音乐需要循环播放,也要能停止或淡出:
class MusicPlayer {
constructor(ctx, masterGain) {
this.ctx = ctx
this.masterGain = masterGain
this.currentTrack = null
this.gain = ctx.createGain()
this.gain.connect(masterGain)
}
play(buffer, volume = 0.5) {
this.stop()
this.currentTrack = this.ctx.createBufferSource()
this.currentTrack.buffer = buffer
this.currentTrack.loop = true
this.currentTrack.connect(this.gain)
this.gain.gain.value = volume
this.currentTrack.start(0)
}
stop() {
if (this.currentTrack) {
this.currentTrack.stop()
this.currentTrack = null
}
}
fadeOut(duration = 1) {
this.gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration)
}
}linearRampToValueAtTime 提供平滑的淡入淡出,而不是粗暴地截断。
空间音频
对 3D 游戏,可以把声音放置到空间中。声音离听者越远音量越小:
function playSpatialSound(buffer, x, y, z) {
const source = audioCtx.createBufferSource()
source.buffer = buffer
const panner = audioCtx.createPanner()
panner.panningModel = 'HRTF'
panner.distanceModel = 'inverse'
panner.refDistance = 1
panner.maxDistance = 100
panner.setPosition(x, y, z)
source.connect(panner)
panner.connect(audioCtx.destination)
source.start(0)
}
function updateListener(x, y, z, fx, fy, fz) {
const listener = audioCtx.listener
listener.setPosition(x, y, z)
listener.setOrientation(fx, fy, fz, 0, 1, 0)
}每帧用相机或玩家位置调用 updateListener。panner 会根据距离和方向自动调整音量与立体声相位。
高频音效
对每秒播放多次的声音(枪声、脚步声),你不希望叠加的实例越堆越多。一个简单的对象池模式就够用:
class SoundPool {
constructor(ctx, buffer, size = 8) {
this.sources = []
this.index = 0
this.ctx = ctx
this.buffer = buffer
this.size = size
}
play(volume = 1) {
const source = this.ctx.createBufferSource()
source.buffer = this.buffer
const gain = this.ctx.createGain()
gain.gain.value = volume
source.connect(gain)
gain.connect(this.ctx.destination)
source.start(0)
this.index = (this.index + 1) % this.size
}
}池限制了同一声音可以并发播放的实例数。达到上限时,最早的会被替换。
音频格式
不同格式适合不同场景。
MP3 到处都能用,适合音乐。OGG Vorbis 在相同体积下质量更好,但 Safari 不支持。AAC/M4A 在 Apple 设备上表现不错。WAV 是未压缩格式,体积大,但解码即用,适合短音效——如果你能承受文件大小。WebM/Opus 是质量和体积比最好的,但只在现代浏览器里能用。
为了最大兼容性,提供回退:
const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'实用技巧
音高变化让重复播放的声音不那么机械:
function playWithPitchVariation(buffer, variance = 0.1) {
const source = audioCtx.createBufferSource()
source.buffer = buffer
source.playbackRate.value = 1 + (Math.random() - 0.5) * variance
source.connect(audioCtx.destination)
source.start(0)
}每次枪声或脚步声听起来都略有不同。
**Ducking(闪避)**在对话或重要音效播放时降低音乐音量:
function duckMusic(musicGain, duration = 0.3) {
musicGain.gain.linearRampToValueAtTime(0.2, audioCtx.currentTime + duration)
}
function unduckMusic(musicGain, duration = 0.3) {
musicGain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + duration)
}iOS 的坑
iOS 比其他平台限制更严。AudioContext 必须在用户手势期间创建并 resume。某些 iOS 版本还要求你在手势期间真的播放一个声音(哪怕是静音的),仅仅创建上下文还不够。如果在桌面上一切正常但 iOS 没声音,多半就是这个原因。
更多资源
发布一个快速加载的网页游戏 讲了包含音频在内的资源压缩。
移动端友好的网页游戏 更多 iOS 相关问题。
离线游戏的 PWA 展示如何缓存音频文件以便离线播放。
Tone.js 游戏音频 用更高层的库做程序化音效和音乐系统。
去哪里找免费游戏素材 列举了 Freesound、Poly Haven 等免费音效与音乐源。
外部资源
- MDN: Web Audio API —— 完整 API 参考
- MDN: AudioContext —— 创建和管理音频上下文
- Web Audio API spec —— W3C 规范