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

Dale Curtis
Dale Curtis

Wstęp

Rozszerzenia źródła multimediów (MSE) zapewniają rozszerzone buforowanie i kontrolę odtwarzania w przypadku elementów <audio> i <video> HTML5. Chociaż usługa została pierwotnie utworzona, aby ułatwić odtwarzanie dynamicznego adaptacyjnego strumieniowego przesyłania danych przez HTTP (DASH), poniżej możemy sprawdzić, jak można ich używać do obsługi dźwięku, a zwłaszcza do odtwarzania bez przerw.

Na pewno zdarzyło Ci się słuchać albumu muzycznego, w którym utwory płynie przepływały między utworami. Być może w tej chwili słuchasz tylko jednego. Wykonawcy tworzą możliwości odtwarzania bez przerw zarówno jako artystyczny wybór, jak i jako artefakt płyt winylowych i CD, w których dźwięk był zapisywany jako jeden ciągły strumień. Niestety ze względu na sposób, w jaki działają nowoczesne kodeki audio, takie jak MP3 i AAC, te niezakłócone wrażenia dźwiękowe często są zapominane.

Szczegółowe informacje o przyczynach wyjaśniamy poniżej, ale na początek pokażemy Ci, jak to działa. Poniżej widać pierwsze 30 sekund doskonałych utworów Sintel pociętych na 5 osobnych plików MP3 i złożonych ponownie przy użyciu MSE. Czerwone linie oznaczają luki w procesie tworzenia (kodowania) każdego pliku MP3; w tych miejscach pojawią się błędy.

Prezentacja

Kurza twarz! Nie jest to zbyt przyjemne doświadczenie, możemy zaoferować coś więcej. Potrzebujemy jednak tych samych plików MP3 w poprzedniej wersji demonstracyjnej, aby pozbyć się tych irytujących luk za pomocą MSE. Zielone linie w następnej demonstracji wskazują, gdzie pliki zostały połączone, a luki usunięte. W przeglądarce Chrome w wersji 38 i nowszych odtwarzanie odtwarza się płynnie.

Prezentacja

Jest wiele sposobów na tworzenie treści bez przerw. Na potrzeby tej demonstracji skupimy się na typach plików, które prawdopodobnie znajdują się wśród zwykłych użytkowników. w sytuacji, gdy każdy plik został osobno zakodowany, niezależnie od segmentów audio przed nim i po nim.

Konfiguracja podstawowa

Najpierw przyjrzyjmy się podstawowej konfiguracji instancji MediaSource. Rozszerzenia źródeł multimediów są tylko rozszerzeniami do istniejących elementów multimedialnych. Poniżej przypisujemy element Object URL, który reprezentuje nasze wystąpienie MediaSource, do atrybutu źródła elementu audio – 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 obiekt MediaSource przeprowadzi inicjalizację i w końcu uruchomi zdarzenie sourceopen. W tym momencie możemy utworzyć obiekt SourceBuffer. Z powyższego przykładu tworzymy segment audio/mpeg, który może analizować i dekodować nasze segmenty MP3. Dostępnych jest kilka innych typów.

Nietypowe fale

Za chwilę wrócimy do kodu, ale przyjrzyjmy się bliżej plikowi, który właśnie został dołączony, a zwłaszcza na jego końcu. Poniżej znajduje się wykres przedstawiający uśrednione 3000 ostatnich próbek z obu kanałów ze ścieżki sintel_0.mp3. Każdy piksel na czerwonej linii jest próbką zmiennoprzecinkową z zakresu [-1.0, 1.0].

Koniec pliku sintel_0.mp3

O co chodzi z tymi zerowymi (cichymi) próbkami? Przyczyną tych problemów są artefakty kompresji wprowadzone podczas kodowania. Prawie każdy koder wprowadza jakiś rodzaj dopełnienia. W tym przypadku LAME dodał(a) dokładnie 576 przykładów dopełnienia na końcu pliku.

Oprócz dopełnienia na końcu każdy plik miał też dopełnienie na początku. Na ścieżce sintel_1.mp3 widać kolejne 576 próbek wypełnienia z przodu. Ilość dopełnienia różni się w zależności od kodera i treści, ale znamy dokładne wartości na podstawie parametru metadata zawartego w każdym pliku.

Początek pliku sintel_1.mp3

Początek pliku sintel_1.mp3

