Extensiones de fuentes de medios para audio

Dale Curtis
Dale Curtis

Introducción

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

Es probable que hayas escuchado un álbum de música en el que las canciones se escucharon sin problemas en todas 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 un artefacto de discos de vinilo y CDs en los que el audio se escribe como una sola 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 con frecuencia hoy en día.

A continuación, detallaremos los motivos, 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 reensamblado 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! No es una gran experiencia; podemos hacerlo mejor. Con un poco más de trabajo, usando exactamente los mismos archivos MP3 en la demostración anterior, podemos usar ECM para eliminar esas molestas brechas. Las líneas verdes en la siguiente demostración indican el punto en el que se unieron los archivos y se eliminaron 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. A los efectos de esta demostración, nos centraremos en el tipo de archivos que un usuario normal podría tener. Cuando cada archivo se codificó por separado, sin tener en cuenta los segmentos de audio antes o después.

Configuración básica

Primero, retrocedamos y abordemos la configuración básica de una instancia MediaSource. Como su nombre lo indica, las extensiones de fuente de medios son solo extensiones de los elementos multimedia existentes. A continuación, asignaremos un elemento 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 el objeto MediaSource esté conectado, realizará una inicialización y, finalmente, activará un evento sourceopen. En ese momento, podemos crear un SourceBuffer. En el ejemplo anterior, creamos un elemento audio/mpeg, que puede analizar y decodificar nuestros segmentos de MP3. Hay muchos otros tipos disponibles.

Formas de onda anómalas

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

Fin de sintel_0.mp3

¿Qué hay con todas esas muestras (silenciosas)? En realidad, se deben a los artefactos de compresión que se ingresan durante la codificación. Casi todos los codificadores incluyen 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, también se agregó padding al principio de cada archivo. Si miramos más adelante la pista sintel_1.mp3, veremos otras 576 muestras de padding en la parte delantera. La cantidad de padding varía según el codificador y el contenido, pero conocemos los valores exactos según 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 en la demostración anterior. Para lograr la reproducción sin espacios, debemos quitar esas 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 adición y un desplazamiento 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 fluida

Veamos lo que logró nuestro nuevo código echando un vistazo a la forma de onda después de que hayamos aplicado 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 dejó una transición fluida entre segmentos.

Cómo unir sintel_0.mp3 y sintel_1.mp3

Conclusión

Con esto, hemos unido los cinco segmentos perfectamente en uno y, posteriormente, llegamos al final de nuestra demostración. Antes de comenzar, quizás hayas notado que nuestro método onAudioLoaded() no tiene en cuenta los contenedores ni los códecs. Eso 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 MP4 fragmentada y compatible con DASH en lugar de MP3.

Datos demográficos

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

¡Gracias por leer esta información!

Apéndice A: Creación de contenido sin brechas

Crear contenido sin espacios vacíos 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érdidas de Sintel. Para la posteridad, se incluye el SHA1 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 de entrada para evitar clics una vez que finalice la reproducción. Con la línea de comandos FFmpeg que aparece 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 Wave, ya que casi todos los codificadores admiten la transferencia de ese archivo. De nuevo, 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 relleno entre los segmentos. Sin embargo, para esta demostración, queremos ese relleno, así que usaremos una codificación VBR estándar de alta calidad para 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, veamos la creación de archivos MP4 fragmentados. Seguimos 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 manera apropiada 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 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 la 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 se crea contenido sin espacios, analizar los metadatos sin espacios puede ser complicado, ya que no existe 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 vacíos. Para comenzar, configura algunos métodos de asistencia 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.

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) escribe una sección breve en ASCII de la siguiente manera:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Esto se escribe en una etiqueta ID3 dentro del contenedor MP3 y en 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. Dividir cada uno de ellos 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 otro lado, 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 esta demostración, tenemos control sobre los medios, pero, en la práctica, será necesario realizar algunas verificaciones adicionales para saber cuándo los metadatos sin espacios realmente están disponibles.

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 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 fotograma, nos da el 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 continuar con la lectura de la cantidad de muestras de padding. Según tu codificador, esto se puede escribir con una etiqueta LAME o Lavf anidada en el encabezado Xing. Exactamente 17 bytes después de este encabezado, hay 3 bytes que representan el relleno del frontend y el final en 12 bits, 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
};
}

De esta manera, 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 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 instancias de SourceBuffer es recopilación de elementos no utilizados 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 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 memoria recuperada, es posible que falle si el intervalo es lo suficientemente pequeño o se detiene por completo si es demasiado grande. Tampoco es una excelente 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 a través del método remove() en cada SourceBuffer, que tarda un rango de [start, end] en segundos. Al igual que appendBuffer(), cada remove() activará un evento updateend una vez que se complete. No se deben enviar otras eliminaciones o anexos hasta que se active el evento.

En la versión de Chrome para computadoras de escritorio, puedes conservar 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 todos los navegadores o plataformas; p.ej., sin dudas 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 se pueden mantener almacenados 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.