Dos relojes

Programa audio web con precisión

Chris Wilson
Chris Wilson

Introducción

Uno de los mayores desafíos en la creación de software de música y audio de alta calidad con la plataforma web es la administración del tiempo. No como en “es hora de escribir código”, sino como en la hora del reloj. Uno de los temas menos comprendidos sobre Web Audio es cómo trabajar correctamente con el reloj de audio. El objeto AudioContext de Web Audio tiene una propiedad currentTime que expone este reloj de audio.

En particular, para las aplicaciones musicales de audio web, no solo para escribir secuenciadores y sintetizadores, sino para cualquier uso rítmico de eventos de audio, como máquinas de batería, juegos y otras aplicaciones, es muy importante tener una sincronización coherente y precisa de los eventos de audio, no solo para iniciar y detener sonidos, sino también para programar cambios en el sonido (como cambiar la frecuencia o el volumen). A veces, es conveniente que haya eventos levemente aleatorizados en cuanto al tiempo (por ejemplo, en la demostración de ametralladoras de Developing Game Audio con la API de Web Audio), pero, por lo general, lo ideal es que las notas musicales se sincronicen de manera coherente y precisa.

Ya te mostramos cómo programar notas con el parámetro de tiempo de los métodos noteOn y noteOff (ahora renombrados como start y stop) de Web Audio en Cómo comenzar a usar Web Audio y también en Cómo desarrollar audio de juegos con la API de Web Audio. Sin embargo, no exploramos en profundidad situaciones más complejas, como reproducir secuencias o ritmos musicales largos. Para empezar, necesitamos un poco de información sobre relojes.

The Best of Times: el reloj de audio web

La API de Web Audio expone el acceso al reloj de hardware del subsistema de audio. Este reloj se expone en el objeto AudioContext a través de su propiedad .currentTime, como un número de punto flotante de segundos desde que se creó AudioContext. Esto permite que este reloj (en adelante, llamado "reloj de audio") tenga una precisión muy alta; está diseñado para poder especificar la alineación a nivel de una muestra de sonido individual, incluso con una tasa de muestreo alta. Dado que hay alrededor de 15 dígitos decimales de precisión en un “doble”, incluso si el reloj de audio se ejecuta durante días, debería tener muchos bits restantes para apuntar a una muestra específica, incluso con una tasa de muestreo alta.

El reloj de audio se usa para programar parámetros y eventos de audio en toda la API de Web Audio, por supuesto, para start() y stop(), pero también para métodos set*ValueAtTime() en AudioParams. Esto nos permite configurar por adelantado eventos de audio con tiempos muy precisos. De hecho, es tentador configurar todo en Web Audio como tiempos de inicio y detención. Sin embargo, en la práctica, hay un problema con eso.

Por ejemplo, observa este fragmento de código reducido de nuestra introducción de audio web, que configura dos barras de un patrón de hi-hat de corchea:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

Este código funcionará de maravilla. Sin embargo, si deseas cambiar el tempo en el medio de esas dos barras, o dejar de reproducir antes de que las dos barras estén arriba, no tienes suerte. (He visto a desarrolladores hacer cosas como insertar un nodo de ganancia entre sus AudioBufferSourceNodes previamente programados y la salida, para que puedan silenciar sus propios sonidos).

En resumen, debido a que necesitarás la flexibilidad para cambiar el tempo o parámetros como la frecuencia u ganancia (o dejar de programar por completo), no querrás enviar demasiados eventos de audio a la cola o, para ser más precisos, no querrás mirar demasiado adelante en el tiempo, porque es posible que quieras cambiar esa programación por completo.

El peor de los tiempos: el reloj JavaScript

También tenemos nuestro querido y muy criticado reloj de JavaScript, representado por Date.now() y setTimeout(). La ventaja del reloj de JavaScript es que tiene un par de métodos muy útiles de devolución de llamada más tarde, window.setTimeout() y window.setInterval(), que nos permiten que el sistema vuelva a llamar a nuestro código en momentos específicos.

