Extensões de origem de mídia para áudio

Dale Curtis
Dale Curtis

Introdução

As extensões de fonte de mídia (MSE) oferecem controle de buffer e reprodução estendidos para os elementos <audio> e <video> do HTML5. Embora originalmente desenvolvidos para facilitar os leitores de vídeo baseados em streaming adaptável dinâmico sobre HTTP (DASH), abaixo vamos mostrar como eles podem ser usados para áudio, especificamente para reprodução sem pausas.

Você provavelmente já ouviu um álbum em que as músicas tocaram sem interrupções. Talvez você esteja ouvindo um agora mesmo. Os artistas criam essas experiências de reprodução sem pausas como uma escolha artística e como um artefato de discos de vinil e CDs, em que o áudio foi gravado como um fluxo contínuo. Infelizmente, devido à forma como os codecs de áudio modernos, como MP3 e AAC, funcionam, essa experiência auditiva perfeita é frequentemente perdida hoje.

Vamos entrar nos detalhes abaixo, mas, por enquanto, vamos começar com uma demonstração. Confira abaixo os primeiros 30 segundos da excelente Sintel divididos em cinco arquivos MP3 separados e remontados usando MSE. As linhas vermelhas indicam lacunas introduzidas durante a criação (codificação) de cada MP3. Você vai ouvir falhas nesses pontos.

Demonstração

Credo! Essa não é uma experiência muito boa. Podemos fazer melhor. Com um pouco mais de trabalho, usando os mesmos arquivos MP3 da demonstração acima, podemos usar a MSE para remover essas lacunas irritantes. As linhas verdes na próxima demonstração indicam onde os arquivos foram unidos e as lacunas removidas. No Chrome 38 ou versões mais recentes, isso vai funcionar perfeitamente.

Demonstração

várias maneiras de criar conteúdo sem lacunas. Para esta demonstração, vamos nos concentrar no tipo de arquivo que um usuário normal pode ter. Em que cada arquivo foi codificado separadamente, sem considerar os segmentos de áudio anteriores ou posteriores.

Configuração básica

Primeiro, vamos voltar e abordar a configuração básica de uma instância MediaSource. As extensões de origem de mídia, como o nome indica, são apenas extensões dos elementos de mídia existentes. Abaixo, estamos atribuindo um Object URL, que representa nossa instância MediaSource, ao atributo de origem de um elemento de áudio, assim como você definiria um URL padrão.

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);

Quando o objeto MediaSource é conectado, ele executa uma inicialização e, eventualmente, dispara um evento sourceopen. Nesse ponto, podemos criar um SourceBuffer. No exemplo acima, estamos criando um audio/mpeg, que é capaz de analisar e decodificar os segmentos MP3. Há vários outros tipos disponíveis.

Formas de onda anômalas

Vamos voltar ao código em um minuto, mas agora vamos analisar mais de perto o arquivo que acabamos de anexar, especificamente no final dele. Confira abaixo um gráfico das últimas 3.000 amostras com a média dos dois canais da faixa sintel_0.mp3. Cada pixel na linha vermelha é uma amostra de ponto flutuante no intervalo de [-1.0, 1.0].

Fim de sintel_0.mp3

O que há com todas essas amostras zero (silenciosas)? Na verdade, elas são causadas por artefatos de compactação introduzidos durante a codificação. Quase todos os codificadores introduzem algum tipo de preenchimento. Nesse caso, o LAME adicionou exatamente 576 amostras de preenchimento ao final do arquivo.

Além do padding no final, cada arquivo também tinha padding adicionado ao início. Se olharmos para a faixa sintel_1.mp3, vamos ver outras 576 amostras de padding na parte da frente. A quantidade de preenchimento varia de acordo com o codificador e o conteúdo, mas sabemos os valores exatos com base no metadata incluído em cada arquivo.

Início de sintel_1.mp3

Início de sintel_1.mp3

As seções de silêncio no início e no final de cada arquivo são o que causa os problemas entre os segmentos na demonstração anterior. Para conseguir uma reprodução sem intervalos, precisamos remover essas seções de silêncio. Felizmente, isso é fácil de fazer com MediaSource. Abaixo, vamos modificar o método onAudioLoaded() para usar uma janela de adição e um deslocamento de carimbo de data/hora para remover esse silêncio.

