Medienquellenerweiterungen für Audio

Dale Curtis
Dale Curtis

Einführung

Media Source Extensions (MSE) bieten erweiterte Steuerungsmöglichkeiten für die Zwischenspeicherung und Wiedergabe der HTML5-Elemente <audio> und <video>. Sie wurden ursprünglich zur Unterstützung des Dynamic Adaptive Streaming over HTTP (DASH)-basierten Videoplayers entwickelt. Im Folgenden erfahren Sie, wie sie für Audioinhalte verwendet werden können, insbesondere für die lückenlose Wiedergabe.

Wahrscheinlich haben Sie schon ein Musikalbum angehört, bei dem Songs nahtlos zwischen Titeln gewechselt sind. Vielleicht hören Sie sich gerade ein Musikalbum an. Künstler schaffen diese lückenlose Wiedergabe sowohl als künstlerische Wahl als auch als Artefakt aus Schallplatten und CDs, bei denen Audioinhalte als ein kontinuierlicher Stream geschrieben wurden. Aufgrund der Art und Weise, wie moderne Audio-Codecs wie MP3 und AAC funktionieren, ist dieses nahtlose Klangerlebnis heute oft nicht mehr gegeben.

Die Gründe dafür werden unten ausführlich beschrieben. Zunächst aber fangen wir mit einer Demonstration an. Unten sehen Sie die ersten 30 Sekunden des erstklassigen Sintel, aufgeteilt in fünf separate MP3-Dateien, die dann mit MSE wieder zusammengesetzt werden. Die roten Linien kennzeichnen Lücken, die bei der Erstellung (Codierung) jeder MP3-Datei entstanden sind. An diesen Punkten treten Störungen auf.

Demo

Igitt! Das ist kein schönes Erlebnis. Das können wir noch besser. Mit etwas mehr Arbeit und genau denselben MP3-Dateien in der obigen Demo können wir MSE verwenden, um diese lästigen Lücken zu schließen. Die grünen Linien in der nächsten Demo geben an, wo die Dateien verbunden und die Lücken beseitigt wurden. Ab Chrome 38 wird dies nahtlos wiedergegeben.

Demo

Es gibt eine Vielzahl von Möglichkeiten zum Erstellen lückenloser Inhalte. In dieser Demo konzentrieren wir uns auf die Dateitypen, die ein normaler Nutzer hat. Dabei wurde jede Datei separat und ohne Berücksichtigung der zuvor oder nach der Audiosegmente codiert.

Grundlegende Einrichtung

Sehen wir uns zuerst die grundlegende Einrichtung einer MediaSource-Instanz an. Wie der Name schon sagt, sind Medienquellenerweiterungen nur Erweiterungen der vorhandenen Medienelemente. Im Folgenden weisen wir dem Quellattribut eines Audioelements eine Object URL zu, die unsere MediaSource-Instanz darstellt – so, als würden Sie eine Standard-URL festlegen.

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

Sobald das MediaSource-Objekt verbunden ist, führt es eine Initialisierung durch und löst schließlich ein sourceopen-Ereignis aus. Dann können wir einen SourceBuffer erstellen. Im Beispiel oben erstellen wir ein audio/mpeg-Objekt, mit dem unsere MP3-Segmente geparst und decodiert werden können. Es gibt mehrere andere Typen.

Ungewöhnliche Wellenformen

Wir kommen gleich auf den Code zurück, sehen uns aber die Datei, die wir gerade angehängt haben, genauer an, genauer gesagt am Ende. Unten sehen Sie eine Grafik der letzten 3.000 Beispiele, gemittelt über beide Kanäle aus dem Track sintel_0.mp3. Jedes Pixel auf der roten Linie ist ein Gleitkommabeispiel im Bereich von [-1.0, 1.0].

MP3-Lücke

Was ist mit all diesen null (stummen) Samples? Tatsächlich sind sie auf Komprimierungsartefakte zurückzuführen, die während der Codierung eingeführt werden. Bei fast jedem Encoder gibt es eine Art von Padding. In diesem Fall hat LAME am Ende der Datei genau 576 Padding-Beispiele hinzugefügt.

Zusätzlich zum Abstand am Ende wurde jeder Datei am Anfang auch ein Abstand hinzugefügt. Wenn wir einen Blick in den Track sintel_1.mp3 werfen, sehen wir, dass am Anfang weitere 576 Beispiele für den Abstand vorhanden sind. Das Maß des Paddings variiert je nach Encoder und Inhalt. Wir kennen die genauen Werte jedoch basierend auf dem in jeder Datei enthaltenen metadata.

MP3-Gap-Ende

Die stillen Abschnitte am Anfang und Ende jeder Datei sind die Ursache für die Fehler zwischen den Segmenten in der vorherigen Demo. Für eine lückenlose Wiedergabe müssen diese Abschnitte der Stille entfernt werden. Glücklicherweise ist dies mit MediaSource ganz einfach. Unten ändern wir unsere onAudioLoaded()-Methode, um ein Anfügefenster und einen Zeitstempel-Offset zu verwenden, um diese Stille zu entfernen.

