Skip to content

游戏的 Web Audio API

好的音频会让游戏脱胎换骨。没声音的游戏总是显得缺乏生气。Web Audio API 让你在浏览器里就能拿到低延迟音效、音乐播放,甚至 3D 空间音频。

试一试(点击按钮听合成音效):

自动播放问题

在做别的事之前,先理解自动播放限制。浏览器在用户与页面交互前不允许播放音频。这是好事(谁也不想网页一加载就放音乐),但它意味着你必须在点击、触摸或按键事件里初始化音频。

js
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 反复使用:

js
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 节点控制音量,然后启动:

js
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

实际游戏里,把所有东西封装到一个管理类里:

js
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 节点让你能做全局音量滑块。所有声音都路由经过它。

背景音乐

音乐需要循环播放,也要能停止或淡出:

js
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 游戏,可以把声音放置到空间中。声音离听者越远音量越小:

js
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 会根据距离和方向自动调整音量与立体声相位。

高频音效

对每秒播放多次的声音(枪声、脚步声),你不希望叠加的实例越堆越多。一个简单的对象池模式就够用:

js
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 是质量和体积比最好的,但只在现代浏览器里能用。

为了最大兼容性,提供回退:

js
const audioUrl = canPlayOgg() ? 'sound.ogg' : 'sound.mp3'

实用技巧

音高变化让重复播放的声音不那么机械:

js
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(闪避)**在对话或重要音效播放时降低音乐音量:

js
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 等免费音效与音乐源。

外部资源