Caso de éxito: A Tale of an HTML5 Game with Web Audio

Corredores de campo

Captura de pantalla de Fieldrunners
Captura de pantalla de Fieldrunners

Fieldrunners es un juego galardonado con estilo de defensa de torres que se lanzó originalmente para iPhone en 2008. Desde entonces, se trasladó a muchas otras plataformas. Una de las plataformas más recientes fue el navegador Chrome en octubre de 2011. Uno de los desafíos de portar los corredores de campo a una plataforma HTML5 era cómo reproducir sonido.

Fieldrunners no hace un uso complicado de los efectos de sonido, pero tiene algunas expectativas sobre cómo interactuar con sus efectos de sonido. El juego tiene 88 efectos de sonido, de los cuales se puede esperar que se reproduzca una gran cantidad de ellos al mismo tiempo. La mayoría de estos sonidos son muy cortos y deben tocarse de la manera más oportuna posible para evitar que se generen desconexiones con la presentación gráfica.

Aparecieron algunos desafíos

Al transferir Fieldrunners a HTML5, encontramos problemas con la reproducción de audio con la etiqueta Audio y, desde el principio, decidimos centrarnos en la API de Web Audio en su lugar. El uso de WebAudio nos ayudó a resolver problemas como proporcionarnos la gran cantidad de efectos simultáneos de reproducción que requiere Fieldrunners. No obstante, mientras desarrollamos un sistema de audio para Fieldrunners HTML5, encontramos algunos problemas matizados que otros desarrolladores podrían querer conocer.

Naturaleza de AudioBufferSourceNodes

AudioBufferSourceNodes es el método principal para reproducir sonidos con WebAudio. Es muy importante entender que son un objeto de uso único. Se crea un objeto AudioBufferSourceNode, se le asigna un búfer, se conecta al gráfico y se reproduce con noteOn o noteGrainOn. Luego, podrás llamar a noteOff para detener la reproducción, pero no podrás reproducir la fuente nuevamente llamando a noteOn o noteGrainOn. Deberás crear otro AudioBufferSourceNode. Puedes reutilizar el mismo objeto AudioBuffer subyacente (de hecho, incluso puedes tener varios AudioBufferSourceNodes activos que apunten a la misma instancia de AudioBuffer). Puedes encontrar un fragmento de reproducción de Fieldrunners en Dame un ritmo.

Contenido que no se almacena en caché

En el lanzamiento, el servidor HTML5 de Fieldrunners mostraba una cantidad masiva de solicitudes de archivos de música. Este resultado surgió del proceso de Chrome 15 en el que se descargaba el archivo en fragmentos y, luego, no se almacenaba en caché. En respuesta al momento, decidimos cargar archivos de música como el resto de nuestros archivos de audio. Esto no es óptimo, pero algunas versiones de otros navegadores aún lo hacen.

Se silenciará cuando esté fuera de foco

Anteriormente era difícil detectar cuándo la pestaña de tu juego estaba fuera de foco. Fieldrunners comenzó a migrar antes de Chrome 13, en el que la API de visibilidad de páginas reemplazó la necesidad de nuestro código complicado para detectar el desenfoque de pestañas. Todos los juegos deberían usar la API de Visibility para escribir un pequeño fragmento y silenciar o pausar el sonido si no se pausa todo el juego. Dado que Fieldrunners usó la API de requestAnimationFrame, la pausa del juego se controló de forma implícita, pero no la pausa sonora.

Pausando sonidos

Curiosamente, cuando recibimos comentarios sobre este artículo, se nos informó que la técnica que utilizábamos para pausar sonidos no era apropiada, ya que estábamos utilizando un error en la implementación actual de Web Audio para pausar la reproducción de sonidos. Debido a que esto se solucionará en el futuro, no puedes pausar el sonido desconectando un nodo o subgrafo para detener la reproducción.

Una arquitectura simple de nodo de audio web

Fieldrunners tiene un modelo de audio muy simple. Ese modelo puede admitir el siguiente conjunto de atributos:

  • Controlar el volumen de los efectos de sonido.
  • Controlar el volumen de la pista de música en segundo plano.
  • Silenciar todo el audio.
  • Desactiva los sonidos de reproducción cuando el juego esté pausado.
  • Vuelve a activar esos mismos sonidos cuando se reanude el juego.
  • Cuando la pestaña del juego pierda el foco, se desactiva todo el audio.
  • Reinicia la reproducción después de que se reproduzca un sonido según sea necesario.