Beispielcode

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

Nahtlose Wellenform

Schauen wir uns an, was unser nagelneuer Code erreicht hat, indem wir uns die Wellenform nach Anwendung der Anfüge-Fenster noch einmal ansehen. Unten sehen Sie, dass der lautlose Abschnitt am Ende von sintel_0.mp3 (rot) und der lautlose Abschnitt am Anfang von sintel_1.mp3 (in Blau) entfernt wurden. So bleibt ein nahtloser Übergang zwischen den Segmenten.

MP3-Mid

Fazit

Damit haben wir alle fünf Segmente nahtlos zu einem zusammengefügt und sind am Ende unserer Demo angelangt. Bevor wir zum Ende kommen, haben Sie vielleicht bemerkt, dass die Methode onAudioLoaded() keine Berücksichtigung von Containern oder Codecs hat. Das bedeutet, dass alle diese Techniken unabhängig vom Container- oder Codec-Typ funktionieren. Unten können Sie die DASH-fertige fragmentierte Demo der Originaldemo statt MP3 noch einmal ansehen.

Demo

Wenn Sie mehr erfahren möchten, finden Sie in den nachfolgenden Anhängen einen tieferen Einblick in die lückenlose Inhaltserstellung und das Parsen von Metadaten. Sie können sich auch gapless.js ansehen, um einen genaueren Blick auf den Code für diese Demo zu werfen.

Vielen Dank, dass Sie sich die Zeit zum Lesen dieser E-Mail genommen haben.

Anhang A: Lückenlose Inhalte erstellen

Es ist nicht immer einfach, Inhalte ohne Unterbrechungen zu erstellen. Im Folgenden wird das in dieser Demo verwendete Sintel-Medium erläutert. Als Erstes benötigen Sie eine Kopie der verlustfreien FLAC-Soundtracks für Sintel. Der SHA1 ist unten enthalten. Für Tools benötigen Sie FFmpeg, MP4Box, LAME und eine OSX-Installation mit afconvert.

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

Zuerst teilen wir die ersten 31, 5 Sekunden des Titels 1-Snow_Fight.flac auf. Wir möchten auch ein 2,5-sekündiges Ausblendungssymbol ab 28 Sekunden hinzufügen, um Klicks nach der Wiedergabe zu vermeiden. Mit der folgenden FFmpeg-Befehlszeile können Sie all dies erledigen und die Ergebnisse in sintel.flac einfügen.

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

Als Nächstes teilen wir die Datei in fünf Wave-Dateien mit jeweils 6,5 Sekunden auf.Die Verwendung einer Wave ist am einfachsten, da fast jeder Encoder die Aufnahme unterstützt. Das können wir genau mit FFmpeg tun, woraufhin wir sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav und sintel_4.wav haben.

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

Als Nächstes erstellen wir die MP3-Dateien. LAME bietet mehrere Möglichkeiten, lückenlose Inhalte zu erstellen. Wenn Sie die Kontrolle über den Inhalt haben, können Sie --nogap mit einer Batchcodierung aller Dateien verwenden, um Padding zwischen Segmenten zu vermeiden. In dieser Demo möchten wir jedoch dieses Padding, also verwenden wir eine standardmäßige, hochwertige VBR-Codierung der Wave-Dateien.

    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

Mehr ist zum Erstellen der MP3-Dateien nicht erforderlich. Kommen wir nun zur Erstellung der fragmentierten MP4-Dateien. Wir folgen dabei der Anleitung von Apple zum Erstellen von Medien, die für iTunes gemastert werden. Im Folgenden werden die Wave-Dateien gemäß der Anleitung in CAF-Zwischendateien konvertiert, bevor sie mit den empfohlenen Parametern als AAC in einem MP4-Container codiert werden.

    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

Es gibt jetzt mehrere M4A-Dateien, die wir entsprechend fragmentieren müssen, bevor sie mit MediaSource verwendet werden können. Für unsere Zwecke verwenden wir eine Fragmentgröße von einer Sekunde. MP4Box schreibt jede fragmentierte MP4-Datei als sintel_#_dashinit.mp4 zusammen mit einem MPEG-DASH-Manifest (sintel_#_dash.mpd) aus, das verworfen werden kann.

    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

Fertig! Wir haben jetzt fragmentierte MP4- und MP3-Dateien mit den richtigen Metadaten, die für eine lückenlose Wiedergabe erforderlich sind. Weitere Informationen dazu, wie diese Metadaten aussehen, finden Sie in Anhang B.

Anhang B: Lückenlose Metadaten parsen

Genau wie das Erstellen lückenloser Inhalte kann das Parsen von lückenlosen Metadaten schwierig sein, da es keine Standardmethode zum Speichern gibt. Im Folgenden erfährst du, wie die beiden gängigsten Encoder, LAME und iTunes, ihre lückenlosen Metadaten speichern. Zuerst richten wir einige Hilfsmethoden und eine Übersicht für die oben verwendete ParseGaplessData() ein.

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

