Extensiones de fuentes de medios para audio

Dale Curtis
Dale Curtis

Introducción

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

Es probable que hayas escuchado un álbum de música en el que las canciones fluían sin problemas entre los segmentos. Incluso es posible que estés escuchando uno en este momento. Los artistas crean estas experiencias de reproducción sin interrupciones como una opción artística y un artefacto de los vinilos y los 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 suele perderse hoy en día.

A continuación, analizaremos los motivos, pero por ahora comencemos con una demostración. A continuación, se muestran los primeros treinta segundos de la excelente película Sintel, divididos en cinco archivos MP3 separados y vueltos a ensamblar 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 estos puntos.

Datos demográficos

¡Qué asco! Esa no es una buena experiencia, podemos hacer mejor. Con un poco más de trabajo, usando los mismos archivos MP3 de la demostración anterior, podemos usar MSE para quitar esos molestos espacios. Las líneas verdes de la siguiente demostración indican dónde se unieron los archivos y se quitaron los espacios. En Chrome 38 y versiones posteriores, se reproducirá sin problemas.

Datos demográficos

Existen varias formas de crear contenido sin interrupciones. A los efectos de esta demostración, nos enfocaremos en el tipo de archivos que un usuario normal podría tener. En el que cada archivo se codificó por separado sin tener en cuenta los segmentos de audio anteriores o posteriores.

Configuración básica

Primero, volvamos atrás y repasemos 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 source de un elemento de audio, tal como 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, creamos uno audio/mpeg, que puede analizar y decodificar nuestros segmentos MP3. Hay varios otros tipos disponibles.

Formas de onda anómalas

Volveremos al código en un momento, pero ahora analicemos con más detalle el archivo que acabamos de agregar, específicamente al final. A continuación, se muestra un gráfico de los últimos 3,000 muestras promediadas en ambos canales de la pista 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].

Final de sintel_0.mp3

¿Qué sucede con todas esas muestras de cero (silenciosas)? 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 padding. 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 avanzamos en la pista sintel_1.mp3, veremos que hay otros 576 muestras de padding al principio. La cantidad de relleno varía según el codificador y el contenido, pero conocemos los valores exactos en función de metadata incluido en cada archivo.

Inicio de sintel_1.mp3

Inicio de sintel_1.mp3

Las secciones de silencio al principio y al final de cada archivo son las que causan los errores entre los segmentos de la demostración anterior. Para lograr una reproducción sin interrupciones, debemos quitar estas secciones de silencio. Por suerte, esto se hace fácilmente con MediaSource. A continuación, modificaremos nuestro método onAudioLoaded() para usar una ventana de inserción y una compensación de marca de tiempo para 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 sin interrupciones

Veamos lo que logró nuestro nuevo código brillante. Para ello, volvamos a observar la forma de onda después de aplicar las ventanas de inserción. A continuación, puedes ver que se quitaron la sección sin sonido al final de sintel_0.mp3 (en rojo) y la sección sin sonido al comienzo de sintel_1.mp3 (en azul), lo que nos deja con una transición perfecta entre los segmentos.

Unión de sintel_0.mp3 y sintel_1.mp3

Conclusión

Con eso, unimos los cinco segmentos sin problemas en uno y, luego, llegamos al final de nuestra demostración. Antes de continuar, es posible que 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 contenedor o el tipo de códec. A continuación, puedes volver a reproducir la demo original en MP4 fragmentado listo para DASH en lugar de MP3.

Datos demográficos

Si quieres obtener más información, consulta los apéndices que se incluyen a continuación para obtener un análisis más detallado de la creación de contenido sin interrupciones 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: Cómo crear contenido sin interrupciones