Exemplo de código

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);
}

Uma forma de onda contínua

Vamos conferir o que nosso novo código conseguiu, analisando novamente a forma de onda depois de aplicar as janelas de anexação. Abaixo, você pode conferir que a seção silenciosa no final de sintel_0.mp3 (em vermelho) e a seção silenciosa no início de sintel_1.mp3 (em azul) foram removidas, deixando uma transição perfeita entre os segmentos.

Junção de sintel_0.mp3 e sintel_1.mp3

Conclusão

Com isso, juntamos os cinco segmentos em um e chegamos ao fim da nossa demonstração. Antes de terminarmos, você pode ter notado que nosso método onAudioLoaded() não considera contêineres ou codecs. Isso significa que todas essas técnicas vão funcionar independentemente do tipo de contêiner ou codec. Abaixo, você pode reproduzir a demonstração original de MP4 fragmentado pronto para DASH em vez de MP3.

Demonstração

Se você quiser saber mais, consulte os apêndices abaixo para saber mais sobre a criação de conteúdo sem lacunas e a análise de metadados. Você também pode conferir gapless.js para saber mais sobre o código que gera essa demonstração.

Agradecemos por ler.

Apêndice A: como criar conteúdo sem lacunas

Criar conteúdo sem lacunas pode ser difícil. Abaixo, vamos mostrar a criação da mídia Sintel usada nesta demonstração. Para começar, você vai precisar de uma cópia da trilha sonora FLAC sem perdas para Sintel. Para a posteridade, o SHA1 está incluído abaixo. Para ferramentas, você vai precisar de FFmpeg, MP4Box, LAME e uma instalação do OSX com afconvert.

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

Primeiro, vamos dividir os primeiros 31,5 segundos da faixa 1-Snow_Fight.flac. Também queremos adicionar um desbotamento de 2,5 segundos a partir de 28 segundos para evitar cliques quando a reprodução terminar. Usando a linha de comando FFmpeg abaixo, podemos fazer tudo isso e colocar os resultados em sintel.flac.

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

Em seguida, vamos dividir o arquivo em cinco ondas de 6,5 segundos cada.É mais fácil usar ondas, já que quase todos os codificadores aceitam a transferência delas. Novamente, podemos fazer isso com precisão usando o FFmpeg, depois disso teremos: sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav e 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

Agora vamos criar os arquivos MP3. O LAME tem várias opções para criar conteúdo sem lacunas. Se você tiver controle do conteúdo, considere usar --nogap com uma codificação em lote de todos os arquivos para evitar o preenchimento entre os segmentos. No entanto, para fins de demonstração, queremos esse preenchimento. Por isso, vamos usar uma codificação VBR padrão de alta qualidade dos arquivos 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

Isso é tudo o que é necessário para criar os arquivos MP3. Agora vamos abordar a criação de arquivos MP4 fragmentados. Vamos seguir as instruções da Apple para criar mídia masterizada para o iTunes. Abaixo, vamos converter os arquivos Wave em arquivos CAF intermediários, de acordo com as instruções, antes de codificá-los como AAC em um contêiner MP4 usando os 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

Agora temos vários arquivos M4A que precisam ser fragmentados de maneira adequada antes de serem usados com MediaSource. Para nossos propósitos, vamos usar um tamanho de fragmento de um segundo. O MP4Box grava cada MP4 fragmentado como sintel_#_dashinit.mp4 com um manifesto MPEG-DASH (sintel_#_dash.mpd) que pode ser descartado.

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

Pronto! Agora temos arquivos MP4 e MP3 fragmentados com os metadados corretos necessários para a reprodução sem intervalos. Consulte o Anexo B para mais detalhes sobre a aparência desses metadados.

Apêndice B: como analisar metadados sem lacunas