Wir befassen uns zuerst mit dem iTunes-Metadatenformat von Apple, da es sich am einfachsten parsen und erklären lässt. In MP3- und M4A-Dateien schreiben iTunes (und afconvert) einen kurzen Abschnitt in ASCII so:

    iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Sie wird in ein ID3-Tag innerhalb des MP3-Containers und in ein Metadatenatom innerhalb des MP4-Containers geschrieben. Für unsere Zwecke können wir das erste 0000000-Token ignorieren. Die nächsten drei Tokens sind das Auffüllen am Anfang, das Auffüllen am Ende und die Gesamtanzahl der Stichproben ohne Auffüllung. Wenn wir diese jeweils durch die Abtastrate der Audiodaten teilen, erhalten wir die jeweilige Dauer.

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

Auf der anderen Seite speichern die meisten Open-Source-MP3-Encoder die lückenlosen Metadaten in einem speziellen Xing-Header, der sich innerhalb eines stillen MPEG-Frames befindet. Dieser ist stumm, sodass Decodierer, die den Xing-Header nicht verstehen, einfach Stille abspielen. Leider ist dieses Tag nicht immer vorhanden und enthält eine Reihe optionaler Felder. In dieser Demo haben wir die Kontrolle über die Medien. In der Praxis sind jedoch zusätzliche Sensibilitätsprüfungen erforderlich, um zu ermitteln, ob lückenlose Metadaten tatsächlich verfügbar sind.

Zuerst analysieren wir die Gesamtstichprobenzahl. Der Einfachheit halber lesen wir dies aus dem Xing-Header, aber es könnte auch aus dem normalen MPEG-Audioheader erstellt werden. Xing-Header können entweder mit einem Xing- oder mit einem Info-Tag gekennzeichnet werden. Genau 4 Byte nach diesem Tag befinden sich 32 Bit, die die Gesamtzahl der Frames in der Datei darstellen. Wenn Sie diesen Wert mit der Anzahl der Stichproben pro Frame multiplizieren, erhalten wir die Gesamtzahl der Stichproben in der Datei.

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

Da wir nun die Gesamtzahl der Stichproben haben, können wir mit dem Auslesen der Anzahl der Padding-Beispiele fortfahren. Je nach Encoder kann dies unter einem LAME- oder Lavf-Tag geschrieben werden, das im Xing-Header verschachtelt ist. Genau 17 Byte nach diesem Header sind 3 Byte vorhanden, die das Front- und End-Padding in jeweils 12 Bits darstellen.

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

Damit haben wir eine vollständige Funktion zum Parsen des Großteils des lückenlosen Inhalts. Es gibt jedoch zahlreiche Grenzfälle, daher sollten Sie vorsichtig sein, wenn Sie ähnlichen Code in der Produktion verwenden.

Anhang C: Über die automatische Speicherbereinigung

Arbeitsspeicher, der zu SourceBuffer-Instanzen gehört, wird gemäß Inhaltstyp, plattformspezifischen Limits und der aktuellen Wiedergabeposition aktiv automatische Speicherbereinigung. In Chrome wird zuerst der Arbeitsspeicher aus bereits wiedergegebenen Zwischenspeichern freigegeben. Wenn die Arbeitsspeichernutzung jedoch plattformspezifische Limits überschreitet, wird Arbeitsspeicher aus nicht wiedergegebenen Zwischenspeichern entfernt.

Wenn die Wiedergabe aufgrund von freigegebenem Speicher eine Lücke in der Zeitachse erreicht, kann es zu einer Störung kommen, wenn der Abstand klein genug ist, oder vollständig blockiert werden, wenn er zu groß ist. Keines der beiden ist eine großartige Nutzererfahrung. Daher ist es wichtig, nicht zu viele Daten gleichzeitig anzuhängen und nicht mehr benötigte Bereiche manuell aus der Medienzeitachse zu entfernen.

Bereiche können mit der Methode remove() für jede SourceBuffer entfernt werden. Dafür wird ein Zeitraum von [start, end] in Sekunden benötigt. Ähnlich wie bei appendBuffer() löst jedes remove()-Ereignis nach Abschluss ein updateend-Ereignis aus. Andere Entfernungen oder Anfügungen sollten erst erfolgen, wenn das Ereignis ausgelöst wird.

In der Desktopversion von Chrome können Sie ungefähr 12 Megabyte an Audioinhalten und 150 Megabyte Videoinhalten gleichzeitig speichern. Sie sollten sich nicht auf verschiedenen Browsern oder Plattformen auf diese Werte verlassen. Sie sind also ganz sicher nicht repräsentativ für Mobilgeräte.

Die automatische Speicherbereinigung wirkt sich nur auf Daten aus, die SourceBuffers hinzugefügt wurden. Es gibt keine Beschränkungen dafür, wie viele Daten in JavaScript-Variablen zwischengespeichert werden können. Bei Bedarf können Sie dieselben Daten auch an derselben Position anhängen.

Feedback