Dos relojes

Programa el audio web con precisión

Chris Wilson
Chris Wilson

Introducción

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

Especialmente en las aplicaciones musicales de audio web (no solo en secuenciadores y sintetizadores de escritura, sino también en cualquier uso rítmico de eventos de audio como tambores, juegos y otras aplicaciones), es muy importante tener una sincronización precisa y uniforme 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 tener eventos que se aleatoricen un poco en el tiempo, por ejemplo, en la demostración de ametralladoras en Cómo desarrollar el audio de un juego con la API de Web Audio, pero, por lo general, queremos tener tiempos coherentes y precisos para las notas musicales.

Ya te mostramos cómo programar notas con los parámetros de tiempo de los métodos noteOn y noteOff (ahora con nombre de inicio y detención) 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 la reproducción de ritmos o secuencias musicales largas. Para profundizar en eso, primero necesitamos un poco de información sobre los relojes.

Lo mejor del momento: 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ó el AudioContext. Esto permite que este reloj (de ahora en adelante, denominado “reloj de audio”) sea muy preciso; está diseñado para poder especificar la alineación a un nivel de muestra de sonido individual, incluso con una tasa de muestreo alta. Como hay alrededor de 15 dígitos decimales de precisión en un “doble”, incluso si el reloj de audio ha estado funcionando durante días, aún debería tener muchos bits restantes para apuntar a una muestra específica incluso a 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 (para start() y stop(), por supuesto), pero también para los métodos set*ValueAtTime() en AudioParams. De esta manera, podemos configurar de antemano eventos de audio con tiempos muy precisos. De hecho, es tentador configurar todo en Web Audio como tiempo de inicio y detención, pero en la práctica existe 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 corcheas:

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á muy bien. Sin embargo, si quieres cambiar el tempo en medio de esos dos compases, o dejar de tocar antes de que las dos compases estén alto, no tienes suerte. (He visto a los desarrolladores hacer cosas como insertar un nodo de ganancia entre sus AudioBufferSourceNodes preprogramados y la salida, solo para que puedan silenciar sus propios sonidos).

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

Lo peor de los tiempos: el reloj de JavaScript

También tenemos nuestro querido reloj de JavaScript, representado por Date.now() y setTimeout(). El lado bueno del reloj de JavaScript es que cuenta con un par de métodos call-me-back-later window.setTimeout() y window.setInterval() muy útiles, que nos permiten hacer que el sistema llame a nuestro código en momentos específicos.

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

La prometedora especificación del tiempo de alta resolución, en realidad, nos brinda una hora actual mucho más precisa a través de window.performance.now(). Incluso se implementa (si bien tiene un prefijo) en muchos navegadores actuales. Esto puede ser útil en algunas situaciones, aunque no es realmente relevante para la peor parte de las APIs de sincronización de JavaScript.

La peor parte de las APIs de sincronización 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 cronómetro en JavaScript (a través de window.setTimeout() o window.setInterval) puede sesgarse fácilmente por decenas de milisegundos o más por diseño, renderización, recolección de elementos no utilizados, XMLHTTPRequest y otras devoluciones de llamada, en resumen, por cualquier ejecución de subprocesos que suceden en el subproceso. ¿Recuerdas que mencioné los “eventos de audio” que podrí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 cuando se hace un diseño complejo o alguna otra tarea larga, el audio se reproducirá exactamente en el momento exacto en el que se indicó que suceda; de hecho, incluso si te detienes en un punto de interrupción en el depurador, el subproceso de audio seguirá reproduciendo eventos programados.

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

Dado que el subproceso principal puede detenerse fácilmente durante varios milisegundos a la vez, no es una buena idea usar el 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 plazo aproximado de un milisegundo cuando deberían hacerlo y, en el peor de los casos, se retrasarán aún más. Y lo que es peor, para lo que deberían ser secuencias rítmicas, no se activarán a intervalos precisos, ya que el tiempo será sensible a otras cosas que sucedan en el subproceso principal de JavaScript.

