Rozszerzenia źródła multimediów dla dźwięku

Dale Curtis
Dale Curtis

Wprowadzenie

Rozszerzenia źródła multimediów (MSE) zapewniają rozszerzone buforowanie i kontrolę odtwarzania w elementach HTML5 <audio> i <video>. Odtwarzacze oparte na dynamicznym adaptacyjnym strumieniowaniu przez HTTP (DASH) zostały pierwotnie opracowane, aby ułatwić strumieniowe przesyłanie danych przez HTTP (DASH), ale poniżej zobaczysz, jak można ich używać do dźwięku, w szczególności do odtwarzania bez przerw.

Prawdopodobnie słuchałeś/słuchałaś albumu muzycznego, na którym utwory płynnie przechodziły jeden w drugi. Być może właśnie teraz słuchasz takiego albumu. Wykonawcy tworzą te odtwarzania bez przerw zarówno jako wybór artystyczny, jak i jako element płyt winylowych i CD, na których dźwięk był zapisywany jako jeden ciągły strumień. Niestety ze względu na sposób działania nowoczesnych kodeków audio, takich jak MP3 i AAC, ten płynny dźwięk jest często tracony.

Poniżej wyjaśnimy, dlaczego tak się dzieje, ale na razie zademonstrujemy to na przykładzie. Poniżej znajdziesz pierwsze 30 sekund z rewelacyjnego filmu Sintel podzielonego na 5 osobnych plików MP3 i zmontowanego za pomocą MSE. Czerwone linie wskazują luki powstałe podczas tworzenia (kodowania) każdego pliku MP3. W tych miejscach usłyszysz zakłócenia.

Prezentacja

Obrzydlistw0! To nie jest dobre rozwiązanie. Możemy to zrobić lepiej. Przy odrobinie dodatkowej pracy i użyciu tych samych plików MP3 co w powyższym pokazie możemy użyć MSE, aby usunąć te irytujące przerwy. Zielone linie w następnym pokazie wskazują miejsca, w których pliki zostały scalone, a luki usunięte. W Chrome 38 lub nowszej odtwarzanie będzie płynne.

Prezentacja

Treści bez przerw można tworzyć na wiele sposobów. W ramach tego demonstracji skupimy się na typach plików, które mogą znajdować się w posiadaniu zwykłego użytkownika. Każdy plik został zakodowany osobno bez uwzględniania segmentów dźwięku poprzedzających go lub następujących po nim.

Konfiguracja podstawowa

Najpierw cofnijmy się do podstawowej konfiguracji instancji MediaSource. Jak sama nazwa wskazuje, rozszerzenia źródeł multimediów to tylko rozszerzenia dotychczasowych elementów multimedialnych. Poniżej przypisujemy atrybutowi source elementu audio wartość Object URL, która reprezentuje instancję MediaSource, tak jak w przypadku standardowego adresu 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);

Po połączeniu obiektu MediaSource wykona on proces inicjalizacji i ostatecznie wywoła zdarzenie sourceopen. W tym momencie możemy utworzyć obiekt SourceBuffer. W przykładzie powyżej tworzymy audio/mpeg, który może analizować i dekodować segmenty MP3. Dostępnych jest kilka innych typów.

Nietypowe przebiegi

Do kodu wrócimy za chwilę, ale teraz przyjrzyjmy się bliżej plikowi, który właśnie dołączyliśmy, zwłaszcza jego końcowej części. Poniżej znajduje się wykres ostatnich 3000 próbek z trakcie średniej z obu kanałów z ścieżki sintel_0.mp3. Każdy piksel na czerwonej linii to próbka z przecinkiem dziesiętnym w zakresie [-1.0, 1.0].

Koniec pliku sintel_0.mp3

Co z tymi wszystkimi próbkami z poziomem 0 (cichymi)? Wynikają one z artefaktów kompresji wprowadzonych podczas kodowania. Prawie każdy koder stosuje pewien rodzaj wypełnienia. W tym przypadku LAME dodało dokładnie 576 próbek wypełniających na końcu pliku.

Oprócz wypełnienia na końcu do każdego pliku dodano również wypełnienie na początku. Jeśli spojrzymy na ścieżkę sintel_1.mp3, zobaczymy, że na początku jest jeszcze 576 próbek wypełnienia. Ilość wypełniania różni się w zależności od kodera i treści, ale znamy dokładne wartości na podstawie metadata zawartego w każdym pliku.

Początek pliku sintel_1.mp3

Początek pliku sintel_1.mp3

Sekcje ciszy na początku i na końcu każdego pliku powodują zakłócenia między segmentami w poprzednim pokazie. Aby zapewnić odtwarzanie bez przerw, musimy usunąć te sekcje ciszy. Na szczęście można to łatwo zrobić za pomocą MediaSource. Poniżej zmodyfikujemy metodę onAudioLoaded(), aby używać okna dodawaniaprzesunięcia sygnatury czasowej, aby usunąć ciszę.

