Caso de éxito: The Sounds of Racer

Introducción

Racer es un experimento de Chrome multijugador y multidispositivo. Un juego de tragamonedas de estilo retro que se juega en varias pantallas. En teléfonos o tablets, Android o iOS Cualquier persona puede unirse. No hay apps. No más descargas. Solo en la Web móvil.

Plan8 junto con nuestros amigos de 14islands crearon una experiencia dinámica de música y sonido basada en una composición original de Giorgio Moroder. Racer incluye sonidos de motor responsivos, efectos de sonido de carreras y, sobre todo, una mezcla musical dinámica que se distribuye en varios dispositivos a medida que los corredores se unen. Se trata de una instalación con varias bocinas compuesta por smartphones.

La conexión de varios dispositivos era algo con lo que habíamos estado desperdiciando durante algún tiempo. Hicimos experimentos musicales en los que el sonido se dividiera en diferentes dispositivos o cambiara de un dispositivo a otro, por lo que estábamos ansiosos por aplicar esas ideas a Racer.

Específicamente, quisimos probar si podíamos desarrollar la pista musical en todos los dispositivos a medida que más y más personas se unían al juego, comenzando con batería y bajo, luego agregando guitarra y sintetizadores, etcétera. Hicimos algunas demostraciones musicales y nos sumergimos en la programación. El efecto de múltiples bocinas fue muy gratificante. En este momento, no teníamos toda la sincronización, pero cuando escuchamos las capas de sonido esparcidas en los dispositivos, supimos que estábamos llegando a algo bueno.

Crea los sonidos

Google Creative Lab definió una dirección creativa para el sonido y la música. Queríamos usar sintetizadores analógicos para crear los efectos de sonido en lugar de grabar sonidos reales o recurrir a bibliotecas de sonidos. También sabíamos que la bocina de salida, en la mayoría de los casos, sería una bocina pequeña de teléfono o tablet, por lo que el espectro de frecuencia de los sonidos debía ser limitado para evitar que los interlocutores se distorsionen. Esto fue todo un desafío. Cuando recibimos los primeros borradores de música de Giorgio, fue un alivio porque su composición funcionaba a la perfección con los sonidos que habíamos creado.

Sonido del motor

El mayor desafío a la hora de programar los sonidos fue encontrar el mejor sonido para el motor y adaptar su comportamiento. La pista de carreras parecía una pista F1 o Nascar, por lo que los autos debían parecer rápidos y explosivos. Al mismo tiempo, los autos eran muy pequeños, por lo que el gran sonido del motor no conectaba realmente el sonido con las imágenes. Como no podíamos reproducir un potente motor en la bocina móvil, tuvimos que descubrir otra cosa.

Para inspirarnos, conectamos algunos de la colección de sintetizadores modulares de Jon Ekstrand de nuestro amigo y comenzamos a jugar. Nos gustó lo que nos conocimos. Así es como sonaba con dos osciladores, algunos filtros agradables y un LFO.

Los equipos analógicos se remodelaron con mucho éxito utilizando la API de Web Audio, por lo que teníamos grandes esperanzas y empezamos a crear un sintetizador simple para Web Audio. Un sonido generado sería el más responsivo, pero afectaría la capacidad de procesamiento del dispositivo. Necesitábamos ser muy eficientes para guardar todos los recursos posibles y que los elementos visuales se ejecutaran sin problemas. Así que cambiamos la técnica para priorizar la reproducción de muestras de audio.

Sintetizador modular para inspirar el sonido del motor

Hay varias técnicas que se pueden usar para hacer que el sonido de un motor se base en muestras. El enfoque más común para los juegos de consola sería tener una capa de varios sonidos (cuantos más, mejor) del motor a diferentes RPM (con carga) y, luego, encadenar y hacer un cambio de tono entre ellos. Luego, agrega una capa de varios sonidos del motor simplemente acelerando (sin carga) a las mismas RPM y también encadenamiento y cambio de tono. El encadenado entre esas capas cuando se cambia de marcha, si se hace correctamente, sonará muy realista, pero solo si tienes una gran cantidad de archivos de sonido. La línea cruzada no puede ser demasiado ancha o sonará muy sintético. Como teníamos que evitar tiempos de carga prolongados, esta opción no era adecuada para nosotros. Probamos con cinco o seis archivos de sonido para cada capa, pero el sonido fue decepcionante. Tuvimos que encontrar una manera con menos archivos.