Para demostrarlo, escribí un ejemplo de una aplicación de metrónomo “mala” (es decir, una que usa setTimeout directamente para programar notas) y que también realiza mucho diseño. Abre esta aplicación, haz clic en "reproducir" y cambia el tamaño de la ventana rápidamente mientras se está reproduciendo; notarás que el tiempo se altera notablemente (puedes escuchar que el ritmo no se mantiene constante). "¿Pero esto es forzado?" dices. Bueno, por supuesto, pero eso no significa que esto no suceda también en el mundo real. Incluso la interfaz de usuario relativamente estática tendrá problemas de sincronización en setTimeout debido a los cambios de diseño. Por ejemplo, noté que cambiar el tamaño de la ventana rápidamente hará que el tiempo de WebkitSynth, que, de otro modo, excelente, se entrecorte notablemente. Ahora imagina lo que sucederá cuando quieras desplazar sin problemas una partitura musical completa junto con tu audio. Puedes imaginar con facilidad 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?”. Si bien puede haber usos para este tipo de devoluciones de llamada, no solucionarían el problema específico en cuestión. Es importante entender que esos eventos se activarían en el subproceso principal de JavaScript, de modo que estarían sujetos a los mismos posibles retrasos que el de setTiempo de espera; es decir, se podrían retrasar durante un tiempo exacto y desconocido de milisegundos.

¿Qué podemos hacer? Bueno, la mejor manera de manejar la sincronización es configurar una colaboración entre los cronómetros de JavaScript (setTimeout(), setInterval() o requestAnimationFrame() , hablaremos sobre esto más adelante) y la programación del hardware de audio.

Obtener tiempos como en el futuro

Volvamos a esa demostración de metrónomo. De hecho, escribí de manera correcta la primera versión de esta simple demostración de metrónomo para demostrar esta técnica de programación colaborativa. (El código también está disponible en GitHub). Esta demostración reproduce pitidos (generados por un oscilador) con alta precisión en cada dieciséis, octavos o negras, 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. Esta es una característica clave de cualquier secuenciador rítmico del mundo real. Sería bastante fácil agregar código para cambiar los sonidos que usa este metrónomo sobre la marcha.

La forma en que permite el control de temperatura y, al mismo tiempo, mantiene una sincronización estable es una colaboración: un temporizador setTimeout que se activa una vez cada tanto y configura la programación de Web Audio en el futuro para notas individuales. El temporizador setTiempo de espera básicamente solo comprueba si hay que programar alguna nota "pronto" en función del tempo actual y, luego, las programa de la siguiente manera:

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

En la práctica, las llamadas a setTimeout() pueden retrasarse, por lo que el momento de las llamadas de programación puede variar (y desviarse, según cómo uses setTimeout) con el tiempo. Si bien los eventos de este ejemplo se activan con aproximadamente 50 ms de diferencia, a menudo son un poco más que eso (y, a veces, mucho más). Sin embargo, durante cada llamada, programamos eventos de audio web no solo para las notas que se deban reproducir en ese momento (por ejemplo, la primera nota), sino también para las notas que se deban reproducir entre ese momento y el próximo intervalo.

De hecho, no queremos mirar hacia adelante precisamente el intervalo entre las llamadas a setTimeout(). También necesitamos una superposición de programación entre esta llamada de temporizador y la siguiente, para adaptarnos al peor comportamiento del subproceso principal, es decir, el peor de los casos de recolección de elementos no utilizados, diseño, renderización u otro código que se produzca en el subproceso principal que retrase nuestra próxima llamada de temporizador. También debemos tener en cuenta el tiempo de programación de bloqueo de audio, es decir, la cantidad de audio que el sistema operativo conserva en su búfer de procesamiento, que varía entre los sistemas operativos 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 el intervalo de tiempo completo durante el cual intentará programar eventos; por ejemplo, el cuarto evento de audio web programado en el diagrama anterior podría haberse reproducido "tardío" si esperábamos reproducirlo hasta que se ocurriera la siguiente llamada a setTimeout, si esa llamada a setTiempo de espera fue solo unos milisegundos después. En la vida real, el jitter en estos tiempos puede ser aún más extremo que eso, y esta superposición se vuelve aún más importante a medida que tu app se vuelve más compleja.

La latencia anticipada general afecta lo estricto que puede ser el control de ritmo (y otros controles en tiempo real). El intervalo entre llamadas de programación es un equilibrio entre la latencia mínima y la frecuencia con la que tu código afecta al procesador. El nivel de superposición de la visualización anticipada con la hora de inicio del siguiente intervalo determina la resiliencia de tu app en 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 resistir a las máquinas y los sistemas operativos más lentos, es mejor tener un panorama general amplio y un intervalo razonablemente corto. Puedes ajustarte para tener superposiciones más cortas y intervalos más largos, a fin de procesar menos devoluciones de llamada, pero en algún momento, es posible que comiences a escuchar que una latencia grande hace que los cambios de tempo, etc., no se apliquen de inmediato; por el contrario, si disminuyes demasiado el retroceso, es posible que comiences a escuchar algunos nervios (ya que una llamada de programación debe “inventar” eventos que deberían haber ocurrido en el pasado).

