Skip to content

音频系统

预计阅读 15 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0) · 微信开发者工具:稳定版 1.06+ · 建议在实际调用前使用 `wx.canIUse('createWebAudioContext')` 做兼容性判断
前端对比:Web 音频 vs 小游戏音频
维度Web <audio>微信小游戏音频
背景音乐.loop = true 直接播放wx.createInnerAudioContext() 单独管理
短音效可复用同一个 <audio> 实例多个 InnerAudioContext 并发(上限 10 个)
自动播放Chrome 限制需用户手势iOS 同样限制,需首次触摸解锁
格式支持MP3 / OGG / WAV 通用推荐 MP3(全平台),OGG 部分支持
高级音效Web Audio API(AudioContext)微信 WebAudio 支持基础节点

适应技巧: Web 前端的音频常被视为"附加功能",但在游戏中音频是核心体验的一部分。背景音乐、跳跃音效、得分音效、死亡音效——每个都有独立的播放逻辑和音量管理需求。建议从一开始就设计完整的音频架构(如本章的 BGMManager + SoundEffectManager 双通道模式),而不是后期临补。

音频是游戏的灵魂。本章讲解微信小游戏中的音频播放、管理与最佳实践。


音频 API 选择

微信小游戏提供两种音频播放方式:

API适用场景特点
wx.createInnerAudioContext()背景音乐、长音频自动管理音频解码、缓存,支持系统音量通道
WebAudio API音效、实时音频处理低延迟、多实例、支持音频节点图,但内存占用较高
推荐策略
  • 背景音乐:使用 wx.createInnerAudioContext(),支持系统音量通道、锁屏控制。
  • 短音效(射击、点击、碰撞):使用 WebAudio API,低延迟、多实例并发。
  • 语音/长音频:使用 wx.createInnerAudioContext()

各引擎音频加载方式对比

不同引擎封装了各自的音频 API,但底层都基于 wx.createInnerAudioContext() 或 WebAudio:

Cocos Creator 内置 AudioSource 组件,支持拖拽配置:

typescript
import { AudioSource, resources } from 'cc'

// 播放背景音乐
resources.load('audio/bgm', (err, clip) => {
  if (err) {
    console.error('加载失败', err)
    return
  }
  const audioSource = this.node.getComponent(AudioSource)
  audioSource.clip = clip
  audioSource.loop = true
  audioSource.play()
})

// 播放短音效(Cocos 3.x 内置音效池)
import { AudioClip, AudioSource } from 'cc'
// 引擎自动管理音效实例池,无需手动创建多个 InnerAudioContext

InnerAudioContext(背景音乐)

基础使用

typescript
class BGMManager {
  private bgm: WechatMinigame.InnerAudioContext
  private _isPlaying = false
  private _errorCount = 0
  private _retryTimer = -1
  private readonly _maxRetries = 3

  constructor() {
    this.bgm = wx.createInnerAudioContext()
    this.bgm.src = 'https://cdn.example.com/bgm.mp3'
    this.bgm.loop = true
    this.bgm.volume = 0.6
    this.bgm.autoplay = false

    // 自然播放结束
    this.bgm.onEnded(() => {
      console.log('BGM 播放完成')
      this._errorCount = 0
    })

    // 音频错误处理:带退避与重试上限
    this.bgm.onError((err) => {
      console.error('BGM 播放错误', err)
      if (this._errorCount >= this._maxRetries) {
        console.warn('BGM 重试次数已达上限,停止播放')
        return
      }
      this._errorCount++
      clearTimeout(this._retryTimer)
      this._retryTimer = setTimeout(() => this.play(), 1000 * this._errorCount)
    })
  }

  play(): void {
    this.bgm.play()
    this._isPlaying = true
  }

  pause(): void {
    this.bgm.pause()
    this._isPlaying = false
  }

  stop(): void {
    this.bgm.stop()
    this._isPlaying = false
  }

  setVolume(v: number): void {
    this.bgm.volume = Math.max(0, Math.min(1, v))
  }

  get isPlaying(): boolean {
    return this._isPlaying
  }

  /** 销毁 BGM 资源:页面/场景切换时调用 */
  destroy(): void {
    clearTimeout(this._retryTimer)
    this.bgm.stop()
    this.bgm.destroy()
    this._isPlaying = false
  }
}

iOS 自动播放限制

iOS 下音频必须在用户交互事件回调中首次播放,否则会被阻塞。首次激活后应及时移除监听并释放静音文件实例:

typescript
// 在游戏第一个触摸事件中激活音频
let audioActivated = false
let unlockAudio: (() => void) | null = null

