Расширения медиа-источников для аудио

Дейл Кертис
Dale Curtis

Введение

Расширения источника мультимедиа (MSE) обеспечивают расширенную буферизацию и управление воспроизведением для элементов HTML5 <audio> и <video> . Первоначально они были разработаны для облегчения видеоплееров на основе динамической адаптивной потоковой передачи через HTTP (DASH) , но ниже мы увидим, как их можно использовать для аудио; специально для воспроизведения без пауз .

Вероятно, вы слушали музыкальный альбом, в котором песни плавно перетекали в треки; возможно, вы даже слушаете одну из них прямо сейчас. Исполнители создают эти впечатления от непрерывного воспроизведения как в качестве творческого выбора, так и в качестве артефакта виниловых пластинок и компакт-дисков , где звук был записан как один непрерывный поток. К сожалению, из-за того, как работают современные аудиокодеки, такие как MP3 и AAC , сегодня это безупречное звучание часто теряется.

Ниже мы подробно расскажем, почему, а пока давайте начнем с демонстрации. Ниже приведены первые тридцать секунд великолепного Sintel , разрезанного на пять отдельных файлов MP3 и снова собранного с помощью MSE. Красные линии обозначают пробелы, возникшие при создании (кодировании) каждого MP3; в этих точках вы услышите глюки.

Демо

Фу! Это не лучший опыт; мы можем сделать лучше. Немного поработав, используя те же файлы MP3, что и в приведенной выше демонстрации, мы сможем использовать MSE, чтобы устранить эти досадные пробелы. Зеленые линии в следующей демонстрации указывают места соединения файлов и удаления пробелов. На Chrome 38+ это будет воспроизводиться без проблем!

Демо

Существует множество способов создания контента без пробелов . Для целей этой демонстрации мы сосредоточимся на типах файлов, которые могут храниться у обычного пользователя. Где каждый файл был закодирован отдельно, без учета аудиосегментов до или после него.

Базовая настройка

Сначала давайте вернемся назад и рассмотрим базовую настройку экземпляра MediaSource . Расширения медиа-источников, как следует из названия, представляют собой просто расширения существующих медиа-элементов. Ниже мы назначаем Object URL , представляющий наш экземпляр MediaSource , атрибуту источника аудио-элемента; точно так же, как если бы вы установили стандартный URL-адрес.

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

После подключения объекта MediaSource он выполнит некоторую инициализацию и в конечном итоге вызовет событие sourceopen ; в этот момент мы можем создать SourceBuffer . В приведенном выше примере мы создаем файл audio/mpeg , который способен анализировать и декодировать наши сегменты MP3; есть еще несколько типов .

Аномальные сигналы

Мы вернемся к коду через минуту, а теперь давайте более внимательно посмотрим на файл, который мы только что добавили, особенно на его конец. Ниже приведен график последних 3000 семплов, усредненных по обоим каналам дорожки sintel_0.mp3 . Каждый пиксель на красной линии представляет собой образец с плавающей запятой в диапазоне [-1.0, 1.0] .

Конец sintel_0.mp3

Что это за нулевые (тихие) семплы!? На самом деле они возникают из-за артефактов сжатия , возникающих во время кодирования. Почти каждый кодер имеет тот или иной тип заполнения. В этом случае LAME добавил в конец файла ровно 576 образцов заполнения.

Помимо заполнения в конце, каждый файл также имел дополнение в начале. Если мы посмотрим на дорожку sintel_1.mp3 , то увидим еще 576 образцов заполнения впереди. Объем заполнения зависит от кодировщика и содержимого, но мы знаем точные значения на основе metadata включенных в каждый файл.

Начало sintel_1.mp3

Начало sintel_1.mp3

Участки тишины в начале и конце каждого файла являются причиной сбоев между сегментами в предыдущей демонстрации. Чтобы добиться воспроизведения без пауз, нам нужно удалить эти участки тишины. К счастью, это легко сделать с помощью MediaSource . Ниже мы изменим наш метод onAudioLoaded() , чтобы использовать окно добавления и смещение метки времени , чтобы удалить это молчание.