Zakłócenia między segmentami w poprzedniej wersji demonstracyjnej stanowią sekcje ciszy na początku i na końcu każdego pliku. Aby odtwarzać bez przerw, musimy usunąć te fragmenty ciszy. Na szczęście można to łatwo zrobić za pomocą usługi MediaSource. Poniżej zmienimy metodę onAudioLoaded(), aby używała okna dołączenia i przesunięcia sygnatury czasowej do usunięcia tej ciszy.

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

Płynna fala

Przyjrzyjmy się ponownie fali po zastosowaniu okien dołączania. Zobaczmy, co udało się osiągnąć naszemu nowemu kodowi. Poniżej widać, że została usunięta sekcja cicha na końcu tekstu sintel_0.mp3 (kolor czerwony) i sekcja cicha na początku tekstu sintel_1.mp3 (kolor niebieski). Pozwala to płynnie przechodzić 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 i doszliśmy do końca prezentacji. Być może udało Ci się 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 i kodeka. Poniżej możesz ponownie odtworzyć oryginalną wersję demonstracyjną gotowego do użytku DASH w formacie MP4, a nie w formacie MP3.

Prezentacja

Więcej informacji na ten temat znajdziesz w załącznikach poniżej, z których dowiesz się więcej na temat tworzenia treści bez przerw i analizowania metadanych. Możesz też przyjrzeć się gapless.js, aby lepiej przyjrzeć się kodowi, na którym opiera się ta wersja demonstracyjna.

Dziękujemy za uwagę!

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

Tworzenie treści bez przerw może być trudne. Poniżej opisujemy, jak utworzyć zasoby multimedialne Sintel używane w tej wersji demonstracyjnej. Na początek potrzebujesz kopii bezstratnej ścieżki dźwiękowej FLAC dla Sintel. Dla ułatwienia poniżej znajdziesz skrót SHA1. Potrzebna jest instalacja FFmpeg, MP4Box, LAME oraz OSX z funkcją afconvert.

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

Najpierw podzielimy pierwsze 31, 5 sekundy ścieżki 1-Snow_Fight.flac. Chcemy też dodać 2,5-sekundowe wyciszanie, zaczynając od 28 sekund, aby uniknąć kliknięć po zakończeniu odtwarzania. Wykonujemy to wszystko za pomocą wiersza poleceń FFmpeg, a wyniki zamieszczamy w języku: 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 go na 5 plików wave o długości 6,5 sekundy.Jest to najłatwiejszy w obsłudze plik wave, ponieważ prawie każdy koder obsługuje jego przetwarzanie. W przypadku FFmpega także jest to możliwe. Pozostałe właściwości to: sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav i 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

Teraz utwórz pliki MP3. LAME ma kilka opcji tworzenia treści bez przerw. Jeśli masz kontrolę nad treścią, możesz użyć metody --nogap z kodowaniem zbiorczym wszystkich plików, aby uniknąć dopełnienia między segmentami. Na potrzeby tej prezentacji wykorzystamy jednak dopełnienie, dlatego użyjemy standardowego kodowania plików VBR o wysokiej jakości.

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 pofragmentowanych plików MP4. Będziemy postępować zgodnie z instrukcjami Apple dotyczącymi tworzenia materiałów w formacie iTunes. Poniżej skonwertujemy pliki wave na pośrednie pliki CAF zgodnie z instrukcjami, zanim zakodujemy je w formacie AAC w kontenerze MP4 z użyciem zalecanych parametrów.

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 fragmentować, zanim będzie można ich używać z MediaSource. Do naszych celów użyjemy fragmentu o długości 1 sekundy. MP4Box zapisze każdy fragment kodu 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. Teraz mamy pofragmentowane pliki MP4 i MP3 z poprawnymi metadanymi, które są niezbędne do odtwarzania bez przerw. Więcej informacji na temat wyglądu takich metadanych znajdziesz w Załączniku B.

Załącznik B. Analizowanie metadanych bez luk

Podobnie jak w przypadku tworzenia treści bez przerw analizowanie takich metadanych może być trudne, ponieważ nie ma standardowej metody przechowywania danych. Poniżej opisujemy, jak 2 najpopularniejsze kodery – LAME oraz iTunes – przechowują swoje metadane bez przerw. Zacznijmy od skonfigurowania metod pomocniczych i zapowiedzi używanych powyżej funkcji 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.

