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

Dale'a Curtisa
Dale Curtis

Wprowadzenie

Rozszerzenia źródła multimediów (MSE) umożliwiają rozszerzone buforowanie i kontrolę odtwarzania elementów <audio> i <video> HTML5. Choć usługa została pierwotnie opracowana z myślą o umożliwieniu obsługi dynamicznego adaptacyjnego strumieniowego przesyłania danych przez HTTP (DASH), poniżej dowiemy się, jak można ich używać do obsługi dźwięku, a zwłaszcza do odtwarzania bez przerw.

Na pewno znasz coś z albumu muzycznego, w którym utwory płynnie łączą się ze sobą. Być może właśnie teraz słuchasz jakiegoś albumu. Wykonawcy tworzą możliwości odtwarzania bez przerw zarówno jako wybór artystyczny, jak i jako artefakt płyt winylowych i płyt CD, gdzie dźwięk jest 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, dziś nie można uzyskać płynnego odsłuchiwania dźwięku.

W dalszej części wyjaśnimy, dlaczego tak się dzieje, ale zacznijmy od demonstracji. Poniżej widać pierwsze 30 sekund świetnego filmu Sintel pociętego na 5 osobnych plików MP3 i zmontowanych z użyciem MSE. Czerwone linie oznaczają luki w trakcie tworzenia (kodowania) każdego pliku MP3. W tych miejscach usłyszysz zakłócenia.

Demonstracja

Oj, nie! To nie jest przyjemne doświadczenie, możemy ulepszyć tę stronę. Przy odrobinie pracy, używając dokładnie tych samych plików MP3 w powyższej prezentacji, będziemy mogli użyć MSE do usunięcia tych irytujących luk. Zielone linie w następnej prezentacji wskazują, w którym miejscu pliki zostały połączone i czy luki zostały usunięte. W Chrome 38 i nowszych wersjach film będzie odtwarzany płynnie.

Demonstracja

Jest wiele sposobów na tworzenie treści bez przerw. Na potrzeby tej prezentacji skupimy się na typach plików, które mogą znajdować się zwykły użytkownik. Każdy plik został zakodowany osobno, niezależnie od segmentów audio przed nim i po nim.

Konfiguracja podstawowa

Najpierw przyjrzyjmy się podstawowej konfiguracji instancji MediaSource. Rozszerzenia źródła multimediów, jak sama nazwa wskazuje, są tylko rozszerzeniami istniejących elementów multimedialnych. Poniżej przypisujemy Object URL, który reprezentuje 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 przeprowadza inicjalizację i w końcu uruchamia zdarzenie sourceopen. W tym momencie możemy utworzyć obiekt SourceBuffer. W przykładzie powyżej tworzymy kod audio/mpeg, który potrafi analizować i odkodowywać nasze segmenty MP3. Dostępnych jest kilka innych typów.

Nietypowe fale

Wrócimy do kodu za chwilę, a teraz przyjrzyjmy się bliżej plikowi, który właśnie został dołączony, a zwłaszcza 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ą w zakresie [-1.0, 1.0].

MP3 Gap

O co chodzi z tymi zerowymi (cichymi) próbkami? Wynika to z artefaktów kompresji wprowadzonych podczas kodowania. Niemal każdy koder wprowadza jakiś rodzaj dopełnienia. W tym przypadku LAME dodał(a) dokładnie 576 próbek wypełnienia na końcu pliku.

Oprócz dopełnienia na końcu każdy plik miał też dopełnienie na początku. W ś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 właściwości metadata zawartych w każdym pliku.

mp3 gap end

To właśnie sekcje ciszy na początku i na końcu każdego pliku są przyczyną problemów między segmentami w poprzedniej wersji demonstracyjnej. Aby odtwarzać bez przerw, musimy usunąć te fragmenty ciszy. Na szczęście można to łatwo zrobić za pomocą MediaSource. Poniżej zmienimy naszą 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

Sprawdźmy, co udało się osiągnąć naszemu nowemu kodowi – jeszcze raz przyjrzyjmy się fali po zastosowaniu okien dołączania. Poniżej widać, że cicha sekcja na końcu tekstu sintel_0.mp3 (kolor czerwony) i sekcja cicha na początku sintel_1.mp3 (w kolorze niebieskim) została usunięta, co pozwala płynnie przejść między segmentami.

MP3 Mid

Podsumowanie

Połączyliśmy wszystkie 5 segmentów w jeden, a następnie dotarliśmy do końca naszej prezentacji. Jak widać, 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 czy kodeka. Poniżej możesz ponownie odtworzyć oryginalną wersję demonstracyjną gotowy do wykorzystania w formacie DASH fragment kodu MP4 zamiast formatu MP3.

Demonstracja

Jeśli chcesz dowiedzieć się więcej, przeczytaj załączniki poniżej, aby dowiedzieć się więcej o tworzeniu treści bez przerw i analizie metadanych. Możesz też przyjrzeć się gapless.js, aby lepiej poznać kod wykorzystywany w tej wersji demonstracyjnej.

Dziękujemy za uwagę!

Załącznik A: tworzenie treści pozbawionych luk

Tworzenie treści bez przerw może być trudne. Poniżej opisujemy, jak utworzyć media Sintel używane w tej wersji demonstracyjnej. Na początek potrzebujesz kopii bezstratnej ścieżki dźwiękowej FLAC dla Sintel. Dla potomności poniżej znajdziesz skrót SHA1. Aby korzystać z narzędzi, potrzebujesz oprogramowania FFmpeg, MP4Box, LAME oraz systemu OSX z poleceniem 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 zanikanie po 28 sekundach, by uniknąć kliknięć po zakończeniu odtwarzania. Korzystając z wiersza poleceń FFmpeg poniżej, można to osiągnąć 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 go na 5 plików wave po 6,5 sekundy każdy.Jest to najłatwiejszy w użyciu, ponieważ prawie każdy koder obsługuje jego przetwarzanie. W FFmpeg możemy zrobić to dokładnie w przypadku FFmpeg. Później będziemy mieć: 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