Пример кода

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

Бесшовная форма волны

Давайте посмотрим, чего достиг наш новый блестящий код, еще раз взглянув на форму сигнала после того, как мы применили окна добавления. Ниже вы можете видеть, что раздел без звука в конце sintel_0.mp3 (красный) и раздел без звука в начале sintel_1.mp3 (синий) были удалены; оставляя нам плавный переход между сегментами.

Объединение sintel_0.mp3 и sintel_1.mp3

Заключение

Таким образом, мы объединили все пять сегментов в один и впоследствии подошли к концу нашей демонстрации. Прежде чем мы продолжим, вы, возможно, заметили, что наш метод onAudioLoaded() не учитывает контейнеры или кодеки. Это означает, что все эти методы будут работать независимо от типа контейнера или кодека. Ниже вы можете воспроизвести исходную демонстрационную версию фрагментированного MP4, готового к DASH, вместо MP3.

Демо

Если вы хотите узнать больше, ознакомьтесь с приложениями ниже, чтобы более подробно изучить создание контента без пробелов и анализ метаданных. Вы также можете изучить gapless.js , чтобы поближе познакомиться с кодом, лежащим в основе этой демонстрации.

Спасибо за прочтение!

Приложение A. Создание контента без пробелов

Создание контента без пробелов может быть трудным делом. Ниже мы рассмотрим создание носителя Sintel , используемого в этой демонстрации. Для начала вам понадобится копия саундтрека FLAC без потерь для Sintel; для потомков SHA1 включен ниже. В качестве инструментов вам понадобятся FFmpeg , MP4Box , LAME и установка OSX с afconvert .

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

Сначала мы выделим первые 31,5 секунды трека 1-Snow_Fight.flac . Мы также хотим добавить затухание на 2,5 секунды, начиная с 28 секунды, чтобы избежать щелчков после завершения воспроизведения. Используя приведенную ниже командную строку FFmpeg, мы можем выполнить все это и поместить результаты в sintel.flac .

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

Далее мы разделим файл на 5 волновых файлов по 6,5 секунд каждый; проще всего использовать wave, поскольку почти каждый кодер поддерживает его прием. Опять же, мы можем сделать это именно с помощью FFmpeg, после чего у нас будут: sintel_0.wav , sintel_1.wav , sintel_2.wav , sintel_3.wav и 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

Далее давайте создадим файлы MP3. У LAME есть несколько вариантов создания контента без пробелов. Если вы контролируете контент, вы можете рассмотреть возможность использования --nogap с пакетным кодированием всех файлов, чтобы вообще избежать заполнения между сегментами. Однако для целей этой демонстрации нам нужно это дополнение, поэтому мы будем использовать стандартное высококачественное VBR-кодирование волновых файлов.

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

Это все, что необходимо для создания файлов MP3. Теперь давайте рассмотрим создание фрагментированных файлов MP4. Мы будем следовать указаниям Apple по созданию медиафайлов, адаптированных для iTunes . Ниже мы преобразуем волновые файлы в промежуточные файлы CAF в соответствии с инструкциями, а затем кодируем их в AAC в контейнере MP4 с использованием рекомендуемых параметров.

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

Теперь у нас есть несколько файлов M4A, которые нам нужно соответствующим образом фрагментировать , прежде чем их можно будет использовать с MediaSource . Для наших целей мы будем использовать фрагмент размером в одну секунду. MP4Box запишет каждый фрагментированный MP4 как sintel_#_dashinit.mp4 вместе с манифестом MPEG-DASH ( sintel_#_dash.mpd ), который можно отбросить.

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

Вот и все! Теперь у нас есть фрагментированные файлы MP4 и MP3 с правильными метаданными, необходимыми для воспроизведения без пауз. См. Приложение B для получения более подробной информации о том, как выглядят эти метаданные.

Приложение B: Анализ метаданных без пробелов

