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) fornecem buffer estendido e controle de reprodução para os elementos <audio> e <video> do HTML5. Originalmente desenvolvido para facilitar players de vídeo com base em Dynamic Adaptive Streaming over HTTP (DASH), veremos 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íam perfeitamente entre as faixas. Você pode até estar ouvindo uma agora. Os artistas criam essas experiências de reprodução sem lacunas, tanto como escolha artística quanto como artefato de registros de vinil e CDs, em que o áudio foi gravado como um fluxo contínuo. Infelizmente, devido à maneira como codecs de áudio modernos, como MP3 e AAC, funcionam, essa experiência auditiva perfeita geralmente é perdida.

Entraremos em detalhes sobre o motivo abaixo, mas, por enquanto, vamos começar com uma demonstração. Confira abaixo os primeiros 30 segundos do excelente Sintel, dividido em cinco arquivos MP3 e remontados usando o MSE. As linhas vermelhas indicam lacunas introduzidas durante a criação (codificação) de cada MP3. Você ouvirá falhas nesses pontos.

Demonstração

Ops! Esta 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 da próxima demonstração indicam onde os arquivos foram unidos e as lacunas removidas. No Chrome 38 e versões mais recentes, a reprodução será perfeita!

Demonstração

várias maneiras de criar conteúdo sem lacunas. Para os fins desta demonstração, vamos nos concentrar nos tipos de arquivo 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 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, atribuímos 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 está conectado, ele realiza algumas inicializações e dispara 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 de MP3. Há vários outros tipos disponíveis.

Ondas anômalas

Voltaremos ao código em breve, mas agora vamos analisar melhor o arquivo que acabamos de anexar, especificamente no final dele. Veja abaixo um gráfico das últimas 3.000 amostras, com uma 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].

lacuna do mp3

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

Além do padding no final, cada arquivo também teve padding adicionado ao início. Se observarmos a faixa sintel_1.mp3, vamos ver mais 576 exemplos de padding na frente. 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.

fim da lacuna do mp3

As seções de silêncio no início e no final de cada arquivo são o que causa as falhas entre os segmentos na demonstração anterior. Para conseguir uma reprodução sem lacunas, é necessário remover essas seções de silêncio. Felizmente, isso é feito facilmente com MediaSource. Vamos modificar nosso método onAudioLoaded() a seguir 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 ver o que nosso novíssimo código realizou analisando novamente a forma de onda depois de aplicar as janelas de anexação. Veja 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, resultando em uma transição perfeita entre os segmentos.

mp3 médio

Conclusão

Com isso, unimos os cinco segmentos perfeitamente em um e, consequentemente, chegamos ao final da demonstração. Antes de terminar, talvez você tenha percebido que o método onAudioLoaded() não considera contêineres ou codecs. Isso significa que todas essas técnicas funcionam independentemente do tipo de contêiner ou codec. Abaixo, você pode reproduzir a demonstração original em MP4 fragmentada pronta para DASH em vez de MP3.

Demonstração

Se você quiser saber mais, consulte 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. Você também pode explorar gapless.js para ver mais detalhes do código por trás desta demonstração.

Agradecemos por ler.

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

Criar um conteúdo sem lacunas pode ser difícil de acertar. Confira abaixo 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 a Sintel. Para o futuro, 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 esmaecimento de 2,5 segundos a partir dos 28 segundos para evitar cliques quando a reprodução terminar. Usando a linha de comando do FFmpeg abaixo, podemos realizar 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 de ondas de 6,5 segundos cada.É mais fácil usar uma onda, já que quase todos os codificadores são compatíveis com a ingestão. Novamente, podemos fazer isso precisamente com o FFmpeg, depois 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

A seguir, vamos criar os arquivos MP3. O LAME tem várias opções para criar conteúdo sem lacunas. Se você está no controle do conteúdo, use --nogap com uma codificação em lote de todos os arquivos para evitar o preenchimento entre os segmentos. No entanto, para esta demonstração, queremos esse padding. Por isso, 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. Seguiremos as instruções da Apple para criar uma mídia que seja masterizada para o iTunes. Abaixo, vamos converter 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 adequadamente antes que possam ser usados com MediaSource. Para nossos objetivos, vamos usar um tamanho de fragmento de um segundo. O MP4Box grava cada MP4 fragmentada 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 lacunas. Consulte o Apêndice B para ver mais detalhes sobre como esses metadados são.

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 para o armazenamento. Veja abaixo 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.

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

    iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Isso é escrito 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 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 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, de modo que 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 esta demonstração, temos controle sobre a mídia, mas, na prática, serão necessárias algumas verificações de sensibilidade 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 isso no cabeçalho Xing, mas ele pode ser criado a partir do cabeçalho de áudio MPEG normal. Os cabeçalhos Xing podem ser marcados por uma tag Xing ou Info. Exatamente quatro bytes após essa tag, há 32 bits representando o número total de frames no arquivo. A multiplicação desse valor pelo número de amostras por frame 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 prosseguir para a leitura do número de amostras de padding. Dependendo do seu codificador, isso pode ser escrito 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 do front-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. Portanto, é recomendável ter cuidado antes de usar códigos semelhantes na produção.

Apêndice C: Na 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 reprodução atual. No Chrome, a memória será primeiro recuperada dos buffers já reproduzidos. No entanto, se o uso de memória exceder os limites específicos da plataforma, ela vai remover a memória de buffers não reproduzidos.

Quando a reprodução atinge uma lacuna na linha do tempo devido à memória recuperada, ela pode falhar se a lacuna for pequena o suficiente ou travar completamente se a lacuna for muito grande. Nenhuma das duas é 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 de mídia os intervalos que não são mais necessários.

Os intervalos podem ser removidos pelo método remove() em cada SourceBuffer, o que leva um intervalo [start, end] em segundos. Semelhante a appendBuffer(), cada remove() disparará um evento updateend assim que for concluído. Outras remoções ou anexos não podem 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. Não confie nesses valores em todos os navegadores ou plataformas. Por exemplo, eles certamente não representam dispositivos móveis.

A coleta de lixo afeta somente os dados adicionados a SourceBuffers. Não há limites para a quantidade de dados que podem ser armazenados em buffer nas variáveis JavaScript. Também é possível reinserir os mesmos dados na mesma posição, se necessário.

Feedback