Przykładowy kod

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

Fali bez przerw

Zobaczmy, co udało się osiągnąć naszemu nowemu kodom, gdy ponownie przyjrzymy się fali po zastosowaniu okien dołączania. Poniżej widać, że sekcja ciszy na końcu sintel_0.mp3 (w kolorze czerwonym) i sekcja ciszy na początku sintel_1.mp3 (w kolorze niebieskim) zostały usunięte, co spowodowało płynne przejście między segmentami.

Łączenie plików sintel_0.mp3 i sintel_1.mp3

Podsumowanie

W ten sposób połączyliśmy wszystkie 5 segmentów w jeden, a tym samym kończymy naszą prezentację. Zanim skończymy, warto zauważyć, że nasza metoda onAudioLoaded() nie uwzględnia kontenerów ani kodeków. Oznacza to, że wszystkie te techniki będą działać niezależnie od typu kontenera lub kodeka. Poniżej możesz odtworzyć oryginalne demo w formacie MP4 podzielonym na fragmenty, które jest gotowe do odtwarzania w ramach DASH, zamiast MP3.

Prezentacja

Jeśli chcesz dowiedzieć się więcej, zapoznaj się z załącznikami poniżej, aby dowiedzieć się więcej o tworzeniu treści bez przerw i analizowaniu metadanych. Aby dokładniej przyjrzeć się kodom, na których opiera się to demo, możesz też otworzyć plik gapless.js.

Dziękujemy za uwagę!

Załącznik A. Tworzenie treści bez luk

Tworzenie treści bez przerw może być trudne. Poniżej pokażemy, jak utworzyć media Sintel używane w tym pokazie. Na początek potrzebujesz kopii ścieżki dźwiękowej FLAC bez strat dla Sintel. Poniżej znajdziesz hasz SHA-1. Z narzędzi będziesz potrzebować FFmpeg, MP4Box, LAME oraz instalacji OSX z afconvert.

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

Najpierw wyodrębnimy pierwsze 31, 5 sekund ścieżki 1-Snow_Fight.flac. Chcemy też dodać 2,5-sekundowe wygaszanie, które rozpocznie się po 28 sekundach, aby uniknąć kliknięć po zakończeniu odtwarzania. Za pomocą podanego poniżej wiersza poleceń FFmpeg możemy wykonać wszystkie te czynności i umieścić wyniki w pliku sintel.flac.

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

Następnie podzielimy plik na 5 plików wave o długości 6,5 sekundy.Najłatwiej jest użyć formatu wave, ponieważ obsługuje go prawie każdy koder. Ponownie możemy to zrobić za pomocą FFmpeg. W wyniku uzyskamy: sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wavsintel_4.wav.

ffmpeg -i sintel.flac -acodec pcm_f32le -map 0 -f segment \
        -segment_list out.list -segment_time 6.5 sintel_%d.wav

Teraz utwórz pliki MP3. LAME oferuje kilka opcji tworzenia treści bez przerw. Jeśli masz kontrolę nad treściami, możesz rozważyć użycie --nogap z kodowaniem zbiorczym wszystkich plików, aby całkowicie uniknąć wypełniania między segmentami. W tym przypadku jednak potrzebujemy wypełnienia, więc użyjemy standardowego kodowania VBR wysokiej jakości plików 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

To wszystko, co jest potrzebne do utworzenia plików MP3. Teraz omówimy tworzenie podzielonych plików MP4. Będziemy postępować zgodnie z wytycznymi Apple dotyczącymi tworzenia multimediów masterowanych na potrzeby iTunes. Poniżej zgodnie z instrukcjami przekonwertujemy pliki wave na pośrednie pliki CAF, a następnie za pomocą zalecanych parametrów skonwertujemy je na AAC w kontenerze 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

Mamy teraz kilka plików M4A, które musimy odpowiednio podzielić, zanim będą mogły być użyte w MediaSource. W naszym przypadku użyjemy fragmentu o długości 1 s. MP4Box zapisze każdy fragmentowany plik MP4 jako sintel_#_dashinit.mp4 wraz z plikiem manifestu MPEG-DASH (sintel_#_dash.mpd), który można odrzucić.

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

Znakomicie. Mamy teraz podzielone pliki MP4 i MP3 z prawidłowymi metadanymi, które są niezbędne do odtwarzania bez przerw. W Załączniku B znajdziesz więcej informacji o tym, jak wyglądają metadane.

Załącznik B. Analizowanie metadanych bez przerw