El inconveniente del reloj de JavaScript es que no es muy preciso. Para empezar, Date.now() muestra un valor en milisegundos (un número entero de milisegundos), por lo que la mejor precisión que puedes esperar es de un milisegundo. Esto no es muy malo en algunos contextos musicales. Si tu nota comenzó un milisegundo antes o después, puede que ni siquiera lo notes, pero incluso con una tasa de hardware de audio relativamente baja de 44.1 kHz, es alrededor de 44.1 veces demasiado lenta para usarla como reloj de programación de audio. Recuerda que soltar muestras puede causar fallas de audio. Por lo tanto, si encadenamos muestras, es posible que debamos que sean secuenciales con precisión.

La próxima especificación de hora de alta resolución nos brinda una hora actual mucho más precisa a través de window.performance.now(); incluso se implementa (aunque con prefijo) en muchos navegadores actuales. Eso puede ayudar en algunas situaciones, aunque no es realmente relevante para la peor parte de las APIs de tiempo de JavaScript.

La peor parte de las APIs de tiempo de JavaScript es que, aunque la precisión de milisegundos de Date.now() no suena tan mal, la devolución de llamada real de los eventos del temporizador en JavaScript (a través de window.setTimeout() o window.setInterval) puede sesgarse fácilmente por decenas de milisegundos o más por el diseño, la renderización, la recolección de basura, XMLHTTPRequest y otras devoluciones de llamada; en resumen, por cualquier cantidad de eventos que ocurren en el subproceso de ejecución principal. ¿Recuerdas que mencioné los "eventos de audio" que podíamos programar con la API de Web Audio? Bueno, todos se procesan en un subproceso independiente, por lo que, incluso si el subproceso principal se detiene temporalmente para realizar un diseño complejo o alguna otra tarea larga, el audio se reproducirá exactamente en los momentos en que se les indicó que se reprodujeran. De hecho, incluso si te detienes en una pausa del depurador, el subproceso de audio seguirá reproduciendo los eventos programados.

Cómo usar setTimeout() de JavaScript en apps de audio

Dado que el subproceso principal puede detenerse fácilmente durante varios milisegundos a la vez, no es recomendable usar setTimeout de JavaScript para comenzar a reproducir eventos de audio directamente, ya que, en el mejor de los casos, tus notas se activarán en un milisegundo o más de lo que realmente deberían, y en el peor de los casos, se retrasarán aún más. Lo peor de todo es que, para lo que deberían ser secuencias rítmicas, no se activarán en intervalos precisos, ya que el tiempo será sensible a otras cosas que suceden en el subproceso principal de JavaScript.

Para demostrar esto, escribí una muestra de aplicación de metrónomo “mala”, es decir, una que usa setTimeout directamente para programar notas y también realiza muchos diseños. Abre esta aplicación, haz clic en “Reproducir” y, luego, cambia el tamaño de la ventana rápidamente mientras se reproduce. Notarás que el tiempo es muy inestable (puedes escuchar que el ritmo no es constante). ¿Dices que esto es artificial? Bueno, por supuesto, pero eso no significa que esto tampoco suceda en el mundo real. Incluso la interfaz de usuario relativamente estática tendrá problemas de tiempo de espera en setTimeout debido a los reenvíos. Por ejemplo, noté que cambiar el tamaño de la ventana rápidamente hará que el tiempo de espera en el excelente WebkitSynth se detenga de forma notable. Ahora imagina lo que sucederá cuando intentes desplazar una partitura musical completa junto con el audio. Podrás imaginar fácilmente cómo esto afectaría a apps de música complejas en el mundo real.

Una de las preguntas más frecuentes que escucho es "¿Por qué no puedo obtener devoluciones de llamada de eventos de audio?". Aunque puede haber usos para estos tipos de devoluciones de llamada, no resolverían el problema en particular; es importante comprender que esos eventos se activarían en el subproceso de JavaScript principal, por lo que estarían sujetos a los mismos retrasos potenciales que setTimeout; es decir, podrían retrasarse durante un tiempo determinado desconocido y variable de milisegundos.