export function setupIOSAudioUnlock(onUnlocked: () => void) {
  unlockAudio = () => {
    if (audioActivated || !unlockAudio) return
    audioActivated = true

    // 用静音文件“解锁”iOS 音频上下文,音量设为 0 避免用户感知
    const unlock = wx.createInnerAudioContext()
    unlock.src = 'audio/silence.mp3' // 1 秒静音文件
    unlock.volume = 0
    unlock.play()
    unlock.onEnded(() => {
      unlock.destroy()
      onUnlocked()
    })

    wx.offTouchStart(unlockAudio)
    unlockAudio = null
  }

  wx.onTouchStart(unlockAudio)
}

// 使用示例:setupIOSAudioUnlock(() => bgmManager.play())

WebAudio API(音效系统)

音效管理器

typescript
class SoundEffectManager {
  private audioContext: AudioContext | null = null
  private buffers: Map<string, AudioBuffer> = new Map()
  private loaded = false

  constructor() {
    // 微信小游戏基础库需支持 createWebAudioContext,低版本应降级到 InnerAudioContext
    if (!wx.canIUse('createWebAudioContext')) {
      console.warn('当前基础库不支持 WebAudio,请使用 InnerAudioContext 播放音效')
      return
    }
    this.audioContext = wx.createWebAudioContext()
  }

  /** 预加载音效 */
  async preload(sounds: Record<string, string>): Promise<void> {
    if (!this.audioContext) {
      console.warn('WebAudio 不可用,跳过预加载')
      return
    }

    const entries = Object.entries(sounds)
    await Promise.all(
      entries.map(async ([name, url]) => {
        try {
          const arrayBuffer = await this.fetchAudio(url)
          const audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer)
          this.buffers.set(name, audioBuffer)
        } catch (err) {
          console.error(`音效 ${name} 加载失败`, err)
        }
      })
    )
    this.loaded = true
    console.log(`预加载 ${this.buffers.size}/${entries.length} 个音效完成`)
  }

  /** 播放音效 */
  play(name: string, volume = 1, loop = false): void {
    if (!this.audioContext) {
      console.warn('WebAudio 不可用,无法播放音效')
      return
    }

    const buffer = this.buffers.get(name)
    if (!buffer) {
      console.warn(`音效 "${name}" 未加载`)
      return
    }

    // 每次播放创建新的 BufferSource(可多实例并发)
    const source = this.audioContext.createBufferSource()
    source.buffer = buffer
    source.loop = loop

    // 音量控制节点
    const gainNode = this.audioContext.createGain()
    gainNode.gain.value = Math.max(0, Math.min(1, volume))

    source.connect(gainNode)
    gainNode.connect(this.audioContext.destination)

    source.start(0)

    // 播放结束后自动销毁
    source.onended = () => {
      source.disconnect()
      gainNode.disconnect()
    }
  }

  /** 从网络加载音频资源 */
  private async fetchAudio(url: string): Promise<ArrayBuffer> {
    return new Promise((resolve, reject) => {
      wx.request({
        url,
        responseType: 'arraybuffer',
        success: (res) => resolve(res.data as ArrayBuffer),
        fail: reject,
      })
    })
  }

  /** 销毁音频上下文,释放资源 */
  destroy(): void {
    this.buffers.clear()
    this.audioContext?.close?.()
    this.audioContext = null
    this.loaded = false
  }
}

使用示例

typescript
// 使用示例
const sfx = new SoundEffectManager()
await sfx.preload({
  shoot: 'https://cdn.example.com/shoot.wav',
  explosion: 'https://cdn.example.com/explosion.wav',
  coin: 'https://cdn.example.com/coin.wav',
})
sfx.play('shoot', 0.8)
sfx.play('coin', 1.0)
sfx.destroy() // 场景切换时释放资源

音频格式选择

格式建议
格式压缩率音质适用场景
mp3较高良好背景音乐(兼容性最佳)
aac / m4a优于 mp3iOS 更友好,小体积
wav无损最佳短音效(解码快、延迟低)
ogg良好Android 友好

建议: 背景音乐使用 mp3(比特率 ≤ 128kbps),短音效使用 wav(码率更低时用 ogg/mp3)。

AI 生成音频资源

对于没有音频制作经验的开发者,AI 工具可以快速获得可替换的音频原型:

类型推荐工具注意
BGM 原型Suno、Udio、Stable Audio免费版通常不可商用;生成后需处理循环点
音效ElevenLabs Sound Effects、Google SoundFX导出后转为单声道,控制体积
配音/TTSElevenLabs、Fish Audio、讯飞配音注意 iOS 静音开关限制

