Extensiones de fuentes de medios para audio

Dale Curtis
Dale Curtis

Introducción

Las extensiones de fuente de medios (MSE) proporcionan un almacenamiento en búfer extendido y control de reproducción para los elementos <audio> y <video> HTML5. Si bien se desarrolló originalmente para facilitar la Transmisión adaptable y dinámica a través de reproductores de video basados en HTTP (DASH), a continuación veremos cómo se pueden usar para audio; en particular, en la reproducción sin interrupciones.

Es probable que hayas escuchado un álbum de música en el que las canciones fluían sin interrupciones en las pistas; incluso es posible que estés escuchando uno en este momento. Los artistas crean estas experiencias de reproducción sin interrupciones como una elección artística y como artefacto de discos de vinilo y CDs en los que el audio se escribía como una transmisión continua. Lamentablemente, debido a la forma en que funcionan los códecs de audio modernos, como MP3 y AAC, esta experiencia auditiva fluida se pierde a menudo.

Profundizaremos en los detalles a continuación, pero por ahora comencemos con una demostración. A continuación, se muestran los primeros treinta segundos de la excelente Sintel dividida en cinco archivos MP3 separados y reensamblados con MSE. Las líneas rojas indican las brechas que se introdujeron durante la creación (codificación) de cada MP3; escucharás fallas en esos puntos.

Datos demográficos

¡Qué asco! No es una gran experiencia, podemos hacerlo mejor. Con un poco más de trabajo, con los mismos archivos MP3 de la demostración anterior, podemos usar MSE para quitar esas molestas brechas. Las líneas verdes de la siguiente demostración indican el punto en el que se unieron los archivos y se quitaron los espacios vacíos. En Chrome 38 y versiones posteriores, esto se reproducirá sin problemas.

Datos demográficos

Existen diversas formas de crear contenido sin espacios. Para los fines de esta demostración, nos centraremos en el tipo de archivos que un usuario normal podría tener. Cada archivo se codificó por separado sin tener en cuenta los segmentos de audio antes o después de él.

Configuración básica

Primero, retrocedamos y veamos la configuración básica de una instancia de MediaSource. Las extensiones de fuente de medios, como su nombre lo indica, son solo extensiones de los elementos multimedia existentes. A continuación, asignamos un Object URL, que representa nuestra instancia de MediaSource, al atributo fuente de un elemento de audio, del mismo modo que lo harías con una URL estándar.

var audio = document.createElement('audio');
var mediaSource = new MediaSource();
var SEGMENTS = 5;

mediaSource.addEventListener('sourceopen', function () {
  var sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');

  function onAudioLoaded(data, index) {
    // Append the ArrayBuffer data into our new SourceBuffer.
    sourceBuffer.appendBuffer(data);
  }

  // Retrieve an audio segment via XHR.  For simplicity, we're retrieving the
  // entire segment at once, but we could also retrieve it in chunks and append
  // each chunk separately.  MSE will take care of assembling the pieces.
  GET('sintel/sintel_0.mp3', function (data) {
    onAudioLoaded(data, 0);
  });
});

audio.src = URL.createObjectURL(mediaSource);

Una vez que se conecte el objeto MediaSource, realizará una inicialización y, finalmente, activará un evento sourceopen. En ese momento, podremos crear un SourceBuffer. En el ejemplo anterior, se crea una audio/mpeg, que puede analizar y decodificar nuestros segmentos de MP3. Existen varios otros tipos disponibles.

Formas de onda anómalas

Regresaremos al código en un momento, pero analicemos en detalle el archivo que acabamos de agregar, específicamente al final. A continuación, se muestra un gráfico de las últimas 3,000 muestras promediadas entre ambos canales desde el segmento sintel_0.mp3. Cada píxel de la línea roja es una muestra de punto flotante en el rango de [-1.0, 1.0].

intervalo de mp3

¿Qué hay de todas esas muestras (silenciosas) de cero? En realidad, se deben a los artefactos de compresión que se introducen durante la codificación. Casi todos los codificadores introducen algún tipo de relleno. En este caso, LAME agregó exactamente 576 muestras de padding al final del archivo.

Además del padding al final, cada archivo también tenía padding agregado al principio. Si nos fijamos en el segmento sintel_1.mp3, veremos otras 576 muestras de padding al frente. La cantidad de relleno varía según el codificador y el contenido, pero conocemos los valores exactos según metadata incluido en cada archivo.