Как и при создании контента без пробелов, анализ метаданных без пробелов может быть сложным, поскольку не существует стандартного метода хранения. Ниже мы рассмотрим, как два наиболее распространенных кодировщика, LAME и iTunes, хранят свои метаданные без пробелов. Начнем с настройки некоторых вспомогательных методов и описания метода ParseGaplessData() , использованного выше.

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

Сначала мы рассмотрим формат метаданных Apple iTunes, поскольку его проще всего анализировать и объяснять. В файлах MP3 и M4A iTunes (и afconvert) напишите короткий раздел в ASCII, например:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Это записывается внутри тега ID3 внутри контейнера MP3 и внутри атома метаданных внутри контейнера MP4. Для наших целей мы можем игнорировать первый токен 0000000 . Следующие три токена — это переднее заполнение, концевое заполнение и общее количество выборок без заполнения. Разделив каждый из них на частоту дискретизации звука, мы получим продолжительность каждого из них.

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

С другой стороны, большинство кодеров MP3 с открытым исходным кодом сохраняют метаданные без пробелов в специальном заголовке Xing , помещенном внутри молчаливого кадра MPEG (он бесшумен, поэтому декодеры, которые не понимают заголовок Xing, просто будут воспроизводить тишину). К сожалению, этот тег присутствует не всегда и имеет ряд необязательных полей. Для целей этой демонстрации мы имеем контроль над медиа, но на практике потребуются некоторые дополнительные проверки, чтобы узнать, когда метаданные без пробелов действительно доступны.

Сначала мы проанализируем общее количество выборок. Для простоты мы будем считать это из заголовка Xing, но его можно составить из обычного аудиозаголовка MPEG . Заголовки Xing могут быть отмечены тегом Xing или Info . Ровно через 4 байта после этого тега находятся 32 бита, обозначающие общее количество кадров в файле; умножение этого значения на количество сэмплов в кадре даст нам общее количество сэмплов в файле.

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

Теперь, когда у нас есть общее количество выборок, мы можем перейти к считыванию количества выборок заполнения. В зависимости от вашего кодировщика это может быть записано под тегом LAME или Lavf, вложенным в заголовок Xing. Ровно через 17 байт после этого заголовка находятся 3 байта, представляющие начальное и конечное заполнение по 12 бит каждый соответственно.

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

Благодаря этому у нас есть полноценная функция для анализа подавляющего большинства контента без пробелов. Однако крайних случаев, безусловно, предостаточно, поэтому рекомендуется соблюдать осторожность перед использованием подобного кода в рабочей среде.

Приложение C. О сборе мусора

Память, принадлежащая экземплярам SourceBuffer , активно очищается от мусора в соответствии с типом контента, ограничениями, специфичными для платформы, и текущей позицией воспроизведения. В Chrome память сначала будет освобождена из уже воспроизведенных буферов. Однако если использование памяти превышает ограничения, специфичные для платформы, память будет удалена из невоспроизводимых буферов.

Когда воспроизведение достигает разрыва на временной шкале из-за освобожденной памяти, оно может давать сбои, если разрыв достаточно мал, или полностью останавливаться, если разрыв слишком велик. Ни то, ни другое не является удобным для пользователя, поэтому важно избегать добавления слишком большого количества данных одновременно и вручную удалять диапазоны с временной шкалы мультимедиа, которые больше не нужны.

Диапазоны можно удалить с помощью метода remove() для каждого SourceBuffer ; который занимает диапазон [start, end] в секундах. Как и в случае с appendBuffer() , каждый remove() запускает событие updateend после завершения. Другие удаления или добавления не должны выполняться до тех пор, пока не произойдет событие.

В настольном Chrome вы можете одновременно хранить в памяти примерно 12 мегабайт аудиоконтента и 150 мегабайт видеоконтента. Вам не следует полагаться на эти значения в разных браузерах или платформах; например, они наверняка не являются репрезентативными для мобильных устройств.

Сбор мусора влияет только на данные, добавленные в SourceBuffers ; нет ограничений на объем данных, которые вы можете хранить в буфере переменных JavaScript. При необходимости вы также можете повторно добавить те же данные в той же позиции.