Najpierw omówimy format metadanych Apple w iTunes, ponieważ jest on najłatwiejszy do przeanalizowania i wyjaśnienia. W plikach MP3 i M4A iTunes (i afconvert) zapisz krótką sekcję w ASCII, na przykład:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Jest ona zapisana w tagu ID3 w kontenerze MP3 oraz w atomu metadanych wewnątrz kontenera MP4. Na potrzeby naszych działań możemy zignorować pierwszy token 0000000. Kolejne 3 tokeny to dopełnienie przednie, na końcu i łączna liczba próbek bez dopełnienia. Po podzieleniu tych danych przez wartość częstotliwości próbkowania dźwięku otrzymamy czas trwania każdego z nich.

// 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ść koderów MP3 typu open source przechowuje metadane bez przerw w specjalnym nagłówku Xing umieszczonym wewnątrz cichej ramki MPEG (kody są ciche, więc dekodery, które nie rozpoznają nagłówka Xing, po prostu odtwarzają ciszę). Ten tag nie zawsze jest dostępny i ma wiele opcjonalnych pól. Na potrzeby tej demonstracji mamy kontrolę nad multimediami, ale w praktyce trzeba będzie sprawdzić, kiedy metadane pozbawione luk w zabezpieczeniach są faktycznie dostępne.

Najpierw przeanalizujemy łączną liczbę próbek. Dla uproszczenia odczytamy go z nagłówka Xing, ale można go utworzyć ze zwykłego nagłówka audio MPEG. Nagłówki Xing mogą być oznaczone tagiem Xing lub Info. Dokładnie po 4 bajtach po tym tagu znajdują się 32-bity reprezentujące łączną liczbę ramek w pliku. Po pomnożeniu tej wartości przez liczbę próbek na ramkę otrzymamy łą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.

Po uzyskaniu łącznej liczby próbek możemy przejść do odczytania liczby próbek dopełnienia. W zależności od kodera można to zapisać w tagu LAME lub Lavf zagnieżdżonym w nagłówku Xing. Dokładnie 17 bajtów po tym nagłówku, czyli 3 bajty reprezentujące dopełnienie przedniego i końcowego odpowiednio po 12 bitach.

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 pełną funkcję analizowania większości treści bez przerw. Przypadki skrajne na pewno są dość często, dlatego zalecamy zachowanie ostrożności przed użyciem podobnego kodu w środowisku produkcyjnym.

Załącznik C. Wywóz śmieci

Pamięć należąca do SourceBuffer instancji jest aktywnie zbierana do kosza zgodnie z typem treści, limitami platformy i bieżącą pozycją odtwarzania. W Chrome pamięć jest najpierw odzyskiwana z odtwarzanych już buforów. Jeśli jednak wykorzystanie pamięci przekracza limity określone na platformie, zostanie usunięta pamięć z nieodtworzonych buforów.

Gdy na osi czasu zostanie osiągnięty luka w zakresie odtwarzania z powodu odzyskanej pamięci, może wystąpić przerwa w działaniu, jeśli jest ona wystarczająco mała, lub całkowite zatrzymanie, jeśli przerwa jest zbyt duża. Również nie wpływa to na komfort użytkowników, dlatego ważne jest, aby nie dodawać zbyt wielu danych naraz i ręcznie usuwać z osi czasu multimediów te zakresy, które nie są już potrzebne.

Zakresy można usuwać za pomocą metody remove() w każdym elemencie SourceBuffer. Zajmuje to zakres [start, end] w sekundach. Podobnie jak w przypadku appendBuffer(), każdy element remove() uruchamia zdarzenie updateend po zakończeniu. Inne operacje usuwania i dołączania nie powinny być wykonywane do momentu uruchomienia zdarzenia.

W przeglądarce Chrome na komputerze możesz jednocześnie przechowywać około 12 MB treści audio i 150 MB materiałów wideo. Nie należy stosować tych wartości w różnych przeglądarkach ani na różnych platformach – na przykład nie są one reprezentatywne dla urządzeń mobilnych.

Czyszczenie pamięci ma wpływ tylko na dane dodane do SourceBuffers. Nie ma żadnych ograniczeń ilości danych, które można buforować w zmiennych JavaScript. W razie potrzeby możesz też ponownie dołączyć te same dane w tej samej pozycji.