fin de la brecha de mp3

Las secciones de silencio al principio y al final de cada archivo son las que causan las fallas entre los segmentos en la demostración anterior. Para lograr una reproducción sin espacios, debemos quitar estas secciones de silencio. Afortunadamente, esto se puede hacer fácilmente con MediaSource. A continuación, modificaremos nuestro método onAudioLoaded() para usar una ventana de agregado y un desplazamiento de marca de tiempo a fin de quitar este silencio.

Código de ejemplo

function onAudioLoaded(data, index) {
  // Parsing gapless metadata is unfortunately non trivial and a bit messy, so
  // we'll glaze over it here; see the appendix for details.
  // ParseGaplessData() will return a dictionary with two elements:
  //
  //    audioDuration: Duration in seconds of all non-padding audio.
  //    frontPaddingDuration: Duration in seconds of the front padding.
  //
  var gaplessMetadata = ParseGaplessData(data);

  // Each appended segment must be appended relative to the next.  To avoid any
  // overlaps, we'll use the end timestamp of the last append as the starting
  // point for our next append or zero if we haven't appended anything yet.
  var appendTime = index > 0 ? sourceBuffer.buffered.end(0) : 0;

  // Simply put, an append window allows you to trim off audio (or video) frames
  // which fall outside of a specified time range.  Here, we'll use the end of
  // our last append as the start of our append window and the end of the real
  // audio data for this segment as the end of our append window.
  sourceBuffer.appendWindowStart = appendTime;
  sourceBuffer.appendWindowEnd = appendTime + gaplessMetadata.audioDuration;

  // The timestampOffset field essentially tells MediaSource where in the media
  // timeline the data given to appendBuffer() should be placed.  I.e., if the
  // timestampOffset is 1 second, the appended data will start 1 second into
  // playback.
  //
  // MediaSource requires that the media timeline starts from time zero, so we
  // need to ensure that the data left after filtering by the append window
  // starts at time zero.  We'll do this by shifting all of the padding we want
  // to discard before our append time (and thus, before our append window).
  sourceBuffer.timestampOffset =
    appendTime - gaplessMetadata.frontPaddingDuration;

  // When appendBuffer() completes, it will fire an updateend event signaling
  // that it's okay to append another segment of media.  Here, we'll chain the
  // append for the next segment to the completion of our current append.
  if (index == 0) {
    sourceBuffer.addEventListener('updateend', function () {
      if (++index < SEGMENTS) {
        GET('sintel/sintel_' + index + '.mp3', function (data) {
          onAudioLoaded(data, index);
        });
      } else {
        // We've loaded all available segments, so tell MediaSource there are no
        // more buffers which will be appended.
        mediaSource.endOfStream();
        URL.revokeObjectURL(audio.src);
      }
    });
  }

  // appendBuffer() will now use the timestamp offset and append window settings
  // to filter and timestamp the data we're appending.
  //
  // Note: While this demo uses very little memory, more complex use cases need
  // to be careful about memory usage or garbage collection may remove ranges of
  // media in unexpected places.
  sourceBuffer.appendBuffer(data);
}

Una forma de onda fluida

Veamos lo que logró nuestro nuevo código reluciente echando un vistazo a la forma de onda después de aplicar nuestras ventanas de anexo. A continuación, puedes ver que se quitaron la sección silenciosa al final de sintel_0.mp3 (en rojo) y la sección silenciosa al comienzo de sintel_1.mp3 (en azul), lo que nos da una transición fluida entre los segmentos.

mp3 medio

Conclusión

Con eso, unimos los cinco segmentos fácilmente en uno y, luego, llegamos al final de nuestra demostración. Antes de terminar, quizás hayas notado que nuestro método onAudioLoaded() no tiene en cuenta los contenedores ni los códecs. Esto significa que todas estas técnicas funcionarán independientemente del tipo de contenedor o códec. A continuación, puedes volver a reproducir la demostración original de MP4 fragmentado y compatible con DASH, en lugar de MP3.

Datos demográficos

Si deseas obtener más información, consulta los apéndices que aparecen a continuación para obtener un análisis más detallado de la creación de contenido sin espacios y el análisis de metadatos. También puedes explorar gapless.js para ver con más detalle el código que impulsa esta demostración.