Para lograr las funciones anteriores con Web Audio, se utilizaron 3 de los nodos posibles proporcionados: DestinationNode, GainNode, AudioBufferSourceNode. Los AudioBufferSourceNodes reproducen los sonidos. Los GainNodes conectan los AudioBufferSourceNodes. El DestinationNode, creado por el contexto de Web Audio, llamado "destination", reproduce sonidos para el reproductor. Web Audio tiene muchos más tipos de nodos, pero solo con ellos podemos crear un gráfico muy simple para los sonidos en un juego.

Gráfico del nodo

Un gráfico de nodos de audio web dirige desde los nodos hoja al nodo de destino. Los corredores de campo usaron 6 nodos de ganancia permanentes, pero 3 son suficientes para permitir un control sencillo del volumen y conectar una mayor cantidad de nodos temporales que reproducirán búferes. Primero, un nodo de ganancia principal que adjunta cada nodo secundario al destino. Inmediatamente conectados al nodo de ganancia principal hay dos nodos de ganancia, uno para un canal de música y otro para vincular todos los efectos de sonido.

Los corredores de campo tuvieron 3 nodos de ganancia adicionales debido al uso incorrecto de un error como función. Usamos esos nodos para recortar grupos de sonidos en reproducción del gráfico, lo que detiene su progreso. Lo hicimos para pausar los sonidos. Como no es correcto, ahora solo usaríamos un total de 3 nodos de ganancia, como se describió anteriormente. Muchos de los siguientes fragmentos incluirán nuestros nodos incorrectos y mostrarán lo que hicimos y cómo podríamos solucionarlo a corto plazo. Pero a largo plazo es mejor no usar los nodos después de nuestro nodo coreEffectsGain.

function AudioManager() {
  // map for loaded sounds
  this.sounds = {};

  // create our permanent nodes
  this.nodes = {
    destination: this.audioContext.destination,
    masterGain: this.audioContext.createGain(),

    backgroundMusicGain: this.audioContext.createGain(),

    coreEffectsGain: this.audioContext.createGain(),
    effectsGain: this.audioContext.createGain(),
    pausedEffectsGain: this.audioContext.createGain()
  };

  // and setup the graph
  this.nodes.masterGain.connect( this.nodes.destination );

  this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );

  this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
  this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}

La mayoría de los juegos permiten controlar los efectos de sonido y la música por separado. Esto se puede lograr fácilmente con el gráfico anterior. Cada nodo de ganancia tiene un atributo “ganancia” que se puede establecer en cualquier valor decimal entre 0 y 1, que se puede usar para controlar el volumen. Como queremos controlar el volumen de los canales de música y efectos de sonido por separado, tenemos un nodo de ganancia para cada uno en el que podemos controlar su volumen.

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

Podemos usar esta misma habilidad para controlar el volumen de todo, de los efectos de sonido y de la música. Configurar la ganancia del nodo principal afectará todo el sonido del juego. Si estableces el valor de ganancia en 0, se silenciarán el sonido y la música. Los AudioBufferSourceNodes también tienen un parámetro de ganancia. Puedes realizar el seguimiento de una lista de todos los sonidos en reproducción y ajustar los valores de ganancia de forma individual para el volumen general. Si crearas efectos de sonido con etiquetas de audio, esto es lo que tendrías que hacer. En cambio, el gráfico de nodos de Web Audio facilita la modificación del volumen de sonido de innumerables sonidos. Controlar el volumen de esta manera también te proporciona energía adicional sin complicaciones. Se podría simplemente conectar un AudioBufferSourceNode directamente al nodo principal para reproducir música y controlar su propia ganancia. Sin embargo, tendrías que establecer este valor cada vez que crees un objeto AudioBufferSourceNode para reproducir música. En su lugar, cambia un nodo solo cuando un reproductor cambia el volumen de la música y cuando se inicia. Ahora, tenemos un valor de ganancia en las fuentes de búfer para hacer algo más. En el caso de la música, un uso común puede ser crear un fundido cruzado de una pista de audio a otra a medida que una se va y llega otra. Web Audio proporciona un buen método para realizar esta tarea con facilidad.

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

Los corredores de campo no hicieron un uso específico del encadenado. Si hubiéramos conocido la funcionalidad de configuración de valores de WebAudio durante el pase original del sistema de sonido, probablemente tendríamos.

Pausando sonidos

Cuando un jugador pausa un juego, es posible que se reproduzcan algunos sonidos. El sonido es una gran parte de la retroalimentación para la pulsación común de los elementos de la interfaz de usuario en los menús del juego. Como Fieldrunners tiene varias interfaces con las que el usuario puede interactuar mientras el juego está pausado, queremos que sigan jugando. Sin embargo, no queremos que se sigan reproduciendo sonidos largos o que se repitan. Es bastante fácil detener esos sonidos con Web Audio o al menos lo creíamos.

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