Entonces, ¿qué podemos hacer? Bueno, la mejor manera de controlar los tiempos es configurar una colaboración entre los temporizadores de JavaScript (setTimeout(), setInterval() o requestAnimationFrame(); hablaremos de esto más adelante) y la programación de hardware de audio.

Lograr un mejor manejo de los tiempos con el futuro

Volvamos a esa demostración del metrónomo. De hecho, escribí la primera versión de esta sencilla demostración del metrónomo correctamente para demostrar esta técnica de programación colaborativa. (El código también está disponible en GitHub). Esta demostración reproduce sonidos de pitido (generados por un oscilador) con alta precisión en cada nota semicorchea, corchea o negra, y altera el tono según el ritmo. También te permite cambiar el tempo y el intervalo de notas mientras se reproduce, o detener la reproducción en cualquier momento, lo que es una función clave para cualquier secuenciador rítmico del mundo real. Sería muy fácil agregar código para cambiar los sonidos que usa este metrónomo sobre la marcha.

La forma en que logra permitir el control de la temperatura y, al mismo tiempo, mantener un tiempo sólido es una colaboración: un temporizador setTimeout que se activa de vez en cuando y configura la programación de Web Audio en el futuro para notas individuales. El temporizador setTimeout básicamente solo comprueba si se deben programar notas “pronto” según el tempo actual y, luego, las programa de la siguiente manera:

Interacción de setTimeout() y eventos de audio.
setTimeout() y la interacción con eventos de audio.

En la práctica, las llamadas a setTimeout() pueden retrasarse, por lo que el tiempo de las llamadas de programación puede variar (y sesgar, según cómo uses setTimeout) con el tiempo. Aunque los eventos de este ejemplo se activan con aproximadamente 50 ms de diferencia, a menudo son un poco más (y, a veces, mucho más). Sin embargo, durante cada llamada, programamos eventos de Web Audio no solo para las notas que deben reproducirse ahora (p.ej., la primera nota), sino también para las que deben reproducirse entre ahora y el siguiente intervalo.

De hecho, no queremos solo mirar hacia adelante con precisión el intervalo entre las llamadas a setTimeout(), sino que también necesitamos cierta superposición de programación entre esta llamada al temporizador y la siguiente para poder adaptarnos al comportamiento del subproceso principal en el peor de los casos, es decir, el peor de los casos de recolección de basura, diseño, renderización o cualquier otro código que se produzca en el subproceso principal que retrase nuestra próxima llamada al temporizador. También debemos tener en cuenta el tiempo de programación de bloques de audio, es decir, la cantidad de audio que el sistema operativo conserva en su búfer de procesamiento, que varía según el sistema operativo y el hardware, desde un solo dígito bajo de milisegundos hasta alrededor de 50 ms. Cada llamada a setTimeout() que se muestra arriba tiene un intervalo azul que muestra todo el rango de tiempos durante los cuales se intentará programar eventos. Por ejemplo, el cuarto evento de audio web programado en el diagrama anterior podría haberse reproducido “tarde” si hubiéramos esperado para reproducirlo hasta que se produjera la siguiente llamada a setTimeout, si esa llamada a setTimeout se hubiera producido unos pocos milisegundos más tarde. En la vida real, el jitter en estos tiempos puede ser aún más extremo, y esta superposición se vuelve aún más importante a medida que la app se vuelve más compleja.

