Ir al contenido
Ayotl

← Así se hace un juego: Snake de Daily Challenge · Lección 11/16

Un chip de sonido con Web Audio

✕
1/9

Una mesa de mezclas en el navegador

Daily Challenge no carga ni un mp3: todos sus sonidos se fabrican al momento con la Web Audio API, como hacía el chip de una consola de 8 bits. La idea es una mesa de mezclas: un AudioContext y nodos conectados en cadena.

  • Un OscillatorNode da un tono: onda square (cuadrada, el sonido de Atari y NES) o triangle (triangular, más suave, buena para el bajo).
  • Un GainNode controla el volumen en el tiempo.
  • Todo termina en un master y de ahí a las bocinas. La música pasa antes por su propio bus, musicBus, para poder callarla sin callar los efectos.

En los teléfonos hay una regla: el audio sólo arranca desde un toque del usuario. Por eso chip.unlock() se llama al pulsar JUGAR, no al cargar la página.

Aquí no hay bocinas (el código corre en un Worker), así que el programa usa un AudioContext de mentira que escribe lo que habría sonado, con la función note() de core/audio.js tal cual.

leccion.js

$ node leccion.js

Pulsa CORRER (o Ctrl+Enter).

Qué mirar

  • Comer son dos notas cuadradas seguidas, 660 y 990 Hz: una quinta hacia arriba, suena a «¡bien!».
  • Chocar es una sola nota que se desliza de 400 a 50 Hz en medio segundo: el «piuuu» de toda la vida (en el juego lleva además un golpe de ruido).
  • Cada nota se apaga 20 ms después de su final, para que la envolvente termine antes de cortar.

Web Audio API (MDN) ↗

Toda la lección en una página

Una mesa de mezclas en el navegador

Daily Challenge no carga ni un mp3: todos sus sonidos se fabrican al momento con la Web Audio API, como hacía el chip de una consola de 8 bits. La idea es una mesa de mezclas: un AudioContext y nodos conectados en cadena.

  • Un OscillatorNode da un tono: onda square (cuadrada, el sonido de Atari y NES) o triangle (triangular, más suave, buena para el bajo).
  • Un GainNode controla el volumen en el tiempo.
  • Todo termina en un master y de ahí a las bocinas. La música pasa antes por su propio bus, musicBus, para poder callarla sin callar los efectos.

En los teléfonos hay una regla: el audio sólo arranca desde un toque del usuario. Por eso chip.unlock() se llama al pulsar JUGAR, no al cargar la página.

Aquí no hay bocinas (el código corre en un Worker), así que el programa usa un AudioContext de mentira que escribe lo que habría sonado, con la función note() de core/audio.js tal cual.

// Un AudioContext de mentira: en vez de sonar, escribe lo que haría
const ctx = {
  createOscillator() {
    const o = {
      type: "",
      frequency: {
        setValueAtTime: (f, t) => console.log("  oscilador", o.type, f, "Hz desde t =", t),
        exponentialRampToValueAtTime: (f, t) => console.log("  se desliza hasta", f, "Hz en t =", t.toFixed(2)),
      },
      connect: (n) => n,
      start() {},
      stop: (t) => console.log("  se apaga en t =", t.toFixed(2)),
    };
    return o;
  },
  createGain() {
    return { gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect: (n) => n };
  },
};
const master = {};

// note() de core/audio.js
function note(freq, t, dur, type = "square", vol = 0.15, slideTo = null, bus = master) {
  const osc = ctx.createOscillator();
  const g = ctx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, t);
  if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(vol, t + 0.005);
  g.gain.setValueAtTime(vol, t + dur * 0.6);
  g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  osc.connect(g).connect(bus);
  osc.start(t); osc.stop(t + dur + 0.02);
}

console.log("eat (comer fruta):");
note(660, 0, 0.06, "square", 0.2);
note(990, 0.06, 0.08, "square", 0.2);
console.log("die (chocar):");
note(400, 0, 0.5, "square", 0.25, 50);
  • Comer son dos notas cuadradas seguidas, 660 y 990 Hz: una quinta hacia arriba, suena a «¡bien!».
  • Chocar es una sola nota que se desliza de 400 a 50 Hz en medio segundo: el «piuuu» de toda la vida (en el juego lleva además un golpe de ruido).
  • Cada nota se apaga 20 ms después de su final, para que la envolvente termine antes de cortar.

La envolvente: cómo nace y muere una nota

Si un oscilador se prende y se apaga de golpe, se oye un «clic». Por eso cada nota lleva una envolvente en su GainNode: sube en 5 milésimas de segundo (el ataque), se sostiene hasta el 60 % de su duración y luego cae (el decaimiento).