Utwórzmy 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 zbiorczym kodowaniem wszystkich plików, aby uniknąć dopełnienia między segmentami. Na potrzeby tej demonstracji chcemy jednak użyć dopełnienia, więc 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 wystarczy do utworzenia plików MP3. Zajmijmy się teraz tworzeniem pofragmentowanych plików MP4. Będziemy postępować zgodnie z instrukcjami Apple dotyczącymi tworzenia multimediów w formacie iTunes. Poniżej przekonwertujemy pliki wave na pośrednie pliki CAF zgodnie z instrukcjami, zanim zakodujemy je w formacie AAC w kontenerze MP4 przy użyciu 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 odpowiednimi metadanymi, które są niezbędne do odtwarzania bez przerw. Więcej informacji o tym, jak wyglądają te metadane, znajdziesz w Załączniku B.

Załącznik B. Analiza metadanych bez luk

Podobnie jak tworzenie treści bez przerw, analizowanie metadanych bez przerw może być trudne, ponieważ nie ma standardowej metody przechowywania danych. Poniżej omówimy, jak dwa najpopularniejsze kodery: LAME oraz iTunes, przechowują metadane bez przerw. Zacznijmy od skonfigurowania metod pomocniczych i zarysu funkcji ParseGaplessData() używanych 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 Apple w iTunes, ponieważ jest on najłatwiejszy do przeanalizowania i wyjaśnienia. W plikach MP3 i M4A iTunes (i afconvert) zapisz krótki fragment w kodzie ASCII w następujący sposób:

    iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Można ją zapisać w tagu ID3 w kontenerze MP3 oraz w atom metadanych wewnątrz kontenera MP4. Na potrzeby tej funkcji możemy zignorować pierwszy token 0000000. Kolejne 3 tokeny to dopełnienie z przodu, dopełnienie na końcu i łączna liczba próbek bez dopełnienia. Podzielenie każdego z nich przez częstotliwość próbkowania audio daje nam 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, odtwarzają ciszę). Ten tag nie zawsze jest dostępny i ma wiele opcjonalnych pól. Na potrzeby tej prezentacji mamy kontrolę nad mediami, ale w praktyce trzeba będzie sprawdzić, czy metadane bez luk są faktycznie dostępne.

Najpierw przeanalizujemy łączną liczbę próbek. Dla uproszczenia odczytamy go z nagłówka Xing, ale można go utworzyć na podstawie zwykłego nagłówka audio MPEG. Nagłówki Xing można oznaczać tagami Xing lub Info. Dokładnie 4 bajty po tym tagu występują 32-bity reprezentujące łączną liczbę klatek w pliku. Po pomnożeniu tej wartości przez liczbę próbek na klatkę damy łą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.

Gdy mamy już łączną liczbę próbek, możemy przejść do odczytania liczby próbek wypełnienia. W zależności od używanego kodera informacje te można zapisać w tagu LAME lub Lavf umieszczonym w nagłówku Xing. Dokładnie 17 bajtów za tym nagłówkiem, po 3 bajtach reprezentujących odpowiednio dopełnienie na początku i na końcu w 12-bitowych bajtach.

        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 znacznej części treści bez przerw. Przypadki skrajne są jednak dość często, dlatego zalecamy ostrożność przy korzystaniu z podobnego kodu w środowisku produkcyjnym.

Załącznik C. Wywóz śmieci

Pamięć należąca do SourceBuffer instancji jest aktywnie zbierana do pamięci według typu treści, limitów platformy i bieżącej pozycji odtwarzania. W Chrome pamięć jest najpierw odzyskiwana z odtwarzanych już buforów. Jeśli jednak wykorzystanie pamięci przekroczy limity określone na platformie, spowoduje to usunięcie pamięci z nieodtworzonych buforów.

Gdy w czasie odtwarzania dojdzie do przerwy w osi czasu z powodu odzyskanej pamięci, przerwa w odtwarzaniu może zostać zakłócona, jeśli jest ona wystarczająco mała, lub całkowicie się zawiesić, jeśli jest zbyt duża. Żaden z nich nie wpływa na wygodę użytkowników, dlatego ważne jest, aby nie dodawać zbyt wielu danych jednocześnie i ręcznie usuwać z osi czasu multimediów zakresy, które nie są już konieczne.

Zakresy można usuwać za pomocą metody remove() w każdym elemencie SourceBuffer, który zajmuje zakres [start, end] w sekundach. Podobnie jak w przypadku appendBuffer(), każde zdarzenie remove() wywoła zdarzenie updateend po zakończeniu. Inne operacje usuwania i dołączania nie powinny być wykonywane, dopóki zdarzenie nie zostanie uruchomione.

W przypadku Chrome na komputery w pamięci można przechowywać około 12 megabajtów treści audio i 150 megabajtów filmów. Nie należy stosować tych wartości w różnych przeglądarkach ani na różnych platformach – na przykład z pewnością nie reprezentują urządzeń mobilnych.

Usuwanie danych ma wpływ tylko na dane dodane do SourceBuffers. Nie ma ograniczeń dotyczących ilości danych, które można buforować w zmiennych JavaScript. W razie potrzeby możesz też ponownie dołączyć te same dane w tym samym miejscu.

Prześlij opinię