El nodo de efectos pausado sigue conectado. Todos los sonidos que tengan permitido ignorar la pausa del juego seguirán reproduciéndose. Cuando se reanude el juego, podremos volver a conectar los nodos y hacer que todo el sonido se reproduzca de nuevo al instante.

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

Después de enviar los Fieldrunners, descubrimos que desconectar un nodo o subgrafo solo no pausará la reproducción de AudioBufferSourceNodes. De hecho, aprovechamos un error en WebAudio que actualmente detiene la reproducción de nodos que no están conectados al nodo de destino en el gráfico. Por lo tanto, para asegurarnos de que esté todo listo para esa corrección futura, necesitamos código como el siguiente:

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();

  var now = Date.now();
  for ( var name in this.sounds ) {
    var sound = this.sounds[ name ];

    if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
      sound.pausedAt = now - sound.source.noteOnAt;
      sound.source.noteOff();
    }
  }
}

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );

  var now = Date.now();
  for ( var name in this.sounds ) {
    if ( sound.pausedAt ) {
      this.play( sound.name );
      delete sound.pausedAt;
    }
  }
};

Si hubiéramos sabido esto antes, que estamos abusando de un error, la estructura de nuestro código de audio sería muy diferente. Por lo tanto, esto afectó varias secciones de este artículo. Tiene un efecto directo aquí, pero también en nuestros fragmentos de código en Losing Focus y Give Me a Beat. Saber cómo funciona esto en realidad requiere cambios en el gráfico de nodos de Fieldrunners (ya que creamos nodos para acortar la reproducción) y el código adicional que grabará y proporcionará los estados de pausa que el audio web no hace por su cuenta.

Pérdida de la concentración

Nuestro nodo principal entra en juego para esta función. Cuando un usuario del navegador cambia a otra pestaña, el juego deja de estar visible. Fuera de la vista y de la mente, y también debería haber desaparecido el sonido. Existen trucos que se pueden realizar para determinar estados de visibilidad específicos de la página de un juego, pero esta API es mucho más sencilla.

Los corredores de campo solo jugarán como la pestaña activa gracias al uso de requestAnimationFrame para llamar a su bucle de actualización. Sin embargo, el contexto del audio web seguirá reproduciendo efectos en bucle y pistas en segundo plano mientras el usuario se encuentre en otra pestaña. Pero podemos detenerlo con un fragmento muy pequeño con reconocimiento de la API de Visibility.

function AudioManager() {
  // map and node setup
  // ...

  // disable all sound when on other tabs
  var self = this;
  window.addEventListener( 'webkitvisibilitychange', function( e ) {
    if ( document.webkitHidden ) {
      self.nodes.masterGain.disconnect();

      // As noted in Pausing Sounds disconnecting isn't enough.
      // For Fieldrunners calling our new pauseEffects method would be
      // enough to accomplish that, though we may still need some logic
      // to not resume if already paused.
      self.pauseEffects();
    } else {
      self.nodes.masterGain.connect( this.nodes.destination );
      self.resumeEffects();
    }
  });
}

Antes de escribir este artículo, pensábamos que desconectar la instancia principal iba a ser suficiente para pausar todo el sonido en lugar de silenciarlo. Cuando desconectamos el nodo en ese momento, detuvimos el procesamiento y la reproducción de él y de sus elementos secundarios. Cuando se reconectó, todos los sonidos y la música comenzaban a reproducirse desde donde lo habían dejado al mismo tiempo que el juego continuaba desde donde lo había dejado. Pero este es un comportamiento inesperado. No basta con desconectarte para detener la reproducción.

La API de visibilidad de la página te permite saber con facilidad cuándo tu pestaña ya no está en primer plano. Si ya tienes un código eficaz para pausar los sonidos, solo se necesitan unas pocas líneas para escribir en la pausa de sonido cuando la pestaña de juegos está oculta.

Dame un ritmo

Ya tenemos algunos elementos configurados. Tenemos un gráfico de nodos. Podemos pausar sonidos cuando el jugador pausa el juego y reproducir sonidos nuevos para elementos como los menús del juego. Podemos pausar todo el sonido y la música cuando el usuario cambie a una pestaña nueva. Ahora tenemos que reproducir un sonido.

En lugar de reproducir varias copias del sonido para varias instancias de una entidad del juego, como la muerte de un personaje, Fieldrunners reproduce un sonido solo una vez durante su duración. Si necesitas el sonido después de terminar la reproducción, puedes reiniciarlo, pero no mientras se esté reproduciendo. Esta es una decisión para el diseño de audio de Fieldrunners, ya que tiene sonidos que se solicita que se reproduzcan rápidamente y que, de otro modo, se entrecortan si se permite reiniciar o crean una cacofonía desagradable si se permite reproducir varias instancias. Se espera que AudioBufferSourceNodes se use como un único intento. Crea un nodo, adjunta un búfer, configura un valor booleano de bucle si es necesario, conéctate a un nodo en el gráfico que llevará al destino, llama a noteOn o noteGrainOn y, de manera opcional, a noteOff.