¡Gracias por leer esta información!

Apéndice A: Crea contenido sin espacios

Crear contenido sin espacios puede ser difícil de hacer bien. A continuación, analizaremos la creación del medio de Sintel que se usó en esta demostración. Para comenzar, necesitarás una copia de la banda sonora FLAC sin pérdida para Sintel. Para la posteridad, la SHA1 se incluye a continuación. Para las herramientas, necesitarás FFmpeg, MP4Box, LAME y una instalación de OSX con afconvert.

    unzip Jan_Morgenstern-Sintel-FLAC.zip
    sha1sum 1-Snow_Fight.flac
    # 0535ca207ccba70d538f7324916a3f1a3d550194  1-Snow_Fight.flac

Primero, dividiremos los primeros 31.5 segundos de la pista 1-Snow_Fight.flac. También queremos agregar un fundido de salida de 2.5 segundos a partir de los 28 segundos para evitar cualquier clic una vez que finalice la reproducción. Con la línea de comandos FFmpeg que se muestra a continuación, podemos lograr todo esto y colocar los resultados en sintel.flac.

    ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac

A continuación, dividiremos el archivo en 5 archivos wave de 6.5 segundos cada uno. Es más fácil usar un conjunto, ya que casi todos los codificadores admiten la transferencia del archivo. Una vez más, podemos hacer esto precisamente con FFmpeg, después de lo cual tendremos: sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav y sintel_4.wav.

    ffmpeg -i sintel.flac -acodec pcm_f32le -map 0 -f segment \
           -segment_list out.list -segment_time 6.5 sintel_%d.wav

A continuación, creemos los archivos MP3. LAME tiene varias opciones para crear contenido sin espacios. Si tienes el control del contenido, puedes usar --nogap con una codificación por lotes de todos los archivos para evitar el padding entre los segmentos por completo. Sin embargo, para los fines de esta demostración, queremos ese relleno, por lo que usaremos una codificación VBR estándar de alta calidad de los archivos Wave.

    lame -V=2 sintel_0.wav sintel_0.mp3
    lame -V=2 sintel_1.wav sintel_1.mp3
    lame -V=2 sintel_2.wav sintel_2.mp3
    lame -V=2 sintel_3.wav sintel_3.mp3
    lame -V=2 sintel_4.wav sintel_4.mp3

Eso es todo lo que se necesita para crear los archivos MP3. Ahora vamos a hablar sobre la creación de archivos MP4 fragmentados. Seguiremos las instrucciones de Apple para crear contenido multimedia que esté masterizado para iTunes. A continuación, convertiremos los archivos de conjuntos en archivos CAF intermedios, según las instrucciones, antes de codificarlos como AAC en un contenedor MP4 con los parámetros recomendados.

    afconvert sintel_0.wav sintel_0_intermediate.caf -d 0 -f caff \
              --soundcheck-generate
    afconvert sintel_1.wav sintel_1_intermediate.caf -d 0 -f caff \
              --soundcheck-generate
    afconvert sintel_2.wav sintel_2_intermediate.caf -d 0 -f caff \
              --soundcheck-generate
    afconvert sintel_3.wav sintel_3_intermediate.caf -d 0 -f caff \
              --soundcheck-generate
    afconvert sintel_4.wav sintel_4_intermediate.caf -d 0 -f caff \
              --soundcheck-generate
    afconvert sintel_0_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
              -b 256000 -q 127 -s 2 sintel_0.m4a
    afconvert sintel_1_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
              -b 256000 -q 127 -s 2 sintel_1.m4a
    afconvert sintel_2_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
              -b 256000 -q 127 -s 2 sintel_2.m4a
    afconvert sintel_3_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
              -b 256000 -q 127 -s 2 sintel_3.m4a
    afconvert sintel_4_intermediate.caf -d aac -f m4af -u pgcm 2 --soundcheck-read \
              -b 256000 -q 127 -s 2 sintel_4.m4a