导入微信前需完成:

  1. 格式转换:用 ffmpeg 转为 MP3/WAV
  2. 循环处理:BGM 需找到无缝循环点
  3. 体积控制:BGM ≤ 1MB,音效 ≤ 100KB
  4. 授权确认:核心 BGM 建议用授权音乐库,避免版权风险

示例:把 AI 生成的 WAV 转为小游戏可用 MP3

bash
# BGM:128kbps 立体声
ffmpeg -i ai-bgm.wav -ar 44100 -ac 2 -b:a 128k assets/bgm.mp3

# 音效:22kHz 单声道
ffmpeg -i ai-sfx.wav -ar 22050 -ac 1 -b:a 64k assets/sfx.mp3

静音与音量管理

游戏中的音量通常分为"背景音乐"和"音效"两个独立通道:

typescript
class AudioSettings {
  private static STORAGE_KEY = 'audioSettings'

  masterVolume = 1
  bgmVolume = 1
  sfxVolume = 1
  isMuted = false

  // 从本地缓存恢复设置
  static load(): AudioSettings {
    try {
      const raw = wx.getStorageSync(this.STORAGE_KEY)
      return raw ? Object.assign(new AudioSettings(), JSON.parse(raw)) : new AudioSettings()
    } catch {
      return new AudioSettings()
    }
  }

  save(): void {
    wx.setStorageSync(
      AudioSettings.STORAGE_KEY,
      JSON.stringify({
        masterVolume: this.masterVolume,
        bgmVolume: this.bgmVolume,
        sfxVolume: this.sfxVolume,
        isMuted: this.isMuted,
      })
    )
  }

  toggleMute(): void {
    this.isMuted = !this.isMuted
    this.apply()
    this.save()
  }

  apply(bgm?: BGMManager): void {
    const master = this.isMuted ? 0 : this.masterVolume
    bgm?.setVolume(master * this.bgmVolume)
    // SFX 管理器也应在 play 时乘以 master * sfxVolume
  }
}

微信音频系统限制

  1. 并发音频数限制InnerAudioContext 实例建议不超过 10 个。
  2. 音频文件大小:单音频文件建议 < 5MB,多个大文件需考虑分包或 CDN 加载。
  3. 后台播放:iOS 锁屏或切换到后台后音频可能被暂停,需在 wx.onShow / wx.onHide 中处理恢复/暂停逻辑。
  4. 静音模式下播放:可调用 wx.setInnerAudioOption({ obeyMuteSwitch: false }) 使手机处于静音开关时仍能播放音频。但非必要音频(如 BGM、普通音效)开启此项可能被审核以“影响用户体验”为由驳回,建议仅在核心玩法必需音频场景使用,并准备合规说明。

3D/空间音频

空间音频(Spatial Audio)让声音具有方向感和距离感——脚步声从左前方传来,爆炸声由远及近。在微信小游戏中,通过 WebAudio API 的 PannerNode 可以实现基础的 3D 音效。

前提条件

空间音频依赖 WebAudio API,需要先检测兼容性:

typescript
function canUseSpatialAudio(): boolean {
  return wx.canIUse('createWebAudioContext')
}

PannerNode 实现

typescript
// audio/SpatialAudioManager.ts
/**
 * 3D 空间音频管理器
 * 使用 WebAudio PannerNode 实现声音的空间定位
 */
class SpatialAudioManager {
  private audioCtx: wx.WebAudioContext | null = null
  private listener: wx.AudioListener | null = null
  private activeSources: Map<
    string,
    {
      source: wx.AudioBufferSourceNode
      panner: wx.PannerNode
      gain: wx.GainNode
    }
  > = new Map()

  constructor() {
    if (!wx.canIUse('createWebAudioContext')) {
      console.warn('当前环境不支持 WebAudio,空间音频不可用')
      return
    }
    this.audioCtx = wx.createWebAudioContext()
    this.listener = this.audioCtx.listener

    // 设置监听器(代表玩家的"耳朵")的初始位置为原点
    if (this.listener.positionX) {
      this.listener.positionX.value = 0
      this.listener.positionY.value = 0
      this.listener.positionZ.value = 0
    }
  }