Crear contenido sin interrupciones puede ser difícil. A continuación, explicaremos cómo crear el contenido de Sintel que se usa en esta demostración. Para comenzar, necesitarás una copia de la banda sonora FLAC sin pérdida de Sintel. A continuación, se incluye el SHA1 para la posteridad. En cuanto a 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 desvanecimiento de 2.5 segundos a partir del segundo 28 para evitar que se produzcan clics una vez que finalice la reproducción. Con la siguiente línea de comandos de FFmpeg, 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 wave, ya que casi todos los codificadores admiten su transferencia. Una vez más, podemos hacerlo con precisión 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, crearemos los archivos MP3. LAME tiene varias opciones para crear contenido sin interrupciones. Si tienes el control del contenido, te recomendamos que uses --nogap con una codificación por lotes de todos los archivos para evitar el relleno entre segmentos. Sin embargo, para los fines de esta demostración, queremos ese padding, 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 necesitas para crear los archivos MP3. Ahora, hablemos de la creación de los archivos MP4 fragmentados. Seguiremos las instrucciones de Apple para crear contenido multimedia masterizado para iTunes. A continuación, convertiremos los archivos Wave 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 se puedan usar 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 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

Eso es todo. Ahora tenemos archivos MP4 y MP3 fragmentados con los metadatos correctos necesarios para la reproducción sin interrupciones. Consulta el Apéndice B para obtener más detalles sobre cómo se ven esos metadatos.

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

Al igual que crear contenido sin interrupciones, analizar los metadatos sin interrupciones 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 interrupciones. Comencemos por configurar algunos métodos auxiliares y un esquema para el 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.

Primero, hablaremos del 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) escriben una breve sección en ASCII de la siguiente manera:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Se escribe dentro de una etiqueta ID3 dentro del contenedor MP3 y dentro de un átomo de metadatos dentro del contenedor 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. Si dividimos cada uno de ellos por la tasa de muestreo del audio, obtenemos 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 otro lado, la mayoría de los codificadores de MP3 de código abierto almacenarán los metadatos sin interrupciones en un encabezado Xing especial ubicado dentro de un fotograma MPEG silencioso (es silencioso para que los decodificadores que no entiendan el encabezado Xing simplemente reproduzcan silencio). Lamentablemente, esta etiqueta no siempre está presente y tiene varios campos opcionales. A los efectos de esta demostración, tenemos control sobre el contenido multimedia, pero, en la práctica, se requerirán algunas verificaciones adicionales para saber cuándo los metadatos sin interrupciones están realmente disponibles.

Primero, analizaremos el recuento total de muestras. Para simplificar, lo leeremos desde el encabezado Xing, pero se podría construir a partir del encabezado de audio MPEG normal. Los encabezados de Xing se pueden marcar con una etiqueta Xing o Info. Exactamente 4 bytes después de esta etiqueta, hay 32 bits que representan la cantidad total de fotogramas en el archivo. Si multiplicamos este valor por la cantidad de muestras por fotograma, obtendremos la cantidad total de muestras 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 el codificador, es posible que se escriba en una etiqueta LAME o Lavf anidada en el encabezado Xing. Exactamente 17 bytes después de este encabezado, hay 3 bytes que representan el padding frontal y 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 interrupciones. Sin embargo, abundan los casos extremos, por lo que se recomienda tener cuidado antes de usar código similar en producción.

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

La memoria que pertenece a las instancias de SourceBuffer se recolecta de forma 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 que ya se reprodujeron. Sin embargo, si el uso de memoria supera los límites específicos de la plataforma, se quitará la memoria de los búferes no reproducidos.

Cuando la reproducción llega a un lapso en la línea de tiempo debido a la memoria recuperada, es posible que se produzcan fallas si el lapso es lo suficientemente pequeño o que se detenga por completo si es demasiado grande. Tampoco es una gran experiencia del usuario, por lo que es importante evitar agregar demasiados datos a la vez y quitar manualmente los rangos del cronograma de contenido multimedia que ya no sean necesarios.

Los rangos se pueden quitar con el método remove() en cada SourceBuffer, que toma un rango [start, end] en segundos. Al igual que appendBuffer(), cada remove() activará un evento updateend una vez que se complete. No se deben emitir otras eliminaciones ni adiciones hasta que se active el evento.

En Chrome para computadoras, puedes mantener 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 navegadores o plataformas; por ejemplo, es muy probable que no sean representativos de los dispositivos móviles.

La recolección de elementos no utilizados solo afecta los datos agregados a SourceBuffers. No hay límites en la cantidad de datos que puedes mantener almacenados en búfer en las variables de JavaScript. Si es necesario, también puedes volver a adjuntar los mismos datos en la misma posición.