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

Dale Curtis
Dale Curtis

Introdução

As extensões de origem de mídia (MSE, na sigla em inglês) oferecem armazenamento em buffer estendido e controle de reprodução para os elementos HTML5 <audio> e <video>. Embora tenham sido originalmente desenvolvidas para facilitar players de vídeo baseados no Dynamic Adaptive Streaming over HTTP (DASH), veremos abaixo como eles podem ser usados para áudio. especificamente para reprodução sem lacunas.

Você provavelmente já ouviu um álbum em que as músicas fluíram perfeitamente entre as faixas. você pode até estar ouvindo uma agora. Os artistas criam essas experiências de reprodução sem lacunas como uma escolha artística e também um artefato de discos de vinil e CDs em que o áudio foi escrito como uma transmissão contínua. Infelizmente, devido à forma como os codecs de áudio modernos, como MP3 e AAC, funcionam, essa experiência auditiva integrada muitas vezes é perdida nos dias de hoje.

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

Demonstração

Eca! Isso não é uma ótima experiência. podemos fazer melhor. Com um pouco mais de trabalho, usando exatamente os mesmos arquivos MP3 da demonstração acima, podemos usar o MSE para remover essas lacunas chatas. As linhas verdes na próxima demonstração indicam onde os arquivos foram mesclados e as lacunas removidas. No Chrome 38 ou superior, a reprodução será fluida sem problemas.

Demonstração

várias maneiras de criar conteúdo sem lacunas. Para os propósitos desta demonstração, vamos nos concentrar nos tipos de arquivos que um usuário normal pode ter. Onde cada arquivo foi codificado separadamente sem considerar os segmentos de áudio antes ou depois dele.

Configuração básica

Primeiro, vamos voltar e abordar a configuração básica de uma instância do 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 de MediaSource, ao atributo de origem de um elemento de áudio. assim como você define 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);

Depois que o objeto MediaSource for conectado, ele vai inicializar e disparar um evento sourceopen. Nesse ponto, podemos criar um SourceBuffer. No exemplo acima, estamos criando um audio/mpeg, que é capaz de analisar e decodificar nossos segmentos MP3. Há vários outros tipos disponíveis.

Formas de onda anômalas

Voltaremos ao código em breve, mas agora vamos ver mais de perto o arquivo que acabamos de anexar, especificamente no final dele. Veja abaixo um gráfico das últimas 3.000 amostras, com média em ambos os 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

Por que todas essas amostras zeradas (silenciosas)? Na verdade, eles ocorrem devido a artefatos de compactação introduzidos durante a codificação. Quase todos os codificadores introduzem algum tipo de preenchimento. Nesse caso, LAME adicionou exatamente 576 amostras de padding ao final do arquivo.

Além do padding no final, cada arquivo também recebeu padding no início. Se observarmos a faixa sintel_1.mp3, veremos outras 576 amostras de padding no início. A quantidade de padding 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 fim de cada arquivo são as causas das falhas entre os segmentos da demonstração anterior. Para conseguir uma reprodução sem lacunas, precisamos remover essas partes de silêncio. Felizmente, isso é feito facilmente com MediaSource. Abaixo, modificaremos nosso método onAudioLoaded() para usar uma janela de anexaçã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 perfeita

Vamos conferir o que nosso novo código fez ao dar uma olhada na forma de onda depois de aplicar as janelas de anexação. Observe abaixo 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-nos com uma transição perfeita entre segmentos.

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

Conclusão

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

Demonstração

Se você quiser saber mais, confira os apêndices abaixo para uma visão mais detalhada sobre a criação de conteúdo sem lacunas e a análise de metadados. Também é possível explorar o gapless.js (link em inglês) para ver mais detalhes do código que alimenta esta demonstração.

Agradecemos por ler.

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