  /**
   * 播放一个位于 3D 空间中的音效
   * @param sourceX - 声源 X 坐标(-1 到 1,-1 = 左,1 = 右)
   * @param sourceY - 声源 Y 坐标(0 = 相同高度)
   * @param sourceZ - 声源 Z 坐标(正值 = 前方,负值 = 后方)
   */
  play3DSound(buffer: wx.AudioBuffer, sourceX: number, sourceY: number, sourceZ: number): void {
    if (!this.audioCtx) return

    const source = this.audioCtx.createBufferSource()
    source.buffer = buffer

    // 创建 PannerNode(3D 定位器)
    const panner = this.audioCtx.createPanner()
    panner.setPosition(sourceX, sourceY, sourceZ)
    // 设置声源朝向(默认全向)
    panner.setOrientation(0, 0, -1)

    // 距离衰减模型:inverse(距离越远音量越小)
    panner.distanceModel = 'inverse'
    panner.refDistance = 1 // 参考距离
    panner.maxDistance = 100 // 最大可听距离

    // GainNode 用于额外的音量控制
    const gain = this.audioCtx.createGain()
    gain.gain.value = 1.0

    // 连接:source → panner → gain → destination
    source.connect(panner)
    panner.connect(gain)
    gain.connect(this.audioCtx.destination)

    const id = `sfx_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
    this.activeSources.set(id, { source, panner, gain })

    source.onended = () => {
      this.activeSources.delete(id)
    }

    source.start()
  }

  /**
   * 更新监听器位置(应在游戏主循环中调用)
   * @param x - 玩家/摄像机 X 坐标
   * @param y - 玩家/摄像机 Y 坐标
   */
  updateListenerPosition(x: number, y: number): void {
    if (!this.listener?.positionX) return

    // 将游戏世界坐标映射到音频空间
    // 可根据游戏比例调整映射系数
    const audioX = x / 100
    const audioY = y / 100

    this.listener.positionX.value = audioX
    this.listener.positionY.value = audioY
    this.listener.positionZ.value = 0
  }

  /** 释放所有活跃音源 */
  stopAll(): void {
    this.activeSources.forEach(({ source }) => {
      try {
        source.stop()
      } catch {
        /* 可能已结束 */
      }
    })
    this.activeSources.clear()
  }

  /** 销毁空间音频系统 */
  destroy(): void {
    this.stopAll()
    this.audioCtx?.close()
    this.audioCtx = null
  }
}

使用场景

场景空间音频效果
横版动作游戏敌人脚步声从左侧/右侧传来
俯视角射击枪声距离感应(远小近大)
3D 赛车引擎声多普勒效应(需要额外的频率偏移计算)
休闲游戏通常不需要——空间音频增加实现复杂度,对休闲游戏体验提升有限

音频预加载与进度反馈

网络不佳时,音频文件可能需要数秒才能开始播放。提供预加载和进度反馈能显著提升用户体验。

预加载管理器

typescript
// audio/AudioPreloader.ts
interface AudioAsset {
  key: string // 资源标识符
  src: string // 音频 URL 或本地路径
  type: 'bgm' | 'sfx'
}

interface LoadProgress {
  loaded: number // 已加载(字节)
  total: number // 总大小(字节)
  percent: number // 进度百分比
}

class AudioPreloader {
  private audioMap: Map<string, wx.InnerAudioContext> = new Map()
  private loadingSet: Set<string> = new Set()
  private onProgress?: (key: string, progress: LoadProgress) => void
  private onComplete?: (key: string) => void
  private onError?: (key: string, err: Error) => void

  /**
   * 预加载音频资源列表
   * @param assets 音频资源清单
   * @param callbacks 进度回调
   */
  async preloadAll(
    assets: AudioAsset[],
    callbacks?: {
      onProgress?: (key: string, progress: LoadProgress) => void
      onComplete?: (key: string) => void
      onError?: (key: string, err: Error) => void
    }
  ): Promise<void> {
    this.onProgress = callbacks?.onProgress
    this.onComplete = callbacks?.onComplete
    this.onError = callbacks?.onError

    // 并行加载,但可以通过信号量控制并发数
    const MAX_CONCURRENT = 3
    const queue = [...assets]
    const active: Promise<void>[] = []

    while (queue.length > 0 || active.length > 0) {
      while (active.length < MAX_CONCURRENT && queue.length > 0) {
        const asset = queue.shift()!
        const promise = this.loadSingle(asset).then(() => {
          active.splice(active.indexOf(promise), 1)
        })
        active.push(promise)
      }
      if (active.length > 0) {
        await Promise.race(active)
      }
    }
  }

  private loadSingle(asset: AudioAsset): Promise<void> {
    return new Promise((resolve, reject) => {
      if (this.loadingSet.has(asset.key)) {
        resolve()
        return
      }

      this.loadingSet.add(asset.key)
      const audio = wx.createInnerAudioContext()
      audio.src = asset.src

      // 对于 BGM,设置循环
      if (asset.type === 'bgm') {
        audio.loop = true
      }

      // 监听 canplay 事件表示可以开始播放(无需等待全部下载完成)
      audio.onCanplay(() => {
        this.audioMap.set(asset.key, audio)
        this.loadingSet.delete(asset.key)
        this.onComplete?.(asset.key)
        resolve()
      })

      audio.onError((err: any) => {
        this.loadingSet.delete(asset.key)
        const error = new Error(`音频加载失败: ${asset.key} — ${err.errMsg || 'Unknown'}`)
        this.onError?.(asset.key, error)
        reject(error)
      })
    })
  }

  /** 获取已加载的音频实例 */
  getAudio(key: string): wx.InnerAudioContext | undefined {
    return this.audioMap.get(key)
  }

  /** 检查某个音频是否已加载完成 */
  isLoaded(key: string): boolean {
    return this.audioMap.has(key)
  }

  /** 加载进度百分比(粗略估计——基于资源数量而非字节) */
  getOverallProgress(assetCount: number): number {
    return assetCount > 0 ? this.audioMap.size / assetCount : 0
  }

  /** 销毁所有预加载的音频实例 */
  destroy(): void {
    this.audioMap.forEach((audio) => audio.destroy())
    this.audioMap.clear()
    this.loadingSet.clear()
  }
}

加载进度 UI 示例

typescript
// 在游戏加载场景中使用
async function showLoadingScreen(): Promise<void> {
  const preloader = new AudioPreloader()
  const assets: AudioAsset[] = [
    { key: 'bgm_main', src: 'audio/bgm.mp3', type: 'bgm' },
    { key: 'sfx_flap', src: 'audio/flap.mp3', type: 'sfx' },
    { key: 'sfx_score', src: 'audio/score.mp3', type: 'sfx' },
    { key: 'sfx_die', src: 'audio/die.mp3', type: 'sfx' },
  ]

  let loadedCount = 0

  await preloader.preloadAll(assets, {
    onComplete: (key) => {
      loadedCount++
      const percent = Math.round((loadedCount / assets.length) * 100)
      console.log(`音频加载进度: ${percent}% (${key} 完成)`)
      // 更新加载界面进度条
      updateLoadingBar(percent)
    },
    onError: (key, err) => {
      console.warn(`音频 ${key} 加载失败,将跳过`, err)
      // 失败不影响游戏启动——静默降级
    },
  })

  console.log('音频预加载完成')
}
预加载最佳实践
  1. BGM 必预加载:背景音乐文件最大,预加载避免游戏开始后无声。
  2. 不阻塞启动:音效加载失败不应阻止游戏启动——静默降级,无音频也能玩。
  3. 按需加载:大型游戏按关卡分包预加载,而不是一次性加载所有音频。
  4. 并发控制:限制同时下载的音频数量(建议 ≤ 3),避免抢占游戏主资源的带宽。
  5. 超时处理onCanplay 可能永远不会触发(网络问题),应设置 10 秒超时并降级。

📝 课后练习

练习 1:音频管理器设计

题目: 你的游戏需要同时播放背景音乐和 5 种音效(跳跃、得分、碰撞、按钮、胜利)。设计一个音频管理器的类结构,支持全局静音和音量控制。

参考答案:

typescript
class AudioManager {
  private bgm: wx.InnerAudioContext
  private sfxPool: wx.InnerAudioContext[] = []
  private masterVolume = 1
  private bgmVolume = 0.7
  private sfxVolume = 1
  private muted = false

  playBGM(src: string) {
    /* 暂停当前 BGM,创建新的并播放 */
  }
  playSFX(src: string) {
    /* 从池中取空闲实例或创建新的(上限 10 个)*/
  }
  setMasterVolume(v: number) {
    this.masterVolume = v
    this.applyVolumes()
  }
  toggleMute() {
    this.muted = !this.muted
    this.applyVolumes()
  }
  private applyVolumes() {
    const master = this.muted ? 0 : this.masterVolume
    this.bgm.volume = master * this.bgmVolume
    this.sfxPool.forEach((sfx) => (sfx.volume = master * this.sfxVolume))
  }
}
练习 2:iOS 自动播放限制

题目: iOS 设备限制音频必须在用户手势中首次播放。写一段代码处理这个限制。

参考答案:

javascript
// 在首次触摸事件中解锁音频
wx.onTouchStart(function unlockAudio() {
  const audio = wx.createInnerAudioContext()
  audio.src = 'audio/silence.mp3' // 1 秒静音文件
  audio.volume = 0
  audio.play()
  audio.onEnded(() => audio.destroy())
  wx.offTouchStart(unlockAudio) // 只执行一次
})

用心学习,持续实践