Las rampas son exponenciales (exponentialRampToValueAtTime) porque el oído percibe el volumen así: bajar de 0.2 a 0.1 suena igual de fuerte que bajar de 0.02 a 0.01. Y por eso arrancan en 0.0001 y no en cero: una rampa exponencial no puede salir de 0.

El programa calcula esa misma curva para una nota de 0.3 s y la dibuja con #.

// La envolvente de note(): ataque de 5 ms, sostiene hasta el 60 %, cae al final
function volumen(t, dur, vol) {
  const rampa = (v0, v1, t0, t1) => v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0));
  if (t < 0.005) return rampa(0.0001, vol, 0, 0.005);
  if (t < dur * 0.6) return vol;
  if (t < dur) return rampa(vol, 0.0001, dur * 0.6, dur);
  return 0;
}

for (let i = 0; i <= 12; i++) {
  const t = i * 0.025;
  const v = volumen(t, 0.3, 0.2);
  console.log(t.toFixed(3), "s", v.toFixed(4).padStart(7), "#".repeat(Math.round(v * 150)));
}
  • Arriba casi desde el primer instante: con 5 ms de ataque el oído lo siente inmediato, pero sin clic.
  • Plano hasta 0.18 s (el 60 % de 0.3).
  • Luego cae rápido al principio y despacio al final: así se ve una rampa exponencial.

El juego no suena: avisa

La clase Snake no sabe que existe el audio. Cuando come, mete 'eat' en this.events; cuando choca, 'die'. Al final de cada tick, main.js recorre esa lista y toca lo que toque:

for (const e of game.events) chip.sfx(e);

¿Por qué tanto rodeo? Porque el servidor corre la misma clase para revisar puntajes, y en el servidor no hay bocinas ni AudioContext. Separar «qué pasó» de «cómo suena» deja la simulación limpia, y además sfx() se encarga de detalles que no son del juego: no repetir el mismo efecto antes de 40 ms y no sonar si apagaste los efectos.

// Un juego mínimo que sólo avisa
class Juego {
  constructor() { this.tick = 0; this.events = []; }
  update() {
    this.events.length = 0;          // se vacía en cada tick, como en snake.js
    this.tick++;
    if (this.tick % 3 === 0) this.events.push("eat");
    if (this.tick === 7) this.events.push("die");
  }
}

// Lo que haría chip.sfx(): aquí sólo escribe las notas
const SONIDOS = { eat: [660, 990], die: [400] };
let efectosOn = true;
const sfx = (e) => { if (efectosOn) console.log("  ♪", e, SONIDOS[e].join(" → "), "Hz"); };

const game = new Juego();
for (let i = 1; i <= 7; i++) {
  game.update();
  console.log("tick", i, game.events);
  for (const e of game.events) sfx(e);
}

La simulación produce una lista de palabras; la parte del navegador las convierte en sonido. Si pones efectosOn = false y corres otra vez, el juego hace exactamente lo mismo y sólo se calla el sonido.

Ruido blanco para la batería

Un oscilador no sabe hacer un platillo ni una tarola: eso es ruido. Al desbloquear el audio, el chip llena un buffer de un segundo con números al azar entre −1 y 1 (Math.random() * 2 - 1), y cada golpe de batería reproduce un pedazo pasándolo por un filtro pasa-altos: con corte en 6000 Hz suena a platillo (H), en 1500 Hz a tarola (S). El bombo (K) sí es un oscilador triangular que baja de 160 a 40 Hz.

Aquí Math.random está bien: el ruido sólo suena y no cambia el juego.

// El buffer de ruido del chip: un segundo de muestras al azar entre -1 y 1
const sampleRate = 44100;
const ruido = new Float32Array(sampleRate);
for (let i = 0; i < ruido.length; i++) ruido[i] = Math.random() * 2 - 1;

console.log("muestras:", ruido.length);
console.log("las primeras:", Array.from(ruido.slice(0, 5), (v) => v.toFixed(3)).join(" "));
const media = ruido.reduce((s, v) => s + v, 0) / ruido.length;
console.log("media:", media.toFixed(4));

// drum() de core/audio.js, resumido
const BATERIA = {
  K: "triángulo 160 → 40 Hz, 0.12 s",
  S: "ruido con pasa-altos en 1500 Hz + triángulo 200 → 100 Hz",
  H: "ruido con pasa-altos en 6000 Hz, 0.035 s",
};
for (const [golpe, como] of Object.entries(BATERIA)) console.log(golpe, "=", como);

44 100 muestras: un segundo a la calidad de un CD. La media sale casi en cero porque hay tantos positivos como negativos; cambia en cada corrida, y da igual.