Ahora tenemos varios archivos M4A que debemos fragmentar de forma adecuada antes de que puedan usarse con MediaSource. Para nuestros fines, usaremos un tamaño de fragmento de un segundo. MP4Box escribirá cada MP4 fragmentado como sintel_#_dashinit.mp4 junto con un manifiesto de MPEG-DASH (sintel_#_dash.mpd) que se puede descartar.

    MP4Box -dash 1000 sintel_0.m4a && mv sintel_0_dashinit.mp4 sintel_0.mp4
    MP4Box -dash 1000 sintel_1.m4a && mv sintel_1_dashinit.mp4 sintel_1.mp4
    MP4Box -dash 1000 sintel_2.m4a && mv sintel_2_dashinit.mp4 sintel_2.mp4
    MP4Box -dash 1000 sintel_3.m4a && mv sintel_3_dashinit.mp4 sintel_3.mp4
    MP4Box -dash 1000 sintel_4.m4a && mv sintel_4_dashinit.mp4 sintel_4.mp4
    rm sintel_{0,1,2,3,4}_dash.mpd

Listo. Ahora tenemos archivos MP4 y MP3 fragmentados con los metadatos correctos necesarios para una reproducción sin interrupciones. Consulta el Apéndice B para obtener más detalles sobre cómo son esos metadatos.

Apéndice B: Análisis de metadatos sin brechas

Al igual que cuando creas contenido sin espacios, analizar los metadatos sin espacios puede ser complicado, ya que no hay un método estándar para el almacenamiento. A continuación, veremos cómo los dos codificadores más comunes, LAME y iTunes, almacenan sus metadatos sin espacios. Para comenzar, configura algunos métodos auxiliares y un esquema del ParseGaplessData() que se usó anteriormente.

    // Since most MP3 encoders store the gapless metadata in binary, we'll need a
    // method for turning bytes into integers.  Note: This doesn't work for values
    // larger than 2^30 since we'll overflow the signed integer type when shifting.
    function ReadInt(buffer) {
      var result = buffer.charCodeAt(0);
      for (var i = 1; i < buffer.length; ++i) {
        result <<= 8;
        result += buffer.charCodeAt(i);
      }
      return result;
    }

    function ParseGaplessData(arrayBuffer) {
      // Gapless data is generally within the first 512 bytes, so limit parsing.
      var byteStr = new TextDecoder().decode(arrayBuffer.slice(0, 512));

      var frontPadding = 0, endPadding = 0, realSamples = 0;

      // ... we'll fill this in as we go below.

En primer lugar, hablaremos sobre el formato de metadatos de iTunes de Apple, ya que es el más fácil de analizar y explicar. Dentro de los archivos MP3 y M4A, iTunes (y afconvert) escribe una sección corta en ASCII de la siguiente manera:

    iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Esto se escribe dentro de una etiqueta ID3 dentro del contenedor de MP3 y en un átomo de metadatos dentro del contenedor de MP4. Para nuestros fines, podemos ignorar el primer token 0000000. Los siguientes tres tokens son el padding frontal, el padding final y el recuento total de muestras sin padding. Dividir cada uno de estos por la tasa de muestreo del audio nos da la duración de cada uno.

// iTunes encodes the gapless data as hex strings like so:
//
//    'iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00'
//    'iTunSMPB[ 26 bytes ]####### frontpad  endpad    real samples'
//
// The approach here elides the complexity of actually parsing MP4 atoms. It
// may not work for all files without some tweaks.
var iTunesDataIndex = byteStr.indexOf('iTunSMPB');
if (iTunesDataIndex != -1) {
  var frontPaddingIndex = iTunesDataIndex + 34;
  frontPadding = parseInt(byteStr.substr(frontPaddingIndex, 8), 16);

  var endPaddingIndex = frontPaddingIndex + 9;
  endPadding = parseInt(byteStr.substr(endPaddingIndex, 8), 16);

  var sampleCountIndex = endPaddingIndex + 9;
  realSamples = parseInt(byteStr.substr(sampleCountIndex, 16), 16);
}

Por otra parte, la mayoría de los codificadores MP3 de código abierto almacenan los metadatos sin espacios dentro de un encabezado Xing especial ubicado dentro de un marco MPEG silencioso (es silencioso, por lo que los decodificadores que no entienden el encabezado Xing simplemente reproducen silencio). Lamentablemente, esta etiqueta no siempre está presente y tiene varios campos opcionales. Para los fines de esta demostración, tenemos el control de los medios de comunicación, pero en la práctica, se requerirán algunas verificaciones de sensibilidad adicionales para saber cuándo están disponibles los metadatos sin espacios.

Primero, analizaremos el recuento total de muestras. Para simplificar, leeremos esto desde el encabezado Xing, pero se podría construir a partir del encabezado de audio MPEG normal. Los encabezados Xing se pueden marcar con una etiqueta Xing o Info. Exactamente a 4 bytes después de esta etiqueta hay 32 bits que representan la cantidad total de marcos en el archivo. Si se multiplica este valor por la cantidad de muestras por marco, se obtendrán las muestras totales en el archivo.

    // Xing padding is encoded as 24bits within the header.  Note: This code will
    // only work for Layer3 Version 1 and Layer2 MP3 files with XING frame counts
    // and gapless information.  See the following document for more details:
    // http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header
    var xingDataIndex = byteStr.indexOf('Xing');
    if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Info');
    if (xingDataIndex != -1) {
      // See section 2.3.1 in the link above for the specifics on parsing the Xing
      // frame count.
      var frameCountIndex = xingDataIndex + 8;
      var frameCount = ReadInt(byteStr.substr(frameCountIndex, 4));

      // For Layer3 Version 1 and Layer2 there are 1152 samples per frame.  See
      // section 2.1.5 in the link above for more details.
      var paddedSamples = frameCount * 1152;

      // ... we'll cover this below.

Ahora que tenemos la cantidad total de muestras, podemos pasar a leer la cantidad de muestras de padding. Según tu codificador, esto se puede escribir en una etiqueta LAME o Lavf anidada en el encabezado Xing. Exactamente en 17 bytes después de este encabezado hay 3 bytes que representan el padding del frontend y el final en 12 bits cada uno, respectivamente.

        xingDataIndex = byteStr.indexOf('LAME');
        if (xingDataIndex == -1) xingDataIndex = byteStr.indexOf('Lavf');
        if (xingDataIndex != -1) {
          // See http://gabriel.mp3-tech.org/mp3infotag.html#delays for details of
          // how this information is encoded and parsed.
          var gaplessDataIndex = xingDataIndex + 21;
          var gaplessBits = ReadInt(byteStr.substr(gaplessDataIndex, 3));

          // Upper 12 bits are the front padding, lower are the end padding.
          frontPadding = gaplessBits >> 12;
          endPadding = gaplessBits & 0xFFF;
        }

        realSamples = paddedSamples - (frontPadding + endPadding);
      }

      return {
        audioDuration: realSamples * SECONDS_PER_SAMPLE,
        frontPaddingDuration: frontPadding * SECONDS_PER_SAMPLE
      };
    }

