33. Representar, visualizar y generar sonidos con JavaScript

JavaScript puede representar señales como números, dibujar sus formas de onda y construir grafos de audio en tiempo real. Un mismo modelo físico conecta datos, imagen y sonido.

33.1 Tres tareas relacionadas

Trabajar con sonido en un navegador suele combinar tres tareas: representar muestras numéricas, visualizar información y generar o procesar audio.

No son lo mismo. Un arreglo puede contener una señal sin reproducirla; un dibujo puede ser una representación reducida; un grafo de audio puede sonar sin guardar todas sus muestras en JavaScript.

33.2 Una señal como función

Una sinusoide de amplitud A, frecuencia f y fase φ puede evaluarse en cualquier instante:

x(t) = A sen(2πft + φ)

En JavaScript, Math.sin recibe radianes. El tiempo debe expresarse en segundos si f está en hertz.

33.3 Una señal como arreglo de muestras

Si la frecuencia de muestreo es fs, la muestra n corresponde a t = n/fs:

function crearSeno(frecuenciaHz, duracionS, muestreoHz, amplitud = 1) {
  const cantidad = Math.round(duracionS * muestreoHz);
  const muestras = new Float32Array(cantidad);

  for (let n = 0; n < cantidad; n += 1) {
    const tiempoS = n / muestreoHz;
    muestras[n] = amplitud * Math.sin(2 * Math.PI * frecuenciaHz * tiempoS);
  }
  return muestras;
}

33.4 Por qué usar Float32Array

Los arreglos tipados almacenan valores con un formato uniforme y memoria contigua. Float32Array coincide con la representación utilizada habitualmente para datos de audio en Web Audio.

También evita crear un objeto independiente para cada muestra, algo importante cuando hay decenas de miles de valores por segundo y por canal.

33.5 Eje temporal de una visualización

Para dibujar N muestras en un ancho W, una transformación sencilla es:

x = nW/(N − 1)

La coordenada horizontal representa tiempo. Si se omite la frecuencia de muestreo, el gráfico muestra el orden relativo, pero no permite leer segundos.

33.6 Transformar amplitud en coordenada vertical

En Canvas, y crece hacia abajo. Para una muestra normalizada entre −1 y 1:

y = alto/2 − muestra·escala

Restar invierte el eje para que una amplitud positiva aparezca arriba. La escala debe dejar margen y contemplar el valor máximo esperado.

33.7 Dibujar una forma de onda

function dibujarOnda(contexto, muestras, ancho, alto) {
  contexto.beginPath();
  muestras.forEach((muestra, indice) => {
    const x = indice * ancho / (muestras.length - 1);
    const y = alto / 2 - muestra * alto * 0.42;
    if (indice === 0) contexto.moveTo(x, y);
    else contexto.lineTo(x, y);
  });
  contexto.stroke();
}

Para arreglos muy largos conviene reducir o resumir los datos al ancho disponible en vez de trazar millones de segmentos invisibles.

33.8 Resolución del lienzo

El tamaño CSS de un canvas y su cantidad interna de píxeles pueden ser diferentes. En pantallas de alta densidad se ajustan canvas.width y canvas.height usando devicePixelRatio.

Después se transforma el contexto para seguir dibujando en coordenadas CSS. Esto evita líneas borrosas sin cambiar la lógica geométrica.

33.9 Animación y reloj visual

requestAnimationFrame sincroniza dibujos con la actualización visual del navegador. Es apropiado para mover un cursor o actualizar un osciloscopio.

No debe usarse como reloj de audio preciso. La interfaz puede perder cuadros; el audio necesita planificación basada en el reloj de AudioContext.

33.10 Web Audio como grafo

Web Audio organiza el procesamiento mediante nodos conectados. Una fuente puede pasar por control de ganancia, filtros y análisis antes de llegar al destino:

fuente → ganancia → analizador → destino

Cada nodo realiza una tarea y las conexiones definen el recorrido de la señal.

33.11 AudioContext