En el caso de Fieldrunners, se verá de la siguiente manera:

AudioManager.prototype.play = function( options ) {
  var now = Date.now(),
    // pull from a map of loaded audio buffers
    sound = this.sounds[ options.name ],
    channel,
    source,
    resumeSource;

  if ( !sound ) {
    return;
  }

  if ( sound.source ) {
    var source = sound.source;
    if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
      // discard the previous source node
      source.stop( 0 );
      source.disconnect();
    } else {
      return;
    }
  }

  source = this.audioContext.createBufferSource();
  sound.source = source;
  // track when the source is started to know if it should still be playing
  source.noteOnAt = now;

  // help with pausing
  sound.ignorePause = !!options.ignorePause;

  if ( options.ignorePause ) {
    channel = this.nodes.pausedEffectsGain;
  } else {
    channel = this.nodes.effectsGain;
  }

  source.buffer = sound.buffer;
  source.connect( channel );
  source.loop = options.loop || false;

  // Fieldrunners' current code doesn't consider sound.pausedAt.
  // This is an added section to assist the new pausing code.
  if ( sound.pausedAt ) {
    source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
    source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;

    // if you needed to precisely stop sounds, you'd want to store this
    resumeSource = this.audioContext.createBufferSource();
    resumeSource.buffer = sound.buffer;
    resumeSource.connect( channel );
    resumeSource.start(
      0,
      sound.pausedAt,
      sound.buffer.duration - sound.pausedAt / 1000
    );
  } else {
    // start play immediately with a value of 0 or less
    source.start( 0 );
  }
}

Demasiadas transmisiones

En un principio, Fieldrunners se lanzó con música de fondo reproducida con una etiqueta de audio. En el lanzamiento, descubrimos que los archivos de música se solicitaban una cantidad desproporcionada de veces en comparación con el resto del contenido del juego. Luego de algunas investigaciones, descubrimos que en ese momento el navegador Chrome no almacenaba en caché los fragmentos de los archivos de música transmitidos. Como resultado, el navegador solicitaba la pista en reproducción cada pocos minutos mientras terminaba. En pruebas más recientes, Chrome almacena en caché las pistas transmitidas, pero es posible que otros navegadores aún no lo estén haciendo. La transmisión de archivos de audio grandes con la etiqueta de audio es óptima para funciones como la reproducción de música, pero, en algunas versiones de navegador, es posible que desees cargar la música de la misma manera que cargas los efectos de sonido.

Como todos los efectos de sonido se reproducían a través de Web Audio, también pasamos la reproducción de la música de fondo al Web Audio. Esto significa que se cargarían las pistas de la misma manera que cargamos todos los efectos con XMLHttpRequests y el tipo de respuesta del búfer de array.

AudioManager.prototype.load = function( options ) {
  var xhr,
      // pull from a map of name, object pairs
      sound = this.sounds[ options.name ];

  if ( sound ) {
    // this is a great spot to add success methods to a list or use promises
    // for handling the load event or call success if already loaded
    if ( sound.buffer && options.success ) {
      options.success( options.name );
    } else if ( options.success ) {
      sound.success.push( options.success );
    }

    // one buffer is enough so shortcut here
    return;
  }

  sound = {
    name: options.name,
    buffer: null,
    source: null,
    success: ( options.success ? [ options.success ] : [] )
  };
  this.sounds[ options.name ] = sound;

  xhr = new XMLHttpRequest();
  xhr.open( 'GET', options.path, true );
  xhr.responseType = 'arraybuffer';
  xhr.onload = function( e ) {
    sound.buffer = self._context.createBuffer( xhr.response, false );

    // call all waiting handlers
    sound.success.forEach( function( success ) {
      success( sound.name );
    });
    delete sound.success;
  };
  xhr.onerror = function( e ) {

    // failures are uncommon but you want to do deal with them

  };
  xhr.send();
}

Resumen

Fieldrunners fue muy divertido en Chrome y HTML5. Más allá de su propia montaña de trabajo que lleva a cabo miles de líneas de C++ en JavaScript, surgen algunos dilemas y decisiones interesantes específicas de HTML5. Para reiterar uno si ninguno de los otros, AudioBufferSourceNodes son objetos de uso único. Créalas, adjunta un búfer de audio, conéctalo al gráfico de audio web y reproduce contenido con noteOn o noteGrainOn. ¿Necesitas volver a reproducir ese sonido? Luego, crea otro AudioBufferSourceNode.