Tone.js para audio de juegos
Tone.js funciona sobre la API Web Audio y facilita el uso práctico de audio sintetizado. En lugar de cargar archivos de audio, generas sonidos mediante código. Esto significa cero tiempo de carga, variaciones infinitas y sonidos capaces de reaccionar a la jugabilidad en tiempo real.
Pruébalo (haz clic en los botones para escuchar sonidos de juego sintetizados):
Por qué usar Tone.js en lugar de archivos de audio
Los archivos de audio funcionan bien para música y sonidos complejos. Sin embargo, para los efectos de sonido de un juego, la síntesis ofrece ventajas reales. Puedes variar el tono y la duración en cada reproducción para que los sonidos no resulten repetitivos. Puedes generar sonidos al instante según el estado del juego. Además, evitas por completo las solicitudes de red.
La desventaja es que el audio sintetizado consume más CPU que reproducir un archivo. En juegos sencillos, esto no supone un problema. En juegos complejos, puedes combinar ambos métodos.
Configuración
Carga Tone.js desde una CDN o mediante npm:
<script src="https://unpkg.com/tone@15/build/Tone.js"></script>O con npm:
npm install toneimport * as Tone from 'tone'El problema de la reproducción automática
Los navegadores bloquean el audio hasta que el usuario interactúa. Tone.js se encarga de ello, pero debes llamar a Tone.start() mediante una acción del usuario:
document.addEventListener('click', async () => {
await Tone.start()
console.log('Audio listo')
}, { once: true })Sin esto, tu AudioContext permanece suspendido y no se reproduce nada.
Tu primer sintetizador
Un sintetizador genera sonido. El más sencillo es Tone.Synth:
const synth = new Tone.Synth().toDestination()
// Reproducir una nota
synth.triggerAttackRelease('C4', '8n')triggerAttackRelease reproduce una nota durante un tiempo determinado. 'C4' es el do central. '8n' es una corchea al tempo actual.
El sintetizador tiene un oscilador (la fuente del sonido) y una envolvente (cómo aparece y desaparece el sonido). Puedes personalizar ambos:
const synth = new Tone.Synth({
oscillator: { type: 'triangle' },
envelope: {
attack: 0.02,
decay: 0.2,
sustain: 0.3,
release: 0.4,
},
}).toDestination()Los tipos de oscilador incluyen sine (puro y suave), triangle (suave), sawtooth (vibrante) y square (hueco y retro).
Cómo crear un sistema de efectos de sonido
Para un juego, conviene utilizar varios sintetizadores para distintos tipos de sonido. Este es un patrón que funciona bien:
let isInitialized = false
let softSynth = null
let subSynth = null
let noiseSynth = null
let sfxGain = null
function initSFX() {
if (isInitialized) return
// Control de volumen maestro
sfxGain = new Tone.Gain(0.7).toDestination()
// Sintetizador suave para sonidos melódicos
softSynth = new Tone.PolySynth(Tone.Synth, {
maxPolyphony: 6,
oscillator: { type: 'sine' },
envelope: {
attack: 0.02,
decay: 0.2,
sustain: 0.3,
release: 0.4,
},
volume: -8,
}).connect(sfxGain)
// Subgrave para impactos profundos
subSynth = new Tone.Synth({
oscillator: { type: 'sine' },
envelope: {
attack: 0.02,
decay: 0.3,
sustain: 0.1,
release: 0.3,
},
volume: -8,
}).connect(sfxGain)
// Ruido para añadir textura
noiseSynth = new Tone.NoiseSynth({
noise: { type: 'pink' },
envelope: {
attack: 0.02,
decay: 0.15,
sustain: 0,
release: 0.15,
},
volume: -20,
}).connect(sfxGain)
isInitialized = true
}PolySynth permite reproducir varias notas a la vez. Un Synth normal es monofónico.
Ejemplos de sonidos de juego
Estos son algunos sonidos habituales en juegos y cómo crearlos:
Recoger una moneda: las notas ascendentes transmiten una sensación de recompensa:
function coinCollect() {
const now = Tone.now()
softSynth.triggerAttackRelease('C4', '8n', now, 0.3)
softSynth.triggerAttackRelease('E4', '8n', now + 0.1, 0.35)
softSynth.triggerAttackRelease('G4', '4n', now + 0.2, 0.4)
}Saltar: un barrido ascendente rápido:
function jump() {
const synth = new Tone.Synth({
oscillator: { type: 'square' },
envelope: { attack: 0.01, decay: 0.1, sustain: 0, release: 0.1 },
}).toDestination()
const now = Tone.now()
synth.frequency.setValueAtTime(150, now)
synth.frequency.exponentialRampToValueAtTime(400, now + 0.1)
synth.triggerAttackRelease('C4', '8n', now, 0.2)
}Golpe/daño: tono descendente con ruido:
function hit() {
const now = Tone.now()
subSynth.triggerAttackRelease('E2', '4n', now, 0.5)
noiseSynth.triggerAttackRelease('4n', now, 0.25)
}Muerte: notas descendentes y tristes:
function death() {
const now = Tone.now()
softSynth.triggerAttackRelease('E4', '4n', now, 0.4)
softSynth.triggerAttackRelease('C4', '4n', now + 0.2, 0.35)
softSynth.triggerAttackRelease('A3', '4n', now + 0.4, 0.3)
}Cómo añadir efectos
Los efectos enriquecen los sonidos. Encadénalos entre el sintetizador y el destino:
const filter = new Tone.Filter({
frequency: 3000,
type: 'lowpass',
rolloff: -12,
})
const reverb = new Tone.Reverb({
decay: 1.2,
wet: 0.25,
})
const gain = new Tone.Gain(0.7)
// Cadena: sintetizador -> filtro -> reverberación -> ganancia -> destino
filter.connect(reverb)
reverb.connect(gain)
gain.toDestination()
const synth = new Tone.Synth().connect(filter)El filtro de paso bajo elimina las frecuencias altas estridentes. La reverberación aporta espacio. La ganancia controla el volumen.
Limitación de frecuencia
Si tu juego activa muchos sonidos rápidamente, estos pueden superponerse y crear un caos sonoro. Añade una limitación de frecuencia:
const MIN_INTERVAL_MS = 35
let lastNoteTime = 0
function canPlayNote() {
const now = performance.now()
if (now - lastNoteTime < MIN_INTERVAL_MS) {
return false
}
lastNoteTime = now
return true
}
function playSound() {
if (!canPlayNote()) return
synth.triggerAttackRelease('C4', '8n')
}Música de fondo dinámica
Para crear música que reaccione a la jugabilidad, utiliza un sistema basado en pulsos:
const BPM = 140
const BEAT_MS = (60 / BPM) * 1000
let bassSynth = null
let kickDrum = null
let beatInterval = null
let beatCount = 0
let energy = 0.3
const PROGRESSION = [
{ root: 'E2', chord: ['E3', 'G3', 'B3'] },
{ root: 'C2', chord: ['C3', 'E3', 'G3'] },
{ root: 'D2', chord: ['D3', 'F#3', 'A3'] },
{ root: 'A1', chord: ['A2', 'C3', 'E3'] },
]
function playBeat() {
const beatInMeasure = beatCount % 8
const chordIndex = Math.floor(beatCount / 8) % PROGRESSION.length
const chord = PROGRESSION[chordIndex]
const now = Tone.now()
// Bajo en el primer pulso
if (beatInMeasure === 0) {
bassSynth.triggerAttackRelease(chord.root, '4n', now, 0.6)
}
// Bombo: solo cuando la energía es alta
if (beatInMeasure === 0 && energy > 0.3) {
kickDrum.triggerAttackRelease('C1', '8n', now, 0.4)
}
beatCount++
}
function startMusic() {
beatInterval = setInterval(playBeat, BEAT_MS)
}
function stopMusic() {
clearInterval(beatInterval)
}
// Los eventos del juego afectan a la energía
function onEnemyKill() {
energy = Math.min(1, energy + 0.1)
}La intensidad de la música cambia según lo que sucede en el juego.
Percusión con MembraneSynth y NoiseSynth
Para la percusión, utiliza sintetizadores especializados:
// Bombo
const kick = new Tone.MembraneSynth({
pitchDecay: 0.05,
octaves: 4,
envelope: {
attack: 0.001,
decay: 0.2,
sustain: 0,
release: 0.1,
},
volume: -14,
}).toDestination()
kick.triggerAttackRelease('C1', '8n')
// Caja
const snare = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: {
attack: 0.002,
decay: 0.15,
sustain: 0,
release: 0.1,
},
volume: -12,
}).toDestination()
snare.triggerAttackRelease('8n')
// Charles
const hihat = new Tone.NoiseSynth({
noise: { type: 'white' },
envelope: {
attack: 0.001,
decay: 0.04,
sustain: 0,
release: 0.03,
},
volume: -22,
}).toDestination()
hihat.triggerAttackRelease('32n')Limpieza
Libera los sintetizadores cuando termines de usarlos para recuperar recursos:
function cleanup() {
if (softSynth) softSynth.dispose()
if (subSynth) subSynth.dispose()
if (noiseSynth) noiseSynth.dispose()
if (sfxGain) sfxGain.dispose()
}Errores comunes
No esperar a Tone.start(): el audio no se reproducirá hasta que el contexto se inicie mediante una interacción del usuario.
Crear sintetizadores para cada sonido: crea los sintetizadores una sola vez durante la inicialización y reutilízalos. Crear otros nuevos cada vez provoca fugas de memoria.
Demasiados sonidos superpuestos: utiliza la limitación de frecuencia y la eliminación de duplicados para evitar una mezcla de audio confusa.
Frecuencias estridentes: utiliza filtros de paso bajo y tipos de oscilador más suaves (sine, triangle) para crear sonidos que no molesten a los jugadores.
Más recursos
API Web Audio para juegos explica la API subyacente y cómo cargar archivos de audio.
La documentación de Tone.js contiene la referencia completa de la API.
Dónde encontrar recursos gratuitos para juegos incluye proveedores de audio y efectos de sonido como Freesound.
Juegos web adaptados a móviles explica las restricciones de audio de iOS y los patrones de desbloqueo mediante interacción táctil.