Assim como a criação de conteúdo sem lacunas, analisar os metadados sem lacunas pode ser complicado, já que não há um método padrão de armazenamento. A seguir, vamos abordar como os dois codificadores mais comuns, LAME e iTunes, armazenam os metadados sem lacunas. Vamos começar configurando alguns métodos auxiliares e um esboço para o ParseGaplessData() usado acima.

// 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.

Vamos abordar primeiro o formato de metadados do iTunes da Apple, porque ele é mais fácil de analisar e explicar. Nos arquivos MP3 e M4A, o iTunes (e o afconvert) grava uma pequena seção em ASCII, como esta:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Isso é gravado em uma tag ID3 no contêiner MP3 e em um átomo de metadados no contêiner MP4. Para nossos propósitos, podemos ignorar o primeiro token 0000000. Os três próximos tokens são o padding frontal, o padding final e a contagem total de amostras sem padding. A divisão de cada um deles pela taxa de amostragem do áudio nos dá a duração de cada um.

// 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 outro lado, a maioria dos codificadores de MP3 de código aberto armazena os metadados sem lacunas em um cabeçalho Xing especial colocado dentro de um frame MPEG silencioso. Como ele é silencioso, os decodificadores que não entendem o cabeçalho Xing simplesmente tocam silêncio. Infelizmente, essa tag nem sempre está presente e tem vários campos opcionais. Para fins de demonstração, temos controle sobre a mídia, mas, na prática, algumas verificações adicionais serão necessárias para saber quando os metadados sem lacunas estão realmente disponíveis.

Primeiro, vamos analisar a contagem total de amostras. Para simplificar, vamos ler isso do cabeçalho Xing, mas ele pode ser criado a partir do cabeçalho de áudio MPEG normal. Os cabeçalhos Xing podem ser marcados com uma tag Xing ou Info. Exatamente 4 bytes após essa tag, há 32 bits representando o número total de frames no arquivo. Multiplicar esse valor pelo número de amostras por frame vai nos dar o total de amostras no arquivo.

// 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.

Agora que temos o número total de amostras, podemos ler o número de amostras de preenchimento. Dependendo do codificador, isso pode ser gravado em uma tag LAME ou Lavf aninhada no cabeçalho Xing. Exatamente 17 bytes após esse cabeçalho, há 3 bytes representando o padding frontal e final em 12 bits cada, 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
};
}

Com isso, temos uma função completa para analisar a grande maioria do conteúdo sem lacunas. No entanto, casos extremos são comuns, então é recomendável ter cuidado ao usar códigos semelhantes na produção.

Apêndice C: Sobre a coleta de lixo

A memória pertencente às instâncias SourceBuffer é coletada de forma ativa de acordo com o tipo de conteúdo, os limites específicos da plataforma e a posição de reprodução atual. No Chrome, a memória será recuperada primeiro dos buffers já reproduzidos. No entanto, se o uso de memória exceder os limites específicos da plataforma, ele vai remover a memória dos buffers não reproduzidos.

Quando a reprodução atinge uma lacuna na linha do tempo devido à recuperação de memória, pode ocorrer uma falha se a lacuna for pequena o suficiente ou uma interrupção completa se a lacuna for muito grande. Nenhuma delas é uma boa experiência do usuário. Portanto, é importante evitar anexar muitos dados de uma só vez e remover manualmente os intervalos da linha do tempo de mídia que não são mais necessários.

Os intervalos podem ser removidos pelo método remove() em cada SourceBuffer, que leva um intervalo de [start, end] em segundos. Assim como o appendBuffer(), cada remove() vai acionar um evento updateend quando for concluído. Outras remoções ou inclusões não podem ser emitidas até que o evento seja acionado.

No Chrome para computador, é possível manter aproximadamente 12 MB de conteúdo de áudio e 150 MB de conteúdo de vídeo na memória de uma só vez. Não confie nesses valores em todos os navegadores ou plataformas. Por exemplo, eles não são representativos de dispositivos móveis.

A coleta de lixo só afeta os dados adicionados a SourceBuffers. Não há limites para a quantidade de dados que você pode manter em buffer nas variáveis do JavaScript. Também é possível anexar novamente os mesmos dados na mesma posição, se necessário.