La latencia anticipada general afecta qué tan estricto puede ser el control de tempo (y otros controles en tiempo real). El intervalo entre llamadas de programación es una compensación entre la latencia mínima y la frecuencia con la que tu código afecta al procesador. La superposición de la anticipación con la hora de inicio del siguiente intervalo determina la resiliencia de tu app entre diferentes máquinas y a medida que se vuelve más compleja (y el diseño y la recolección de elementos no utilizados pueden tardar más). En general, para ser resistente a máquinas y sistemas operativos más lentos, es mejor tener una gran anticipación general y un intervalo razonablemente corto. Puedes ajustar para tener superposiciones más cortas y intervalos más largos, de modo que se procesen menos devoluciones de llamada, pero, en algún momento, es posible que comiences a escuchar que una latencia alta hace que los cambios de tempo, etc., no se apliquen de inmediato. Por el contrario, si disminuiste demasiado la previsión, es posible que comiences a escuchar algunos errores de jitter (ya que una llamada de programación podría tener que “compensar” eventos que deberían haber ocurrido en el pasado).

En el siguiente diagrama de tiempo, se muestra lo que realmente hace el código de demostración del metrónomo: tiene un intervalo de setTimeout de 25 ms, pero una superposición mucho más resistente: cada llamada se programará para los próximos 100 ms. La desventaja de esta previsión a largo plazo es que los cambios de tempo, etc., tardarán una décima de segundo en aplicarse. Sin embargo, somos mucho más resistentes a las interrupciones:

Programación con superposiciones largas.
programación con superposiciones largas

De hecho, en este ejemplo, puedes ver que tuvimos una interrupción de setTimeout en el medio. Deberíamos haber tenido una devolución de llamada de setTimeout aproximadamente a los 270 ms, pero se retrasó por algún motivo hasta aproximadamente los 320 ms, ¡50 ms más tarde de lo que debería haber sido! Sin embargo, la gran latencia de previsión mantuvo el tiempo sin problemas, y no perdimos el ritmo, a pesar de que aumentamos el tempo justo antes para reproducir semicorcheas a 240 bpm (más allá de los tempos de batería y bajo hardcore).

También es posible que cada llamada del programador termine programando varias notas. Veamos qué sucede si usamos un intervalo de programación más largo (250 ms de anticipación, con un espacio de 200 ms) y un aumento de tempo en el medio:

setTimeout() con anticipación larga e intervalos largos.
setTimeout() con anticipación larga y intervalos largos

En este caso, se demuestra que cada llamada a setTimeout() puede terminar programando varios eventos de audio. De hecho, este metrónomo es una aplicación simple de una nota a la vez, pero puedes ver fácilmente cómo funciona este enfoque para una caja de ritmos (en la que a menudo hay varias notas simultáneas) o un secuenciador (que puede tener intervalos no regulares entre las notas).

En la práctica, te recomendamos ajustar el intervalo de programación y la vista previa para ver qué tan afectado se ve por el diseño, la recolección de elementos no utilizados y otras cosas que ocurren en el subproceso de ejecución principal de JavaScript, y para ajustar el nivel de detalle del control sobre el tempo, etc. Si tienes un diseño muy complejo que ocurre con frecuencia, por ejemplo, probablemente querrás agrandar la vista previa. El punto principal es que queremos que la cantidad de “programación anticipada” que hacemos sea lo suficientemente grande como para evitar demoras, pero no tanto como para crear una demora notable cuando se ajusta el control de tempo. Incluso el caso anterior tiene una superposición muy pequeña, por lo que no será muy resistente en una máquina lenta con una aplicación web compleja. Un buen punto de partida son 100 ms de tiempo de “anticipación”, con intervalos establecidos en 25 ms. Es posible que aún haya problemas en aplicaciones complejas en máquinas con mucha latencia del sistema de audio. En ese caso, debes aumentar el tiempo de visualización anticipada. O bien, si necesitas un control más estricto con la pérdida de cierta resiliencia, usa una visualización anticipada más corta.

El código principal del proceso de programación se encuentra en la función scheduler().

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

Esta función solo obtiene la hora actual del hardware de audio y la compara con la hora de la siguiente nota de la secuencia. La mayoría de las veces* en esta situación precisa, no hará nada (ya que no hay "notas" del metrónomo esperando a ser programadas), pero cuando tenga éxito, programará esa nota con la API de Web Audio y avanzará a la siguiente.