Podobnie jak tworzenie bezprzerywowych treści, parsowanie metadanych bezprzerywanych może być trudne, ponieważ nie ma standardowej metody ich przechowywania. Poniżej opisujemy, jak dwa najpopularniejsze kodery, LAME i iTunes, przechowują metadane bez przerw. Zacznijmy od skonfigurowania kilku metod pomocniczych i szkicowania ParseGaplessData() użytego powyżej.

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

Najpierw omówimy format metadanych iTunes firmy Apple, ponieważ jest on najłatwiejszy do zanalizowania i wytłumaczenia. W plikach MP3 i M4A iTunes (i afconvert) zapisują krótki fragment w formacie ASCII w ten sposób:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Jest on zapisywany w tagu ID3 w kontenerze MP3 i w elemencie atomu metadanych w kontenerze MP4. W naszym przypadku możemy zignorować pierwszy token 0000000. Kolejne 3 tokeny to wypełnienie z przodu, wypełnienie z tyłu i łączna liczba próbek bez wypełnień. Po podzieleniu każdego z nich przez częstotliwość próbkowania dźwięku otrzymujemy czas trwania.

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

Z drugiej strony większość enkoderów MP3 na licencji open source przechowuje metadane bez przerw w specjalnym nagłówku Xing umieszczonym w cichym klatce MPEG (jest ona cicha, więc dekodery, które nie rozumieją nagłówka Xing, po prostu odtwarzają ciszę). Niestety ten tag nie zawsze występuje i zawiera kilka pól opcjonalnych. Na potrzeby tego demonstracji mamy kontrolę nad mediami, ale w praktyce konieczne będą dodatkowe sprawdzenia, aby dowiedzieć się, kiedy metadane bez przerw są rzeczywiście dostępne.

Najpierw przeanalizujemy łączną liczbę próbek. Dla uproszczenia odczytamy to z nagłówka Xing, ale można go utworzyć na podstawie zwykłego nagłówka audio MPEG. Nagłówki Xing mogą być oznaczone tagiem Xing lub Info. Po tym tagu znajdują się 32 bity, które reprezentują łączną liczbę klatek w pliku. Pomnożenie tej wartości przez liczbę próbek na klatkę da nam łączną liczbę próbek w pliku.

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

Teraz, gdy znamy łączną liczbę próbek, możemy przejść do odczytu liczby próbek wypełniających. W zależności od używanego kodera może on być zapisany w tagu LAME lub Lavf zagnieżdżonym w nagłówku Xing. Po 17 bajtach po tym nagłówku znajdują się 3 bajty, które stanowią odpowiednio 12-bitowy wypełniacz na początku i na końcu.

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

Dzięki temu mamy kompletną funkcję analizowania zdecydowanej większości treści bez przerw. Zdarzają się jednak przypadki szczególne, dlatego przed użyciem podobnego kodu w produkcji należy zachować ostrożność.

Załącznik C. Czyszczenie pamięci

Pamięć przypisana instancjom SourceBuffer jest aktywnie usuwana zgodnie z typem treści, limitami określonymi przez platformę i bieżącą pozycją odtwarzania. W Chrome pamięć zostanie najpierw odzyskana z buforów już odtworzonych. Jeśli jednak wykorzystanie pamięci przekroczy limity określone dla danej platformy, usunie pamięć z nieodtwarzanych buforów.

Gdy odtwarzanie dotrze do luki w osi czasu spowodowanej odzyskaną pamięcią, może wystąpić błąd, jeśli luka jest wystarczająco mała, lub całkowite zatrzymanie, jeśli luka jest zbyt duża. Żadne z tych rozwiązań nie jest korzystne dla użytkowników, dlatego ważne jest, aby nie dołączać zbyt wielu danych naraz i ręcznie usuwać z osi czasu multimediów zakresy, które nie są już potrzebne.

Zakresy można usuwać za pomocą metody remove() w przypadku każdego SourceBuffer. Zajmuje to [start, end] sekund. Podobnie jak w przypadku appendBuffer(), każde zdarzenie remove() spowoduje wywołanie zdarzenia updateend po zakończeniu. Inne usunięcia lub dodania nie powinny być wykonywane, dopóki nie nastąpi zdarzenie.

W Chrome na komputery możesz przechowywać w pamięci około 12 megabajtów treści audio i 150 megabajtów treści wideo. Nie należy polegać na tych wartościach w przypadku przeglądarek ani platform. Na przykład nie odzwierciedlają one w żaden sposób urządzeń mobilnych.

Zbieranie elementów usuwanych ma wpływ tylko na dane dodawane do zmiennej SourceBuffers. Nie ma limitów dotyczących ilości danych, które możesz przechowywać w buforze w zmiennych JavaScript. W razie potrzeby możesz też ponownie dołączyć te same dane w tej samej pozycji.