La solución más eficaz demostró ser la siguiente:

  • Un archivo de sonido con aceleración y cambio de marcha sincronizado con la aceleración visual del automóvil que termina en un bucle programado en el tono más alto / RPM. La API de Web Audio es muy buena para repetir bucles de manera precisa, de modo que podemos hacerlo sin fallas ni saltos.
  • Un archivo de sonido con desaceleración o aceleración del motor.
  • Y, por último, hay un archivo de sonido que reproduce el sonido quieto / inactivo en un bucle.

Parece esto

Gráfico de sonido de motor

Para el primer evento táctil / aceleración, reproduciríamos el primer archivo desde el principio y, si el reproductor liberaba el límite, calculamos el tiempo desde el lugar en el que estábamos en el archivo de sonido al momento de la liberación para que, cuando se activara el acelerador nuevamente, se saltara al lugar correcto en el archivo de aceleración después de que se reprodujera el segundo archivo (reducción de velocidad).

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

Pruébalo

Enciende el motor y presiona el botón "Acelerar".

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

Así que con solo tres archivos de sonido pequeños y un buen motor de sonido, decidimos pasar al siguiente desafío.

Obteniendo la sincronización

Junto con David Lindkvist, de 14islands, empezamos a investigar en profundidad para lograr que los dispositivos se sincronizaran a la perfección. La teoría básica es simple. El dispositivo le solicita la hora al servidor, considera la latencia de la red y luego calcula la desfase del reloj local.

syncOffset = localTime - serverTime - networkLatency

Con este desplazamiento, cada dispositivo conectado comparte el mismo concepto de tiempo. Fácil, ¿verdad? (De nuevo, en teoría).

Cómo calcular la latencia de red

Podemos suponer que la latencia es la mitad del tiempo que lleva solicitar y recibir una respuesta del servidor:

networkLatency = (receivedTime - sentTime) × 0.5

El problema con esta suposición es que el recorrido de ida y vuelta al servidor no siempre es simétrico; es decir, la solicitud puede tardar más que la respuesta o viceversa. Cuanto mayor sea la latencia de la red, mayor impacto tendrá esta asimetría, lo que provocará que los sonidos se retrasen y se dessincronizan con otros dispositivos.

Por suerte, el cerebro está conectado para no notar si los sonidos se retrasan un poco. Los estudios demuestran que el cerebro tarda entre 20 y 30 milisegundos (ms) en percibir los sonidos como separados. Sin embargo, a los 12 a 15 ms comenzarás a “sentir” los efectos de una señal retrasada, incluso si no puede "percibirla" por completo. Investigamos un par de protocolos de sincronización de tiempo establecidos, alternativas más simples e intentamos implementar algunos de ellos en la práctica. Al final, gracias a la infraestructura de baja latencia de Google, pudimos simplemente muestrear una gran cantidad de solicitudes y usar la muestra con la latencia más baja como referencia.

Desvío de reloj de lucha

¡Funcionó! Teníamos más de 5 dispositivos reproduciendo un pulso en perfecta sincronía, pero solo por un tiempo. Después de reproducir durante algunos minutos, los dispositivos se desviaban a pesar de que programamos el sonido utilizando el tiempo preciso del contexto de la Web Audio API. El retraso se acumuló lentamente, de solo un par de milisegundos a la vez y fue indetectable al principio, pero dio como resultado las capas musicales totalmente desincronizadas después de reproducir durante períodos más largos. Di hola, la desviación del reloj.

La solución fue volver a sincronizar cada segundo, calcular una nueva compensación del reloj e incorporarlo sin problemas al programador de audio. Para reducir el riesgo de cambios notables en la música debido a retrasos en la red, decidimos facilitar el cambio manteniendo un historial de las compensaciones de sincronización más recientes y calculando un promedio.

Programar canciones y cambiar arreglos

Crear una experiencia de sonido interactiva significa que ya no tendrás el control de cuándo se reproducirán las partes de la canción, ya que dependerás de las acciones del usuario para cambiar el estado actual. Tuvimos que asegurarnos de que podíamos cambiar los arreglos de la canción de manera oportuna, lo que significa que el programador tenía que poder calcular cuánto queda de la barra que se está reproduciendo antes de cambiar al siguiente arreglo. Al final, nuestro algoritmo se ve más o menos así:

  • Client(1) inicia la canción.
  • Client(n) le pregunta al primer cliente cuándo comenzó la canción.
  • Client(n) calcula un punto de referencia al momento en que se inició la canción mediante su contexto de audio web, teniendo en cuenta syncOffset y el tiempo que ha transcurrido desde que se creó su contexto de audio.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) calcula cuánto tiempo se está reproduciendo la canción con playDelta. El programador de canciones usa esta información para saber qué barra del arreglo actual debe reproducirse a continuación.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

