Skip to content

Web Audio API para juegos

Un buen audio transforma un juego. Un juego silencioso parece carente de vida. Web Audio API te ofrece efectos de sonido de baja latencia, reproducción de música e incluso audio espacial 3D, todo en el navegador.

Pruébalo (haz clic en un botón para escuchar sonidos sintetizados):

El problema de la reproducción automática

Antes que nada, debes comprender las restricciones de reproducción automática. Los navegadores bloquean el audio hasta que el usuario interactúa con la página. Esto es algo positivo (nadie quiere que los sitios web reproduzcan sonidos a todo volumen al cargarse), pero implica que debes inicializar el audio en respuesta a un clic, un toque o la pulsación de una tecla.

js
let audioCtx = null

function initAudio() {
  if (!audioCtx) {
    audioCtx = new AudioContext()
  }
  if (audioCtx.state === 'suspended') {
    audioCtx.resume()
  }
  return audioCtx
}

// Initialize on first interaction
document.addEventListener('click', () => initAudio(), { once: true })
document.addEventListener('keydown', () => initAudio(), { once: true })

Si intentas reproducir audio antes de esto, fallará sin mostrar ningún error o el AudioContext permanecerá suspendido. Esto toma por sorpresa a muchos desarrolladores.

Cargar sonidos

Los archivos de audio deben decodificarse antes de reproducirse. Obtén el archivo, decodifícalo y conserva el búfer para reutilizarlo:

js
async function loadSound(url) {
  const response = await fetch(url)
  const arrayBuffer = await response.arrayBuffer()
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
  return audioBuffer
}

La decodificación lleva tiempo, así que carga todos los sonidos durante una pantalla de carga, no cuando necesites reproducirlos.

Reproducir sonidos

Para reproducir un sonido, crea una fuente de búfer, conéctala mediante un nodo de ganancia para controlar el volumen e iníciala:

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
}

Las fuentes de búfer son de un solo uso. Una vez que una fuente termina de reproducirse, no puedes reiniciarla. Crea una nueva cada vez que reproduzcas el sonido. Puede parecer un desperdicio, pero así funciona la API y los navegadores están optimizados para ello.

Un gestor de sonido

Para un juego real, encapsula todo en una clase gestora:

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
  }
}

El nodo de ganancia maestro te permite implementar un control deslizante de volumen global. Todos los sonidos pasan por él.

Música de fondo

La música debe poder reproducirse en bucle, detenerse y desvanecerse:

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)
  }
}

El método linearRampToValueAtTime permite crear desvanecimientos suaves en lugar de cortes bruscos.

Audio espacial

En juegos 3D, puedes colocar los sonidos en el espacio. Los sonidos pierden volumen a medida que se alejan del oyente:

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.positionX.value = x
  panner.positionY.value = y
  panner.positionZ.value = z
  
  source.connect(panner)
  panner.connect(audioCtx.destination)
  source.start(0)
}

function updateListener(x, y, z, fx, fy, fz) {
  const listener = audioCtx.listener
  listener.positionX.value = x
  listener.positionY.value = y
  listener.positionZ.value = z
  listener.forwardX.value = fx
  listener.forwardY.value = fy
  listener.forwardZ.value = fz
  listener.upX.value = 0
  listener.upY.value = 1
  listener.upZ.value = 0
}

Llama a updateListener en cada fotograma con la posición de la cámara o del jugador. El nodo de posicionamiento ajusta automáticamente el volumen y el balance estéreo según la distancia y la dirección.

Sonidos rápidos y repetidos

Para los sonidos que se reproducen muchas veces por segundo (disparos, pasos), no conviene que se acumulen instancias superpuestas. Un patrón sencillo de pool ayuda a evitarlo:

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
  }
}

El pool limita cuántas instancias del mismo sonido pueden reproducirse a la vez. Al alcanzar el límite, la instancia más antigua se sustituye.

Formatos de audio

Cada formato funciona mejor en situaciones diferentes:

MP3 funciona en todas partes y es adecuado para la música. OGG Vorbis ofrece mejor calidad con el mismo tamaño de archivo. Safari fue el último navegador importante que no lo admitía, pero añadió reproducción nativa de Ogg Vorbis en Safari 18.4, por lo que ahora todos los navegadores principales actuales son compatibles. Aun así, conviene ofrecer MP3 como alternativa si te importan las versiones anteriores de Safari. AAC/M4A funciona bien en dispositivos Apple. WAV no está comprimido y ocupa mucho espacio, pero se decodifica al instante, lo cual resulta útil para efectos de sonido cortos cuando el tamaño del archivo no es un problema. WebM/Opus ofrece la mejor relación entre calidad y tamaño, pero solo funciona en navegadores modernos.

Para lograr la máxima compatibilidad, proporciona alternativas:

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

Trucos útiles

La variación de tono hace que los sonidos repetidos resulten menos robóticos:

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)
}

Así, cada disparo o paso suena ligeramente diferente.

La atenuación reduce el volumen de la música durante los diálogos o los sonidos importantes:

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)
}

Particularidades de iOS

iOS impone restricciones de audio más estrictas que otras plataformas. El AudioContext debe crearse y reanudarse durante un gesto del usuario. Algunas versiones de iOS también requieren que reproduzcas un sonido (aunque sea silencioso) durante el gesto, no solo que crees el contexto. Si el audio funciona en equipos de escritorio, pero no en iOS, probablemente sea por este motivo.

Más recursos

Publica un juego web que cargue rápido explica la compresión de recursos, incluido el audio.

Juegos web adaptados a dispositivos móviles ofrece más información sobre problemas específicos de iOS.

PWA para juegos sin conexión muestra cómo almacenar en caché archivos de audio para jugar sin conexión.

Tone.js para el audio de juegos explica la generación procedural de sonidos y los sistemas musicales mediante una biblioteca de más alto nivel.

Dónde encontrar recursos gratuitos para juegos enumera fuentes gratuitas de efectos de sonido y música, como Freesound y Poly Haven.

Recursos externos