Criar conteúdo sem lacunas pode ser difícil de acertar. Abaixo, mostraremos como criar a mídia Sintel usada nesta demonstração. Para começar, você precisa de uma cópia da trilha sonora FLAC sem perdas do Sintel. para a posteridade, o SHA1 está incluído abaixo. Para as ferramentas, você vai precisar do 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 esmaecimento 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, dividiremos o arquivo em cinco arquivos wave de 6,5 segundos cada. é mais fácil usar ondas, já que quase todos os codificadores suportam a ingestão delas. Novamente, podemos fazer isso precisamente com 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. LAME tem várias opções para criar conteúdo sem lacunas. Se você estiver no controle do conteúdo, use --nogap com uma codificação em lote de todos os arquivos para evitar o padding entre os segmentos. No entanto, para os fins desta demonstração, queremos esse preenchimento, portanto, usaremos uma codificação VBR padrão de alta qualidade dos arquivos da onda.

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 dos arquivos MP4 fragmentados. Vamos seguir as instruções da Apple para a criação de mídia masterizada para o iTunes. Abaixo, converteremos os arquivos da onda 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 precisamos fragmentar corretamente antes de serem usados com MediaSource. Para nossos objetivos, usaremos um tamanho de fragmento de um segundo. O MP4Box gravará 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 necessários para a reprodução sem lacunas. Consulte o Apêndice B para obter mais detalhes sobre a aparência desses metadados.

Apêndice B: análise de metadados sem lacunas

Assim como criar conteúdo sem lacunas, a análise dos metadados sem lacunas pode ser complicada, já que não há um método padrão de armazenamento. A seguir, abordaremos como os dois codificadores mais comuns, LAME e iTunes, armazenam seus metadados sem lacunas. Para começar, vamos configurar alguns métodos auxiliares e uma descrição da ParseGaplessData() usada 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 iTunes da Apple, porque é o mais fácil de analisar e explicar. Dentro dos arquivos MP3 e M4A, o iTunes (e aafconvert) escreve uma pequena seção em ASCII da seguinte forma:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Isso é gravado dentro de uma tag ID3 no contêiner MP3 e em um átomo de metadados dentro do contêiner MP4. Para nossos objetivos, podemos ignorar o primeiro token 0000000. Os próximos três tokens são o padding frontal, o padding final e a contagem total de amostras sem padding. Dividir cada um deles pela taxa de amostragem do áudio nos fornece 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 MP3 de código aberto armazena os metadados sem lacunas em um cabeçalho Xing especial colocado dentro de um frame MPEG silencioso. Ele é silencioso, então os decodificadores que não entendem o cabeçalho Xing simplesmente reproduzem silêncio. Infelizmente, essa tag nem sempre está presente e tem vários campos opcionais. Para os fins desta demonstração, temos controle sobre a mídia, mas, na prática, serão necessárias algumas verificações adicionais para saber quando os metadados sem lacunas estão realmente disponíveis.

Primeiro, vamos analisar a contagem total de amostras. Para simplificar, vamos ler a partir do cabeçalho Xing, mas ele pode ser construído a partir do cabeçalho de áudio MPEG normal. Os cabeçalhos Xing podem ser marcados por uma tag Xing ou Info. Exatamente 4 bytes depois dessa tag, há 32 bits representando o número total de frames no arquivo. multiplicar esse valor pelo número de amostras por frame nos fornecerá 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 padding. Dependendo do seu codificador, isso pode ser gravado sob uma tag LAME ou Lavf aninhada no cabeçalho Xing. Exatamente 17 bytes após esse cabeçalho há 3 bytes representando o padding do front-end e do end 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, há muitos casos extremos, por isso recomendamos cuidado antes de usar um código semelhante na produção.

Apêndice C: sobre coleta de lixo

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

Quando a reprodução atinge um intervalo na linha do tempo devido à memória recuperada, ele pode falhar se a lacuna for pequena o suficiente ou parar completamente se a lacuna for muito grande. Nenhuma é uma ótima experiência do usuário, por isso é importante evitar anexar muitos dados de uma só vez e remover manualmente da linha do tempo da mídia os intervalos que não são mais necessários.

Os intervalos podem ser removidos usando o método remove() em cada SourceBuffer. o que leva um intervalo de [start, end] em segundos. Assim como em appendBuffer(), cada remove() dispara um evento updateend quando é concluído. Outras remoções ou anexos não devem ser emitidos até que o evento seja disparado.

No Chrome para computador, é possível manter aproximadamente 12 megabytes de conteúdo de áudio e 150 megabytes de conteúdo de vídeo na memória de uma só vez. Você não deve confiar nesses valores em navegadores ou plataformas; Por exemplo, eles certamente não são representativos de dispositivos móveis.

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