La función scheduleNote() es responsable de programar la próxima "nota" de Web Audio que se reproducirá. En este caso, usé osciladores para hacer sonidos de pitido en diferentes frecuencias. Con la misma facilidad, puedes crear nodos AudioBufferSource y configurar sus búferes para que reproduzcan sonidos de batería o cualquier otro sonido que desees.

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

Una vez que esos osciladores estén programados y conectados, este código puede olvidarse por completo de ellos; se iniciarán, se detendrán y se eliminarán automáticamente.

El método nextNote() es responsable de avanzar a la siguiente semicorchea, es decir, establecer las variables nextNoteTime y current16thNote en la siguiente nota:

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

Esto es bastante sencillo, aunque es importante entender que en este ejemplo de programación no estoy haciendo un seguimiento del “tiempo de la secuencia”, es decir, el tiempo desde el comienzo del metrónomo. Todo lo que tenemos que hacer es recordar cuándo tocamos la última nota y averiguar cuándo se programó la siguiente. De esta manera, podemos cambiar el tempo (o detener la reproducción) con mucha facilidad.

Varias aplicaciones de audio de la Web utilizan esta técnica de programación, por ejemplo, la Web Audio Drum Machine, el divertido juego Acid Defender y otros ejemplos de audio más detallados, como la demostración de efectos granulares.

Yet Another Timing System

Como cualquier buen músico sabe, lo que toda aplicación de audio necesita es más cencerro, más temporizadores. Vale la pena mencionar que la forma correcta de hacer una presentación visual es usar un sistema de sincronización TERCERO.

¿Por qué, por qué, por qué necesitamos otro sistema de temporización? Bueno, esta se sincroniza con la visualización visual, es decir, la frecuencia de actualización de los gráficos, a través de la API de requestAnimationFrame. Para dibujar cuadros en nuestro ejemplo de metrónomo, esto puede no parecer un gran problema, pero a medida que tus gráficos se vuelven más y más complejos, se vuelve cada vez más importante usar requestAnimationFrame() para sincronizarse con la frecuencia de actualización visual, y en realidad es tan fácil de usar desde el principio como usar setTimeout(). Con gráficos sincronizados muy complejos (p. ej., la visualización precisa de notas musicales densas mientras se reproducen en un paquete de notación musical), requestAnimationFrame() te brindará la sincronización de audio y gráficos más fluida y precisa.

Hacemos un seguimiento de los ritmos de la cola en el programador:

notesInQueue.push( { note: beatNumber, time: time } );

La interacción con la hora actual de nuestro metrónomo se puede encontrar en el método draw(), al que se llama (con requestAnimationFrame) cada vez que el sistema gráfico está listo para una actualización:

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

Una vez más, notarás que estamos verificando el reloj del sistema de audio, ya que es con el que queremos sincronizarnos, ya que será el que reproducirá las notas, para ver si debemos dibujar un cuadro nuevo o no. De hecho, realmente no usamos las marcas de tiempo requestAnimationFrame en absoluto, ya que usaremos el reloj del sistema de audio para averiguar dónde estamos en el tiempo.

Por supuesto, podría haber omitido por completo el uso de una devolución de llamada setTimeout() y haber colocado mi programador de notas en la devolución de llamada requestAnimationFrame. En ese caso, volveríamos a tener dos temporizadores. También está bien hacerlo, pero es importante comprender que requestAnimationFrame es solo un sustituto de setTimeout() en este caso. De todas formas, querrás que la precisión de programación del tiempo de Web Audio sea para las notas reales.

Conclusión

Espero que este instructivo te haya resultado útil para explicar los relojes, los cronómetros y cómo crear un buen tiempo en las aplicaciones de audio web. Estas mismas técnicas se pueden extrapolar fácilmente para crear reproductores de secuencias, baterías y más. Hasta la próxima…