AudioContext contiene el grafo, el reloj de audio y el destino de reproducción. Su propiedad sampleRate informa la frecuencia de procesamiento elegida para ese contexto.

Conviene reutilizar un contexto en lugar de crear uno para cada nota. El estado puede comenzar suspendido y requerir resume().

33.12 La reproducción requiere interacción

Los navegadores protegen al usuario frente a audio inesperado. Por eso el contexto suele iniciarse o reanudarse dentro de un evento de botón, teclado u otra interacción válida.

El programa debe manejar el estado suspendido y no asumir que el sonido comenzará automáticamente al cargar la página.

33.13 OscillatorNode

Un OscillatorNode genera una señal periódica. Incluye tipos sinusoidal, cuadrado, diente de sierra y triangular, y permite usar una PeriodicWave personalizada.

const contexto = new AudioContext();
const oscilador = contexto.createOscillator();
oscilador.type = "sine";
oscilador.frequency.value = 440;
oscilador.connect(contexto.destination);
oscilador.start();

33.14 GainNode y amplitud

GainNode multiplica la señal por el valor de su parámetro gain. Es el control básico de amplitud dentro del grafo.

Una ganancia grande puede sumar señales fuera del intervalo disponible y producir clipping. Para pruebas conviene comenzar con valores pequeños.

33.15 Evitar clics con envolventes

Encender o cortar una onda instantáneamente crea una discontinuidad que contiene muchas frecuencias y puede oírse como un clic.

Una envolvente aplica un ataque y una liberación breves. Los métodos de automatización de AudioParam permiten programar rampas sobre el reloj de audio.

33.16 Programar eventos con currentTime

audioContext.currentTime es el reloj de referencia para planificar audio. Los eventos se programan con tiempos absolutos dentro del contexto.

const ahora = contexto.currentTime;
ganancia.gain.setValueAtTime(0.0001, ahora);
ganancia.gain.exponentialRampToValueAtTime(0.08, ahora + 0.03);
ganancia.gain.exponentialRampToValueAtTime(0.0001, ahora + 1);
oscilador.start(ahora);
oscilador.stop(ahora + 1.02);

33.17 Las fuentes programadas son de un solo uso

Después de detener un OscillatorNode o un AudioBufferSourceNode, no se lo vuelve a iniciar. Para una nueva reproducción se crea otra fuente.

Los nodos de procesamiento, como filtros y ganancias, sí pueden reutilizarse según la arquitectura elegida.

33.18 AudioBuffer para sonidos calculados

AudioBuffer contiene muestras en memoria. Es útil para reproducir señales generadas, sonidos decodificados o resultados calculados previamente.

const duracionS = 1;
const buffer = contexto.createBuffer(
  1,
  contexto.sampleRate * duracionS,
  contexto.sampleRate
);
const canal = buffer.getChannelData(0);

for (let n = 0; n < canal.length; n += 1) {
  canal[n] = 0.1 * Math.sin(2 * Math.PI * 440 * n / contexto.sampleRate);
}

33.19 PeriodicWave y síntesis armónica

PeriodicWave define una onda periódica mediante coeficientes de Fourier reales e imaginarios. Permite limitar la cantidad de armónicos o crear un timbre propio.

El índice del arreglo corresponde al número armónico. El índice 0 representa la componente continua.

33.20 Formas clásicas y armónicos

  • La sinusoide ideal contiene solo la fundamental.
  • La onda cuadrada ideal contiene armónicos impares con amplitud proporcional a 1/n.
  • El diente de sierra contiene todos los armónicos con amplitud proporcional a 1/n y signos alternados según la orientación.
  • La triangular contiene armónicos impares que decrecen como 1/n² y alternan signo.

Una suma finita aproxima esas formas sin reproducir bordes infinitamente abruptos.

33.21 AnalyserNode

AnalyserNode permite consultar datos temporales y espectrales sin interrumpir el flujo de audio. Es apropiado para osciloscopios y analizadores visuales.

Métodos como getFloatTimeDomainData y getFloatFrequencyData copian los resultados en arreglos proporcionados por el programa.