En el siguiente diagrama de tiempo, se muestra lo que hace en realidad el código de demostración del metrónomo: tiene un intervalo setTimeout de 25 ms, pero una superposición mucho más resistente: cada llamada se programará para los siguientes 100 ms. La desventaja de este largo período de anticipación es que los cambios de ritmo, entre otros, 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 se puede ver que tuvimos una interrupción de setTimeout en el medio. Deberíamos haber tenido una devolución de llamada de setTimeout a los 270 ms aproximadamente, pero se retrasó por algún motivo hasta unos 320 ms o 50 ms después de lo esperado. Sin embargo, la gran latencia de vista anticipada mantuvo la sincronización sin problemas y no perdimos un ritmo, a pesar de que aumentamos el tempo justo antes de tocar las decimosextas notas a 240 ppm (más allá de los ritmos hardcore de drum & bass).

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

setTimeout() con un largo anticipado y intervalos largos.
setTimeout() con largo anticipado y intervalos largos

Este caso demuestra que cada llamada a setTimeout() puede terminar programando múltiples 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 batería (donde a menudo hay varias notas simultáneas) o un secuenciador (que a menudo puede tener intervalos no regulares entre notas).

En la práctica, lo mejor es ajustar el intervalo de programación y el anuncio anticipado para ver qué tan afectado lo afecta el diseño, la recolección de elementos no utilizados y otros elementos que ocurren en el subproceso de ejecución principal de JavaScript, y ajustar el nivel de detalle del control sobre el ritmo, etc. Si tienes un diseño muy complejo que ocurre con frecuencia, por ejemplo, probablemente te convenga agrandar el estilo del período de visualización. El punto principal es que queremos que la cantidad de “programación por adelantado” que hacemos sea lo suficientemente grande como para evitar demoras, pero no tan grande como para crear un retraso notable cuando se ajusta el control de ritmo. 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 probablemente 100 ms de tiempo de “preparación”, con intervalos establecidos en 25 ms. Esto puede seguir teniendo problemas en aplicaciones complejas de máquinas con mucha latencia del sistema de audio, en cuyo caso debes anticipar el tiempo previsto o, si necesitas un mayor control debido a la pérdida de resiliencia, usar un estilo anticipado más corto.

El código central 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 el tiempo actual del hardware de audio y lo compara con el tiempo de la siguiente nota en la secuencia. La mayoría de las veces*, en esta situación precisa, esto no hará nada (ya que no hay “notas” del metrónomo esperando para programarse, pero cuando tiene éxito, programará esa nota usando la API de Web Audio y avanzará a la siguiente nota.

La función scheduleNote() es responsable de programar la reproducción de la siguiente "nota" del audio web. En este caso, usé osciladores para emitir pitidos en diferentes frecuencias; podrías crear fácilmente nodos AudioBufferSource y establecer sus búferes para 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 de ellos por completo; se iniciarán, luego se detendrán y luego se recolectarán automáticamente.

El método nextNote() es responsable de avanzar a la siguiente decimosexta nota, 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 claro, 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 inicio del metrónomo. Lo único que tenemos que hacer es recordar cuándo reprodujimos la última nota y determinar cuándo está programada para tocar la siguiente nota. De esa manera, podemos cambiar el tempo (o dejar de jugar) muy fácilmente.

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

Otro sistema de sincronización

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

¿Por qué? ¿Por qué necesitamos otro sistema de tiempos? Este se sincroniza con la pantalla visual, es decir, la frecuencia de actualización de gráficos, a través de la API de requestAnimationFrame. Para dibujar cuadros en nuestro ejemplo de metrónomo, esto puede no parecer muy grande, pero a medida que tus gráficos se vuelven cada vez 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 los gráficos sincronizados muy complejos (p. ej., la visualización precisa de las notas musicales densas y precisas en una notación de audio musical).

Mantuvimos un registro de los ritmos en la cola del programador:

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

La interacción con el tiempo 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án que estamos revisando el reloj del sistema de audio, porque ese es realmente con el que queremos sincronizarlo, ya que en realidad reproduce las notas, para ver si deberíamos dibujar un nuevo cuadro o no. De hecho, en realidad no usamos las marcas de tiempo requestAnimationFrame, ya que usamos el reloj del sistema de audio para averiguar dónde nos encontramos en el tiempo.

Por supuesto, podría haber omitido el uso de una devolución de llamada setTimeout() y haber colocado mi programador de notas en la devolución de llamada requestAnimationFrame, luego volveríamos a usar dos temporizadores. Está bien hacer eso también, pero es importante entender que requestAnimationFrame es solo un sustituto de setTimeout() en este caso; igualmente querrás la precisión de programación de la sincronización del audio web para las notas reales.

Conclusión

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