Para mantener la cuerda plena, limitamos nuestros arreglos para que siempre tengan ocho compases y tengan el mismo tempo (pulsaciones por minuto).

Mira hacia adelante

Siempre es importante programar con anticipación cuando uses setTimeout o setInterval en JavaScript. Esto se debe a que el reloj de JavaScript no es muy preciso y las devoluciones de llamada programadas pueden dividirse fácilmente por decenas de milisegundos o más por diseño, renderización, recolección de elementos no utilizados y XMLHTTPRequests. En nuestro caso, también tuvimos que tener en cuenta el tiempo que tardan todos los clientes en recibir el mismo evento a través de la red.

Objetos de audio

Combinar sonidos en un archivo es una excelente manera de reducir las solicitudes HTTP, tanto para audio HTML como para la API de Web Audio. También resulta ser la mejor manera de reproducir sonidos de manera responsiva usando el objeto Audio, ya que no tiene que cargar un nuevo objeto de audio antes de reproducirlo. Ya hay algunas buenas implementaciones que usamos como punto de partida. Ampliamos nuestro objeto para que funcione de forma confiable tanto en iOS como en Android, así como en algunos casos extraños en los que se duermen los dispositivos.

En Android, se siguen reproduciendo los elementos de audio incluso si colocas el dispositivo en modo de suspensión. En el modo de suspensión, la ejecución de JavaScript se limita a preservar la batería y no puedes usar requestAnimationFrame, setInterval ni setTimeout para activar devoluciones de llamada. Este es un problema, ya que los objetos de audio dependen de JavaScript para verificar constantemente si se debe detener la reproducción. Para empeorar la situación, en algunos casos, el currentTime del elemento de Audio no se actualiza aunque el audio aún se esté reproduciendo.

Consulta la implementación de AudioSprite que usamos en Chrome Racer como resguardo de audio no disponible en la Web.

Elemento de audio

Cuando comenzamos a trabajar en Racer, Chrome para Android aún no admitía la API de Web Audio. La lógica de usar audio HTML para algunos dispositivos y la API de Web Audio para otros, combinada con la salida de audio avanzada que queríamos lograr, supuso algunos desafíos interesantes. Afortunadamente, esto es toda historia ahora. La API de Web Audio se implementa en la versión beta de Android M28.

  • Problemas de retrasos o de tiempo. El elemento de audio no siempre se reproduce exactamente cuando le indicas que se reproduzca. Como JavaScript tiene un subproceso único, es posible que el navegador esté ocupado, lo que provoca retrasos en la reproducción de hasta dos segundos.
  • Las demoras en la reproducción indican que no siempre es posible repetir indefinidamente. En las computadoras de escritorio, puedes usar el almacenamiento en búfer doble para lograr bucles sin espacios, pero en dispositivos móviles, esta opción no es posible debido a los siguientes motivos:
    • La mayoría de los dispositivos móviles no reproducen más de un elemento de audio a la vez.
    • Volumen fijo. Ni Android ni iOS permiten cambiar el volumen de un objeto de audio.
  • Sin precarga. En los dispositivos móviles, el elemento de audio no comenzará a cargar su fuente, a menos que la reproducción se inicie en un controlador touchStart.
  • Buscando problemas. La obtención de duration o la configuración de currentTime fallará, a menos que tu servidor admita el rango de bytes HTTP. Ten cuidado con esto si estás creando un objeto de audio como lo hicimos nosotros.
  • La autenticación básica en MP3 falla. Algunos dispositivos no cargan archivos MP3 protegidos por la autenticación básica, independientemente del navegador que estés usando.

Conclusiones

Hemos recorrido un largo camino desde que presionar el botón de silencio es la mejor opción para lidiar con el sonido en la Web, pero este es solo el comienzo y el audio web está a punto de ser duro. Solo exploramos la superficie en lo que respecta a la sincronización de múltiples dispositivos. No teníamos la capacidad de procesamiento de los teléfonos y las tablets para profundizar en el procesamiento de señal y los efectos (como la reverberación), pero a medida que aumente el rendimiento del dispositivo los juegos basados en la Web también aprovecharán esas funciones. Son tiempos emocionantes para seguir impulsando las posibilidades del sonido.