33.22 Tamaño de FFT y resolución

En un analizador, un tamaño de FFT mayor produce más bandas y mejora la separación en frecuencia, pero representa una ventana temporal más larga.

Δf ≈ fs/NFFT

Existe un compromiso entre resolución temporal y espectral. Aumentar N no mejora por sí solo la calidad del sonido.

33.23 Espectro en decibelios

Los datos espectrales suelen mostrarse en decibelios porque sus magnitudes abarcan un rango grande. El analizador permite configurar límites como minDecibels y maxDecibels.

Una barra espectral representa un intervalo de frecuencias, no necesariamente un armónico exacto.

33.24 Filtros

BiquadFilterNode ofrece filtros pasa bajos, pasa altos, pasa banda y otras respuestas comunes. Sus parámetros pueden automatizarse.

Filtrar modifica amplitudes y fases según la frecuencia. Un control de “tono” es un procesamiento espectral, no un cambio de la frecuencia fundamental.

33.25 Estéreo y canales

El audio puede tener varios canales. Un StereoPannerNode distribuye una señal entre izquierda y derecha mediante un parámetro de panorámica.

Dos canales no significan automáticamente dos sonidos independientes: pueden contener versiones correlacionadas de la misma escena.

33.26 Control de nivel y seguridad

Los ejemplos deben comenzar con ganancia reducida y evitar saltos repentinos. El volumen final también depende del sistema operativo, amplificador y auriculares.

Una aplicación no puede deducir con certeza el nivel acústico que llegará al oído. El usuario conserva el control del volumen físico.

33.27 Separar audio y visualización

El audio debe continuar aunque la interfaz pierda cuadros. La lógica visual lee o representa el estado, pero no debería alimentar muestra por muestra al audio desde el hilo principal.

Para procesamiento personalizado en tiempo real se utiliza una arquitectura destinada al hilo de audio, como AudioWorklet, no temporizadores visuales.

33.28 Rendimiento

Conviene reutilizar arreglos y evitar asignaciones masivas dentro de cada cuadro. Para dibujar muchos datos se agrupan muestras por columna y se muestran mínimos y máximos.

También se detienen fuentes que ya no se usan y se desconectan nodos cuando corresponde. Un grafo sencillo facilita comprender el flujo y detectar pérdidas de recursos.

33.29 Ejemplo resuelto: sumar tres parciales

Construyamos 20 ms de una señal con 220, 440 y 660 Hz y amplitudes 1; 0,5 y 0,25, normalizada para evitar clipping.

const sampleRate = 48000;
const duration = 0.02;
const partials = [
  { frequency: 220, amplitude: 1 },
  { frequency: 440, amplitude: 0.5 },
  { frequency: 660, amplitude: 0.25 }
];
const samples = new Float32Array(Math.round(sampleRate * duration));
const normalizer = partials.reduce((sum, partial) => sum + partial.amplitude, 0);

for (let n = 0; n < samples.length; n += 1) {
  const t = n / sampleRate;
  samples[n] = partials.reduce((sum, partial) =>
    sum + partial.amplitude * Math.sin(2 * Math.PI * partial.frequency * t), 0
  ) / normalizer;
}

console.log({ sampleCount: samples.length, firstSamples: [...samples.slice(0, 8)] });

Se generan 960 muestras. Dividir por la suma de amplitudes garantiza en este caso que la cota máxima no supere 1.

33.30 Grafo mínimo con envolvente

async function playTone(frequencyHz = 440) {
  const context = new AudioContext();
  await context.resume();
  const oscillator = context.createOscillator();
  const gain = context.createGain();
  const now = context.currentTime;

  oscillator.frequency.value = frequencyHz;
  gain.gain.setValueAtTime(0.0001, now);
  gain.gain.exponentialRampToValueAtTime(0.05, now + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.8);
  oscillator.connect(gain).connect(context.destination);
  oscillator.start(now);
  oscillator.stop(now + 0.82);
}

En una aplicación se reutilizaría el contexto. El ejemplo concentra en pocas líneas la creación, conexión, automatización y finalización de una fuente.