Con eso, tenemos una función completa para analizar la gran mayoría del contenido sin espacios. Sin embargo, es cierto que abundan los casos extremos, por lo que se recomienda tener precaución antes de usar código similar en producción.

Apéndice C: De la recolección de elementos no utilizados

La memoria que pertenece a las instancias de SourceBuffer se recopila de manera activa según el tipo de contenido, los límites específicos de la plataforma y la posición de reproducción actual. En Chrome, primero se recuperará la memoria de los búferes ya reproducidos. Sin embargo, si el uso de memoria excede los límites específicos de la plataforma, se quitará la memoria de los búferes no reproducidos.

Cuando la reproducción alcanza un intervalo en la línea de tiempo debido a la memoria recuperada, es posible que falle si el intervalo es lo suficientemente pequeño o que se detenga por completo si es demasiado grande. Tampoco es una buena experiencia del usuario, por lo que es importante evitar agregar demasiados datos a la vez y quitar de forma manual los rangos del cronograma de contenido multimedia que ya no son necesarios.

Los rangos se pueden quitar mediante el método remove() en cada SourceBuffer, lo que tarda un rango de [start, end] en segundos. De manera similar a appendBuffer(), cada remove() activará un evento updateend una vez que se complete. No se deben emitir otras eliminaciones ni anexos hasta que se active el evento.

En la versión de Chrome para computadoras de escritorio, puedes guardar aproximadamente 12 megabytes de contenido de audio y 150 megabytes de contenido de video en la memoria a la vez. No debes confiar en estos valores en los distintos navegadores o plataformas; p.ej., sin duda no son representativos de los dispositivos móviles.

La recolección de elementos no utilizados solo afecta a los datos que se agregan a SourceBuffers. No hay límites sobre la cantidad de datos que puedes mantener en búfer en las variables de JavaScript. También puedes volver a agregar los mismos datos en la misma posición si es necesario.

Comentarios