33.31 Errores frecuentes

  • Pasar grados a Math.sin en vez de radianes.
  • Usar el índice de muestra como tiempo sin dividir por fs.
  • Crear un AudioContext nuevo para cada nota.
  • Intentar reiniciar un oscilador que ya fue detenido.
  • Cortar la ganancia instantáneamente y producir clics.
  • Usar requestAnimationFrame para programar eventos de audio.
  • Sumar muchas señales sin reservar margen y provocar clipping.

33.32 Laboratorio interactivo de síntesis y visualización

Elegí una familia espectral, cantidad de armónicos y envolvente. La misma serie usada para dibujar crea una PeriodicWave al reproducir.

Período fundamental4,545 ms
Armónicos activos5
Frecuencia máxima1980 Hz
Frecuencia del contextoAl reproducir
EstadoDetenido

Cuadrada: armónicos impares con amplitud proporcional a 1/n.

Presioná “Reproducir sonido” para iniciar Web Audio mediante una interacción explícita.

33.33 Ejercicio resuelto: crear un acorde en un buffer

Un acorde de la mayor puede aproximarse con 440; 554,37 y 659,25 Hz. Sumemos las tres sinusoides con una envolvente corta.

function fillChord(channel, sampleRate, frequencies) {
  const duration = channel.length / sampleRate;
  for (let n = 0; n < channel.length; n += 1) {
    const t = n / sampleRate;
    const attack = Math.min(1, t / 0.02);
    const release = Math.min(1, (duration - t) / 0.2);
    const envelope = Math.max(0, Math.min(attack, release));
    const sum = frequencies.reduce(
      (value, frequency) => value + Math.sin(2 * Math.PI * frequency * t),
      0
    );
    channel[n] = 0.15 * envelope * sum / frequencies.length;
  }
}

const frequencies = [440, 554.37, 659.25];

Dividir por la cantidad de componentes y usar ganancia reducida deja margen. La envolvente evita discontinuidades al comienzo y al final.

33.34 Límites y compatibilidad

La disponibilidad de audio depende del navegador, dispositivo y políticas de reproducción. La latencia y la frecuencia del contexto pueden variar entre sistemas.

Las visualizaciones del laboratorio son series matemáticas ideales. El sonido físico final también depende del conversor, amplificador, altavoz, sala y oído.

33.35 Ejercicio propuesto

Programá una señal de 0,5 s formada por 300, 600 y 900 Hz con amplitudes relativas 1; 0,4 y 0,2, usando una frecuencia de muestreo de 48 kHz.

  1. Calculá cuántas muestras necesita el arreglo.
  2. Generá un Float32Array con la suma normalizada.
  3. Aplicá 20 ms de ataque y 100 ms de liberación.
  4. Indicá las posiciones aproximadas de los tres picos en un espectro.
  5. Explicá cómo reproducirías el arreglo mediante AudioBuffer.
Ver solución y explicación

Se necesitan 0,5·48000 = 24000 muestras. En cada índice se calcula t = n/48000, se suman las tres sinusoides y se divide por 1 + 0,4 + 0,2 = 1,6.

El ataque alcanza 1 en la muestra 960 y la liberación comienza aproximadamente en la muestra 19 200. Los picos aparecen alrededor de 300, 600 y 900 Hz. Para reproducir, se crea un AudioBuffer de un canal, se copian las muestras con copyToChannel o getChannelData y se conecta un nuevo AudioBufferSourceNode al destino.

33.36 Ideas para recordar

  • Una señal puede representarse como función o como arreglo de muestras.
  • Canvas transforma tiempo y amplitud en coordenadas visuales.
  • Web Audio procesa sonido mediante un grafo de nodos.
  • El reloj de audio es currentTime; la animación visual usa requestAnimationFrame.
  • Las envolventes evitan discontinuidades audibles.
  • OscillatorNode y AudioBufferSourceNode se crean nuevamente después de detenerse.
  • El control de ganancia y el margen previenen clipping.